diff --git a/Dockerfile b/Dockerfile index b4848f9e..89eeb27b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,13 @@ COPY --from=build-node /app/build ./wwwroot # Set non-privileged user ARG APP_UID=1000 + +# Ensure the app user owns the files they need to modify +RUN chown -R $APP_UID:$APP_UID /app/wwwroot + +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + 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 fea6c442..a809cba0 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -66,6 +66,7 @@ public interface IGeneralSettings public bool PlayAudio { 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 076f36da..767d7fc1 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -56,6 +56,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImageFill { get; set; } = false; public bool PlayAudio { get; set; } = false; public string Layout { get; set; } = "splitview"; + public string BaseUrl { get; set; } = "/"; } /// @@ -64,6 +65,7 @@ public class ServerSettingsV1 : IConfigSettable /// the V1 settings object to wrap public class ServerSettingsV1Adapter(ServerSettingsV1 _delegate) : IServerSettings { + public ServerSettingsV1 Settings { get; } = _delegate; public IEnumerable Accounts => new List { new(_delegate) }; public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(_delegate); @@ -135,7 +137,8 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool PlayAudio => _delegate.PlayAudio; public string Layout => _delegate.Layout; public string Language => _delegate.Language; + public string BaseUrl => _delegate.BaseUrl; public void Validate() { } } -} +} \ No newline at end of file diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index ff0f9e75..54843ee0 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -31,7 +31,8 @@ public class ClientSettingsDto public bool ImageFill { get; set; } public bool PlayAudio { 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) { @@ -64,6 +65,7 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.PlayAudio = generalSettings.PlayAudio; 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 74d0fb8e..540957eb 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -64,6 +64,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public bool ImageFill { get; set; } = false; public bool PlayAudio { 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; @@ -73,7 +74,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 a86a4d00..685d9f4d 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -34,6 +34,8 @@ "ImageZoom": true, "ImagePan": false, "ImageFill": false, + "Layout": "splitview", + "BaseUrl": "/" "PlayAudio": false, "Layout": "splitview" }, diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..edb15f16 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -34,6 +34,7 @@ General: ImageFill: false PlayAudio: 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 51d80ed5..de6e7f95 100644 --- a/docker/example.env +++ b/docker/example.env @@ -11,6 +11,9 @@ ApiKey=KEY # ImagePan=false # PlayAudio: 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 94e285d6..0933405b 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -104,6 +104,9 @@ General: PlayAudio: 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/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..2ebf02a5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +CONFIG_DIR="${IMMICHFRAME_CONFIG_PATH:-/app/Config}" + +if [ -n "$BaseUrl" ] && [ "$BaseUrl" != "/" ]; then + BASE_PATH=$(echo "$BaseUrl" | sed 's|/*$||') +else + FILE_BASE_URL="" + + if [ -f "$CONFIG_DIR/Settings.json" ]; then + FILE_BASE_URL=$(grep -o '"BaseUrl"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/Settings.json" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + fi + + if [ -z "$FILE_BASE_URL" ] && [ -f "$CONFIG_DIR/Settings.yml" ]; then + FILE_BASE_URL=$(grep -E '^[[:space:]]+BaseUrl:' "$CONFIG_DIR/Settings.yml" | head -1 | sed 's/.*BaseUrl:[[:space:]]*//' | tr -d "' \"") + fi + + if [ -z "$FILE_BASE_URL" ] && [ -f "$CONFIG_DIR/Settings.yaml" ]; then + FILE_BASE_URL=$(grep -E '^[[:space:]]+BaseUrl:' "$CONFIG_DIR/Settings.yaml" | head -1 | sed 's/.*BaseUrl:[[:space:]]*//' | tr -d "' \"") + fi + + if [ -n "$FILE_BASE_URL" ] && [ "$FILE_BASE_URL" != "/" ]; then + BASE_PATH=$(echo "$FILE_BASE_URL" | sed 's|/*$||') + else + BASE_PATH="" + fi +fi + +echo "Applying BaseUrl: $BASE_PATH" +find /app/wwwroot -type f \( -name "*.html" -o -name "*.js" -o -name "*.json" -o -name "*.webmanifest" -o -name "*.css" \) -exec sed -i "s|/__IMMICH_FRAME_BASE__|$BASE_PATH|g" {} + +exec dotnet ImmichFrame.WebApi.dll 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 e8dae934..6137865b 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -214,6 +214,7 @@ export type ClientSettingsDto = { playAudio?: 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..dc307d3d 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -1,11 +1,18 @@ 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 () => { + setBaseUrl(base + "/"); + const configRequest = await api.getConfig({ clientIdentifier: "" }); const config = configRequest.data; + if (config.baseUrl) { + setBaseUrl(config.baseUrl); + } 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.