Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
27 changes: 26 additions & 1 deletion ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using ImmichFrame.WebApi.Models;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using AwesomeAssertions;
using FluentAssertions;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

namespace ImmichFrame.WebApi.Tests.Helpers.Config;

Expand Down Expand Up @@ -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<string, string> { { "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<string, string> { { "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);
Expand Down
2 changes: 1 addition & 1 deletion ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
Expand Down
44 changes: 38 additions & 6 deletions ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -86,12 +87,23 @@ private IServerSettings LoadConfigRaw(string configPath)

throw new ImmichFrameException("Failed to load configuration");
}

internal T LoadConfigFromDictionary<T>(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>(T config, IDictionary env) where T : IConfigSettable
{
foreach (var key in env.Keys)
{
if (key == null) continue;
Expand All @@ -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<T>(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)
{
Expand Down
5 changes: 4 additions & 1 deletion ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "/";
}

/// <summary>
Expand All @@ -64,6 +65,7 @@ public class ServerSettingsV1 : IConfigSettable
/// <param name="_delegate">the V1 settings object to wrap</param>
public class ServerSettingsV1Adapter(ServerSettingsV1 _delegate) : IServerSettings
{
public ServerSettingsV1 Settings { get; } = _delegate;
public IEnumerable<IAccountSettings> Accounts => new List<AccountSettingsV1Adapter> { new(_delegate) };
public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(_delegate);

Expand Down Expand Up @@ -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() { }
}
}
}
4 changes: 3 additions & 1 deletion ImmichFrame.WebApi/Models/ClientSettingsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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;
}
}
15 changes: 14 additions & 1 deletion ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Webcalendars { get; set; } = new();
public int RefreshAlbumPeopleInterval { get; set; } = 12;
Expand All @@ -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
Expand Down
41 changes: 32 additions & 9 deletions ImmichFrame.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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($@"
Expand Down Expand Up @@ -80,6 +90,28 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___

var app = builder.Build();

var settings = app.Services.GetRequiredService<IGeneralSettings>();
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())
{
Expand All @@ -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<CustomAuthenticationMiddleware>();

Expand Down
2 changes: 2 additions & 0 deletions docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"ImageZoom": true,
"ImagePan": false,
"ImageFill": false,
"Layout": "splitview",
"BaseUrl": "/"
"PlayAudio": false,
"Layout": "splitview"
},
Expand Down
1 change: 1 addition & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ General:
ImageFill: false
PlayAudio: false
Layout: splitview
BaseUrl: '/'
Accounts:
- ImmichServerUrl: REQUIRED
# Exactly one of ApiKey or ApiKeyFile must be set.
Expand Down
3 changes: 3 additions & 0 deletions docker/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions immichFrame.Web/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@

<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- iOS PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ImmichFrame" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="favicon.png" />
<!-- Android/Desktop PWA -->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#000000" />
%sveltekit.head%
</head>
Expand All @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions immichFrame.Web/src/lib/immichFrameApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export type ClientSettingsDto = {
playAudio?: boolean;
layout?: string | null;
language?: string | null;
baseUrl?: string | null;
};
export type IWeather = {
location?: string | null;
Expand Down
Loading