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
46 changes: 45 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
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;

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
3 changes: 3 additions & 0 deletions ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "/";
}

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

Expand Down Expand Up @@ -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() { }
}
Expand Down
4 changes: 3 additions & 1 deletion ImmichFrame.WebApi/Models/ClientSettingsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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;
}
}
15 changes: 14 additions & 1 deletion ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Webcalendars { get; set; } = new();
public int RefreshAlbumPeopleInterval { get; set; } = 12;
Expand All @@ -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
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
3 changes: 2 additions & 1 deletion docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"ImageZoom": true,
"ImagePan": false,
"ImageFill": false,
"Layout": "splitview"
"Layout": "splitview",
"BaseUrl": "/"
},
"Accounts": [
{
Expand Down
1 change: 1 addition & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ General:
ImagePan: false
ImageFill: 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 @@ -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
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 @@ -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:
Expand Down
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 @@ -212,6 +212,7 @@ export type ClientSettingsDto = {
imageFill?: boolean;
layout?: string | null;
language?: string | null;
baseUrl?: string | null;
};
export type IWeather = {
location?: string | null;
Expand Down
Loading