diff --git a/Dockerfile b/Dockerfile index b4848f9e..8ce330fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,50 @@ COPY --from=build-node /app/build ./wwwroot # Set non-privileged user ARG APP_UID=1000 +# Don't use USER $APP_UID yet, as we need to write entrypoint.sh and then use it to perform runtime operations + +# Create entrypoint script +RUN echo '#!/bin/bash\n\ +set -e\n\ +\n\ +# Use BASE_URL env var if set, otherwise try to extract from Settings.json/yml if they exist, else default to /\n\ +BASE_PATH="${BASE_URL}"\n\ +\n\ +if [ -z "$BASE_PATH" ]; then\n\ + if [ -f "/app/Config/Settings.json" ]; then\n\ + BASE_PATH=$(grep -oE "\"BaseUrl\":\s*\"[^\"]+\"" /app/Config/Settings.json | cut -d\" -f4 || echo "/")\n\ + elif [ -f "/app/Config/Settings.yml" ]; then\n\ + BASE_PATH=$(grep -E "^BaseUrl:" /app/Config/Settings.yml | awk \"{print \$2}\" || echo "/")\n\ + fi\n\ +fi\n\ +\n\ +BASE_PATH=${BASE_PATH:-/}\n\ +\n\ +# Ensure BASE_PATH starts with / and does not end with / (unless it is just /)\n\ +[[ "${BASE_PATH}" != /* ]] && BASE_PATH="/${BASE_PATH}"\n\ +[[ "${BASE_PATH}" != "/" ]] && BASE_PATH="${BASE_PATH%/}"\n\ +\n\ +echo "Setting base path to ${BASE_PATH}"\n\ +\n\ +# Copy wwwroot to runtime directory to keep original clean and allow re-runs\n\ +cp -rf /app/wwwroot /app/wwwroot-runtime\n\ +\n\ +# Replace placeholder with actual base path in the runtime copy\n\ +find /app/wwwroot-runtime -type f -exec sed -i "s|/__IMMICH_FRAME_BASE__|${BASE_PATH}|g" {} +\n\ +\n\ +# We need to make sure the app serves from wwwroot-runtime or we symlink it\n\ +rm -rf /app/wwwroot && ln -s /app/wwwroot-runtime /app/wwwroot\n\ +\n\ +exec dotnet ImmichFrame.WebApi.dll "$@"' > /app/entrypoint.sh \ + && chmod +x /app/entrypoint.sh + +# Now we can set the user, but we need to make sure the user has permissions to the runtime directory +# Actually, it might be better to run as root for the setup part of the entrypoint and then gosu/su-exec +# but the original Dockerfile uses USER $APP_UID. +# If we want to stay with USER $APP_UID, we must ensure /app is writable by it. + +RUN chown -R $APP_UID /app + USER $APP_UID -ENTRYPOINT ["dotnet", "ImmichFrame.WebApi.dll"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 0141af37..a23f22b5 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -62,6 +62,7 @@ public interface IGeneralSettings public bool ImageFill { get; } public string Layout { get; } public string Language { get; } + public string? BaseUrl { get; } public void Validate(); } diff --git a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs index 15c3254b..a478e8de 100644 --- a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs +++ b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs @@ -6,7 +6,7 @@ using ImmichFrame.WebApi.Models; using Microsoft.Extensions.Logging; using NUnit.Framework; -using AwesomeAssertions; +using FluentAssertions; namespace ImmichFrame.WebApi.Tests.Helpers.Config; @@ -68,6 +68,31 @@ public void TestLoadConfigV2Yaml() VerifyConfig(config, true, false); } + [Test] + public void TestApplyEnvironmentVariables_V1() + { + var v1 = new ServerSettingsV1 { BaseUrl = "/" }; + var adapter = new ServerSettingsV1Adapter(v1); + + var env = new Dictionary { { "BaseUrl", "'/new-path'" } }; + + _configLoader.MapDictionaryToConfig(v1, env); + + Assert.That(v1.BaseUrl, Is.EqualTo("/new-path")); + } + + [Test] + public void TestApplyEnvironmentVariables_V2() + { + var settings = new ServerSettings { GeneralSettingsImpl = new GeneralSettings { BaseUrl = "/" } }; + + var env = new Dictionary { { "BaseUrl", "\"/new-path\"" } }; + + _configLoader.MapDictionaryToConfig(settings.GeneralSettingsImpl, env); + + Assert.That(settings.GeneralSettings.BaseUrl, Is.EqualTo("/new-path")); + } + private void VerifyConfig(IServerSettings serverSettings, bool usePrefix, bool expectNullApiKeyFile) { VerifyProperties(serverSettings.GeneralSettings); diff --git a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj index 5bb9e4d9..02262eb0 100644 --- a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj +++ b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs b/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs index 58782cba..c515de99 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs @@ -23,6 +23,7 @@ private string FindConfigFile(string dir, params string[] fileNames) public IServerSettings LoadConfig(string configPath) { var config = LoadConfigRaw(configPath); + ApplyEnvironmentVariables(config); config.Validate(); return config; } @@ -86,12 +87,23 @@ private IServerSettings LoadConfigRaw(string configPath) throw new ImmichFrameException("Failed to load configuration"); } - - internal T LoadConfigFromDictionary(IDictionary env) where T : IConfigSettable, new() + private void ApplyEnvironmentVariables(IServerSettings config) { - var config = new T(); - var propertiesSet = 0; + var env = Environment.GetEnvironmentVariables(); + if (config is ServerSettings serverSettings) + { + if (serverSettings.GeneralSettingsImpl == null) + serverSettings.GeneralSettingsImpl = new GeneralSettings(); + MapDictionaryToConfig(serverSettings.GeneralSettingsImpl, env); + } + else if (config is ServerSettingsV1Adapter v1Adapter) + { + MapDictionaryToConfig(v1Adapter.Settings, env); + } + } + internal void MapDictionaryToConfig(T config, IDictionary env) where T : IConfigSettable + { foreach (var key in env.Keys) { if (key == null) continue; @@ -100,10 +112,30 @@ private IServerSettings LoadConfigRaw(string configPath) if (propertyInfo != null) { - config.SetValue(propertyInfo, env[key]?.ToString() ?? string.Empty); - propertiesSet++; + var value = env[key]?.ToString() ?? string.Empty; + // Clean up quotes if present + if (value.StartsWith("'") && value.EndsWith("'")) + value = value.Substring(1, value.Length - 2); + if (value.StartsWith("\"") && value.EndsWith("\"")) + value = value.Substring(1, value.Length - 2); + + config.SetValue(propertyInfo, value); } } + } + internal T LoadConfigFromDictionary(IDictionary env) where T : IConfigSettable, new() + { + var config = new T(); + MapDictionaryToConfig(config, env); + + // Count set properties to see if we have anything + var propertiesSet = 0; + foreach (var key in env.Keys) + { + if (key == null) continue; + if (typeof(T).GetProperty(key.ToString() ?? string.Empty) != null) + propertiesSet++; + } if (propertiesSet < 2) { diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 1e6ae02b..75d6adfe 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -52,6 +52,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public string Layout { get; set; } = "splitview"; + public string? BaseUrl { get; set; } = "/"; } /// @@ -60,6 +61,7 @@ public class ServerSettingsV1 : IConfigSettable /// the V1 settings object to wrap public class ServerSettingsV1Adapter(ServerSettingsV1 _delegate) : IServerSettings { + public ServerSettingsV1 Settings => _delegate; public IEnumerable Accounts => new List { new(_delegate) }; public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(_delegate); @@ -126,6 +128,7 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool ImageFill => _delegate.ImageFill; public string Layout => _delegate.Layout; public string Language => _delegate.Language; + public string? BaseUrl => _delegate.BaseUrl; public void Validate() { } } diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index 264824a7..f9e966cb 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -29,7 +29,8 @@ public class ClientSettingsDto public bool ImagePan { get; set; } public bool ImageFill { get; set; } public string Layout { get; set; } - public string Language { get; set; } + public string Language { get; set; } = string.Empty; + public string? BaseUrl { get; set; } public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSettings) { @@ -60,6 +61,7 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.ImageFill = generalSettings.ImageFill; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; + dto.BaseUrl = generalSettings.BaseUrl; return dto; } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 6f2ce348..c6dbb7f7 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -62,6 +62,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public string Layout { get; set; } = "splitview"; + public string? BaseUrl { get; set; } = "/"; public int RenewImagesDuration { get; set; } = 30; public List Webcalendars { get; set; } = new(); public int RefreshAlbumPeopleInterval { get; set; } = 12; @@ -71,7 +72,19 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public string? Webhook { get; set; } public string? AuthenticationSecret { get; set; } - public void Validate() { } + public void Validate() + { + if (!string.IsNullOrEmpty(BaseUrl) && !BaseUrl.StartsWith('/')) + { + throw new InvalidOperationException("BaseUrl must start with '/' or be empty."); + } + + // Normalize trailing slash for consistency + if (!string.IsNullOrEmpty(BaseUrl) && BaseUrl != "/" && BaseUrl.EndsWith('/')) + { + BaseUrl = BaseUrl.TrimEnd('/'); + } + } } public class ServerAccountSettings : IAccountSettings, IConfigSettable diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..d922c95b 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -8,6 +8,16 @@ using ImmichFrame.WebApi.Helpers.Config; var builder = WebApplication.CreateBuilder(args); + +if (builder.Environment.IsDevelopment()) +{ + var root = Directory.GetCurrentDirectory(); + var dotenv = Path.Combine(root, "..", "docker", ".env"); + + dotenv = Path.GetFullPath(dotenv); + DotEnv.Load(dotenv); +} + //log the version number var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; Console.WriteLine($@" @@ -80,6 +90,28 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ var app = builder.Build(); +var settings = app.Services.GetRequiredService(); +var baseUrl = settings.BaseUrl?.TrimEnd('/'); + +if (!string.IsNullOrEmpty(baseUrl) && baseUrl != "/") +{ + app.UsePathBase(baseUrl); + + // Ensure that requests not starting with BaseUrl do not fall through to the app + app.Use(async (context, next) => + { + if (!context.Request.PathBase.HasValue || !context.Request.PathBase.Value.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("Not Found"); + return; + } + await next(); + }); +} + +app.UseRouting(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -93,15 +125,6 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ app.UseDefaultFiles(); } -if (app.Environment.IsDevelopment()) -{ - var root = Directory.GetCurrentDirectory(); - var dotenv = Path.Combine(root, "..", "docker", ".env"); - - dotenv = Path.GetFullPath(dotenv); - DotEnv.Load(dotenv); -} - // app.UseHttpsRedirection(); app.UseMiddleware(); diff --git a/docker/Settings.example.json b/docker/Settings.example.json index fe37b5a5..f8b08c16 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -34,7 +34,8 @@ "ImageZoom": true, "ImagePan": false, "ImageFill": false, - "Layout": "splitview" + "Layout": "splitview", + "BaseUrl": "/" }, "Accounts": [ { diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 5662b364..30480d52 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -33,6 +33,7 @@ General: ImagePan: false ImageFill: false Layout: splitview + BaseUrl: '/' Accounts: - ImmichServerUrl: REQUIRED # Exactly one of ApiKey or ApiKeyFile must be set. diff --git a/docker/example.env b/docker/example.env index 75e875fd..411b4d0e 100644 --- a/docker/example.env +++ b/docker/example.env @@ -10,6 +10,9 @@ ApiKey=KEY # ImageZoom=true # ImagePan=false # Layout=splitview +# BaseUrl: Set the base path for reverse proxy deployments (default: /) +# Example: BaseUrl=/immichframe for hosting at https://example.com/immichframe +# BaseUrl=/ # DownloadImages=false # ShowMemories=false # ShowFavorites=false diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index fbfac287..e751965e 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -98,6 +98,9 @@ General: ImageFill: false # boolean # Allow two portrait images to be displayed next to each other Layout: 'splitview' # single | splitview + # The base URL the app is hosted on. Useful when using a reverse proxy. + # Example: For https://example.com/immichframe, set this to '/immichframe' + BaseUrl: '/' # string # multiple accounts permitted Accounts: diff --git a/immichFrame.Web/src/app.html b/immichFrame.Web/src/app.html index b6bfde3b..3c814da7 100644 --- a/immichFrame.Web/src/app.html +++ b/immichFrame.Web/src/app.html @@ -3,15 +3,15 @@ - + - + - + %sveltekit.head% @@ -22,7 +22,7 @@ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker - .register('/pwa-service-worker.js') + .register('pwa-service-worker.js') .then(() => console.log('PWA service worker registered')) .catch(console.error); }); diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index 4237d089..8890d2f6 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -212,6 +212,7 @@ export type ClientSettingsDto = { imageFill?: boolean; layout?: string | null; language?: string | null; + baseUrl?: string | null; }; export type IWeather = { location?: string | null; diff --git a/immichFrame.Web/src/routes/+page.ts b/immichFrame.Web/src/routes/+page.ts index c1ddd91b..2e8a92b7 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -1,11 +1,20 @@ import * as api from '$lib/immichFrameApi'; import { configStore } from '$lib/stores/config.store.js' +import { setBaseUrl } from '$lib/index.js'; +import { base } from '$app/paths'; export const load = async () => { + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + setBaseUrl(normalizedBase + "/"); + const configRequest = await api.getConfig({ clientIdentifier: "" }); const config = configRequest.data; + if (config.baseUrl) { + const normalizedUrl = config.baseUrl.endsWith('/') ? config.baseUrl : config.baseUrl + '/'; + setBaseUrl(normalizedUrl); + } configStore.ps(config); }; diff --git a/immichFrame.Web/static/manifest.webmanifest b/immichFrame.Web/static/manifest.webmanifest index 1f2219c8..b128967b 100644 --- a/immichFrame.Web/static/manifest.webmanifest +++ b/immichFrame.Web/static/manifest.webmanifest @@ -1,18 +1,18 @@ { "name": "ImmichFrame", "short_name": "ImmichFrame", - "start_url": "/", + "start_url": "/__IMMICH_FRAME_BASE__/", "display": "fullscreen", "background_color": "#000000", "theme_color": "#000000", "icons": [ { - "src": "/logo_192.png", + "src": "/__IMMICH_FRAME_BASE__/logo_192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/logo_512.png", + "src": "/__IMMICH_FRAME_BASE__/logo_512.png", "sizes": "512x512", "type": "image/png" } diff --git a/immichFrame.Web/svelte.config.js b/immichFrame.Web/svelte.config.js index 113bbc9b..54983099 100644 --- a/immichFrame.Web/svelte.config.js +++ b/immichFrame.Web/svelte.config.js @@ -9,6 +9,9 @@ const config = { preprocess: vitePreprocess({ script: true }), kit: { + paths: { + base: '/__IMMICH_FRAME_BASE__' + }, // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters.