From 738e710d510f834f3f44d7f3f9962805de683bcf Mon Sep 17 00:00:00 2001 From: brady Date: Tue, 6 Jan 2026 15:52:47 -0500 Subject: [PATCH 1/6] Initial commit to support modifying baseUrl through environment variables or config. --- ImmichFrame.Core/Interfaces/IServerSettings.cs | 1 + ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs | 2 ++ ImmichFrame.WebApi/Models/ClientSettingsDto.cs | 2 ++ ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + immichFrame.Web/src/lib/immichFrameApi.ts | 1 + immichFrame.Web/src/routes/+page.ts | 4 ++++ 6 files changed, 11 insertions(+) diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 0141af37..a199f784 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/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 1e6ae02b..e2c9fac4 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; } = "/"; } /// @@ -126,6 +127,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..3b4d9f6d 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -30,6 +30,7 @@ public class ClientSettingsDto public bool ImageFill { get; set; } public string Layout { get; set; } public string Language { get; set; } + 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..3c48c34c 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; 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..27feb190 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -1,11 +1,15 @@ import * as api from '$lib/immichFrameApi'; import { configStore } from '$lib/stores/config.store.js' +import { setBaseUrl } from '$lib/index.js'; export const load = async () => { const configRequest = await api.getConfig({ clientIdentifier: "" }); const config = configRequest.data; + if (config.baseUrl) { + setBaseUrl(config.baseUrl); + } configStore.ps(config); }; From a6cef9f1581158bfe90d8dbab572eb44687ec8ac Mon Sep 17 00:00:00 2001 From: brady Date: Tue, 6 Jan 2026 15:56:38 -0500 Subject: [PATCH 2/6] Documentation changes to support baseUrl --- docker/Settings.example.json | 3 ++- docker/Settings.example.yml | 1 + docker/example.env | 1 + docs/docs/getting-started/configuration.md | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) 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..a94e97ca 100644 --- a/docker/example.env +++ b/docker/example.env @@ -10,6 +10,7 @@ ApiKey=KEY # ImageZoom=true # ImagePan=false # Layout=splitview +# 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..6e3e6bd3 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -98,6 +98,8 @@ 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. + BaseUrl: '/' # string # multiple accounts permitted Accounts: From c2fff3463ebb47b1e5b4961ab1c6a52fc6ea60ea Mon Sep 17 00:00:00 2001 From: brady Date: Wed, 7 Jan 2026 11:09:11 -0500 Subject: [PATCH 3/6] Additional changes for proper re-writes of the BaseUrl at docker startup. Return 404 for incorrect BaseUrl requests --- Dockerfile | 17 ++- .../Helpers/Config/ConfigLoaderTest.cs | 27 ++++- .../Helpers/Config/ConfigLoader.cs | 44 +++++++- .../Helpers/Config/ServerSettingsV1.cs | 105 +++++++++--------- ImmichFrame.WebApi/Program.cs | 41 +++++-- immichFrame.Web/src/app.html | 8 +- immichFrame.Web/src/routes/+page.ts | 3 + immichFrame.Web/static/manifest.webmanifest | 6 +- immichFrame.Web/svelte.config.js | 3 + 9 files changed, 178 insertions(+), 76 deletions(-) diff --git a/Dockerfile b/Dockerfile index b4848f9e..94c25326 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,21 @@ 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 + +# Create a startup script to handle BaseUrl replacement +RUN echo '#!/bin/sh\n\ +if [ -n "$BaseUrl" ] && [ "$BaseUrl" != "/" ]; then\n\ + BASE_PATH=$(echo "$BaseUrl" | sed "s|/*$||")\n\ +else\n\ + BASE_PATH=""\n\ +fi\n\ +echo "Applying BaseUrl: $BASE_PATH"\n\ +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" {} +\n\ +exec dotnet ImmichFrame.WebApi.dll' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh + USER $APP_UID -ENTRYPOINT ["dotnet", "ImmichFrame.WebApi.dll"] +ENTRYPOINT ["/app/entrypoint.sh"] 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/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 e2c9fac4..0a9ff044 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -58,11 +58,12 @@ public class ServerSettingsV1 : IConfigSettable /// /// Adapter to present a SettingsV1 object as an IServerSettings /// -/// the V1 settings object to wrap -public class ServerSettingsV1Adapter(ServerSettingsV1 _delegate) : IServerSettings +/// the V1 settings object to wrap +public class ServerSettingsV1Adapter(ServerSettingsV1 Settings) : IServerSettings { - public IEnumerable Accounts => new List { new(_delegate) }; - public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(_delegate); + public ServerSettingsV1 Settings { get; } = Settings; + public IEnumerable Accounts => new List { new(Settings) }; + public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(Settings); public void Validate() { @@ -73,61 +74,61 @@ public void Validate() } } - class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings + class AccountSettingsV1Adapter(ServerSettingsV1 Settings) : IAccountSettings { - public string ImmichServerUrl => _delegate.ImmichServerUrl; - public string ApiKey => _delegate.ApiKey; + public string ImmichServerUrl => Settings.ImmichServerUrl; + public string ApiKey => Settings.ApiKey; public string? ApiKeyFile => null; // V1 settings didn't support paths to api keys. - public bool ShowMemories => _delegate.ShowMemories; - public bool ShowFavorites => _delegate.ShowFavorites; - public bool ShowArchived => _delegate.ShowArchived; - public int? ImagesFromDays => _delegate.ImagesFromDays; - public DateTime? ImagesFromDate => _delegate.ImagesFromDate; - public DateTime? ImagesUntilDate => _delegate.ImagesUntilDate; - public List Albums => _delegate.Albums; - public List ExcludedAlbums => _delegate.ExcludedAlbums; - public List People => _delegate.People; - public int? Rating => _delegate.Rating; + public bool ShowMemories => Settings.ShowMemories; + public bool ShowFavorites => Settings.ShowFavorites; + public bool ShowArchived => Settings.ShowArchived; + public int? ImagesFromDays => Settings.ImagesFromDays; + public DateTime? ImagesFromDate => Settings.ImagesFromDate; + public DateTime? ImagesUntilDate => Settings.ImagesUntilDate; + public List Albums => Settings.Albums; + public List ExcludedAlbums => Settings.ExcludedAlbums; + public List People => Settings.People; + public int? Rating => Settings.Rating; public void ValidateAndInitialize() { } } - class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings + class GeneralSettingsV1Adapter(ServerSettingsV1 Settings) : IGeneralSettings { - public List Webcalendars => _delegate.Webcalendars; - public int RefreshAlbumPeopleInterval => _delegate.RefreshAlbumPeopleInterval; - public string? WeatherApiKey => _delegate.WeatherApiKey; - public string? WeatherLatLong => _delegate.WeatherLatLong; - public string? UnitSystem => _delegate.UnitSystem; - public string? Webhook => _delegate.Webhook; - public string? AuthenticationSecret => _delegate.AuthenticationSecret; - public int Interval => _delegate.Interval; - public double TransitionDuration => _delegate.TransitionDuration; - public bool DownloadImages => _delegate.DownloadImages; - public int RenewImagesDuration => _delegate.RenewImagesDuration; - public bool ShowClock => _delegate.ShowClock; - public string? ClockFormat => _delegate.ClockFormat; - public string? ClockDateFormat => _delegate.ClockDateFormat; - public bool ShowProgressBar => _delegate.ShowProgressBar; - public bool ShowPhotoDate => _delegate.ShowPhotoDate; - public string? PhotoDateFormat => _delegate.PhotoDateFormat; - public bool ShowImageDesc => _delegate.ShowImageDesc; - public bool ShowPeopleDesc => _delegate.ShowPeopleDesc; - public bool ShowAlbumName => _delegate.ShowAlbumName; - public bool ShowImageLocation => _delegate.ShowImageLocation; - public string? ImageLocationFormat => _delegate.ImageLocationFormat; - public string? PrimaryColor => _delegate.PrimaryColor; - public string? SecondaryColor => _delegate.SecondaryColor; - public string Style => _delegate.Style; - public string? BaseFontSize => _delegate.BaseFontSize; - public bool ShowWeatherDescription => _delegate.ShowWeatherDescription; - public string? WeatherIconUrl => _delegate.WeatherIconUrl; - public bool ImageZoom => _delegate.ImageZoom; - public bool ImagePan => _delegate.ImagePan; - public bool ImageFill => _delegate.ImageFill; - public string Layout => _delegate.Layout; - public string Language => _delegate.Language; - public string BaseUrl => _delegate.BaseUrl; + public List Webcalendars => Settings.Webcalendars; + public int RefreshAlbumPeopleInterval => Settings.RefreshAlbumPeopleInterval; + public string? WeatherApiKey => Settings.WeatherApiKey; + public string? WeatherLatLong => Settings.WeatherLatLong; + public string? UnitSystem => Settings.UnitSystem; + public string? Webhook => Settings.Webhook; + public string? AuthenticationSecret => Settings.AuthenticationSecret; + public int Interval => Settings.Interval; + public double TransitionDuration => Settings.TransitionDuration; + public bool DownloadImages => Settings.DownloadImages; + public int RenewImagesDuration => Settings.RenewImagesDuration; + public bool ShowClock => Settings.ShowClock; + public string? ClockFormat => Settings.ClockFormat; + public string? ClockDateFormat => Settings.ClockDateFormat; + public bool ShowProgressBar => Settings.ShowProgressBar; + public bool ShowPhotoDate => Settings.ShowPhotoDate; + public string? PhotoDateFormat => Settings.PhotoDateFormat; + public bool ShowImageDesc => Settings.ShowImageDesc; + public bool ShowPeopleDesc => Settings.ShowPeopleDesc; + public bool ShowAlbumName => Settings.ShowAlbumName; + public bool ShowImageLocation => Settings.ShowImageLocation; + public string? ImageLocationFormat => Settings.ImageLocationFormat; + public string? PrimaryColor => Settings.PrimaryColor; + public string? SecondaryColor => Settings.SecondaryColor; + public string Style => Settings.Style; + public string? BaseFontSize => Settings.BaseFontSize; + public bool ShowWeatherDescription => Settings.ShowWeatherDescription; + public string? WeatherIconUrl => Settings.WeatherIconUrl; + public bool ImageZoom => Settings.ImageZoom; + public bool ImagePan => Settings.ImagePan; + public bool ImageFill => Settings.ImageFill; + public string Layout => Settings.Layout; + public string Language => Settings.Language; + public string BaseUrl => Settings.BaseUrl; public void Validate() { } } 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/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/routes/+page.ts b/immichFrame.Web/src/routes/+page.ts index 27feb190..dc307d3d 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -1,9 +1,12 @@ 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; 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. From 0ba35dc804bc3820378546c5b5c84eef5f02c9d6 Mon Sep 17 00:00:00 2001 From: brady Date: Wed, 7 Jan 2026 11:52:31 -0500 Subject: [PATCH 4/6] Implement CodeRabbitAI recommendations --- Dockerfile | 46 ++++++++++++++++++- .../Interfaces/IServerSettings.cs | 1 + .../Helpers/Config/ConfigLoaderTest.cs | 2 +- .../ImmichFrame.WebApi.Tests.csproj | 1 + .../Helpers/Config/ServerSettingsV1.cs | 2 + .../Models/ClientSettingsDto.cs | 4 +- ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + 7 files changed, 54 insertions(+), 3 deletions(-) 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..e0722358 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; diff --git a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj index 5bb9e4d9..37c97603 100644 --- a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj +++ b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 1e6ae02b..63fdeb67 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; } = "/"; } /// @@ -126,6 +127,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..e9da8f21 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; From eec82dd0920b91fcd2a5a9af7d4e4033939c49fd Mon Sep 17 00:00:00 2001 From: brady Date: Wed, 7 Jan 2026 12:15:29 -0500 Subject: [PATCH 5/6] Implement CodeRabbitAI nitpick recommendations --- .../ImmichFrame.WebApi.Tests.csproj | 1 - .../Helpers/Config/ServerSettingsV1.cs | 1 + ImmichFrame.WebApi/Models/ServerSettings.cs | 14 +++++++++++++- docker/example.env | 2 ++ docs/docs/getting-started/configuration.md | 1 + immichFrame.Web/src/routes/+page.ts | 3 ++- 6 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj index 37c97603..02262eb0 100644 --- a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj +++ b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj @@ -7,7 +7,6 @@ - diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 63fdeb67..75d6adfe 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -61,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); diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index e9da8f21..c6dbb7f7 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -72,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/docker/example.env b/docker/example.env index a94e97ca..411b4d0e 100644 --- a/docker/example.env +++ b/docker/example.env @@ -10,6 +10,8 @@ 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 diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 6e3e6bd3..e751965e 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -99,6 +99,7 @@ General: # 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 diff --git a/immichFrame.Web/src/routes/+page.ts b/immichFrame.Web/src/routes/+page.ts index dc307d3d..353d72c8 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -11,7 +11,8 @@ export const load = async () => { const config = configRequest.data; if (config.baseUrl) { - setBaseUrl(config.baseUrl); + const normalizedUrl = config.baseUrl.endsWith('/') ? config.baseUrl : config.baseUrl + '/'; + setBaseUrl(normalizedUrl); } configStore.ps(config); From 502f771fa20f0736867c8f2e2d89984c9f44370d Mon Sep 17 00:00:00 2001 From: brady Date: Wed, 7 Jan 2026 12:22:41 -0500 Subject: [PATCH 6/6] Implement CodeRabbitAI recommendations --- immichFrame.Web/src/routes/+page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/immichFrame.Web/src/routes/+page.ts b/immichFrame.Web/src/routes/+page.ts index 353d72c8..2e8a92b7 100644 --- a/immichFrame.Web/src/routes/+page.ts +++ b/immichFrame.Web/src/routes/+page.ts @@ -5,7 +5,8 @@ import { base } from '$app/paths'; export const load = async () => { - setBaseUrl(base + "/"); + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + setBaseUrl(normalizedBase + "/"); const configRequest = await api.getConfig({ clientIdentifier: "" });