Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,5 @@ $RECYCLE.BIN/

## Ignore Settings file
ImmichFrame/Settings.json
ImmichFrame.WebApi/Config/Settings.json
ImmichFrame.WebApi/Config/Settings.json
data/
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
<PackageVersion Include="Ical.Net" Version="4.3.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.20" />
<PackageVersion Include="Microsoft.AspNetCore.SpaProxy" Version="8.0.20" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.20" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.20" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.20" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Client" Version="8.0.20" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.5" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ ENV APP_VERSION=$VERSION
COPY --from=publish-api /app ./
COPY --from=build-node /app/build ./wwwroot

# Set non-privileged user
# Create persistent data dir for SQLite (mounted to /data in docker-compose)
ARG APP_UID=1000
USER root
RUN mkdir -p /data && chown -R ${APP_UID}:${APP_UID} /data

# Set non-privileged user
USER $APP_UID

ENTRYPOINT ["dotnet", "ImmichFrame.WebApi.dll"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Http.Json;
using ImmichFrame.Core.Interfaces;
using ImmichFrame.WebApi.Models;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;

namespace ImmichFrame.WebApi.Tests.Controllers;

[TestFixture]
public class ConfigControllerAccountOverridesTests
{
private WebApplicationFactory<Program> _factory = null!;

[TearDown]
public void TearDown()
{
_factory.Dispose();
}

[Test]
public async Task PutAccountOverrides_WhenAuthenticationSecretIsSet_RequiresBearerToken()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// isolate DB per test
Environment.SetEnvironmentVariable("IMMICHFRAME_DB_PATH",
Path.Combine(Path.GetTempPath(), $"immichframe-tests-{Guid.NewGuid()}.db"));

var generalSettings = new GeneralSettings { AuthenticationSecret = "secret" };
var accountSettings = new ServerAccountSettings
{
ImmichServerUrl = "http://mock",
ApiKey = "k"
};
var serverSettings = new ServerSettings
{
GeneralSettingsImpl = generalSettings,
AccountsImpl = new List<ServerAccountSettings> { accountSettings }
};

services.AddSingleton<IServerSettings>(serverSettings);
services.AddSingleton<IGeneralSettings>(generalSettings);
});
});

var client = _factory.CreateClient();

var put = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true });
Assert.That(put.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));

client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret");
var putOk = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true });
Assert.That(putOk.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}

[Test]
public async Task PutAccountOverrides_WhenAuthenticationSecretIsNotSet_DoesNotRequireAuth()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
Environment.SetEnvironmentVariable("IMMICHFRAME_DB_PATH",
Path.Combine(Path.GetTempPath(), $"immichframe-tests-{Guid.NewGuid()}.db"));

var generalSettings = new GeneralSettings { AuthenticationSecret = null };
var accountSettings = new ServerAccountSettings
{
ImmichServerUrl = "http://mock",
ApiKey = "k"
};
var serverSettings = new ServerSettings
{
GeneralSettingsImpl = generalSettings,
AccountsImpl = new List<ServerAccountSettings> { accountSettings }
};

services.AddSingleton<IServerSettings>(serverSettings);
services.AddSingleton<IGeneralSettings>(generalSettings);
});
});

var client = _factory.CreateClient();
var put = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true });
Assert.That(put.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}


59 changes: 59 additions & 0 deletions ImmichFrame.WebApi.Tests/Services/AccountOverrideMergerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using ImmichFrame.WebApi.Models;
using ImmichFrame.WebApi.Services;
using NUnit.Framework;

namespace ImmichFrame.WebApi.Tests.Services;

[TestFixture]
public class AccountOverrideMergerTests
{
[Test]
public void Apply_WhenOverridesAreNullLike_DoesNotChangeBase()
{
var baseAccount = new ServerAccountSettings
{
ImmichServerUrl = "http://mock",
ApiKey = "k",
ShowFavorites = false,
ShowMemories = false,
ShowArchived = false,
ImagesFromDays = 7,
Rating = 3,
Albums = new(),
ExcludedAlbums = new(),
People = new()
};

var overrides = new AccountOverrideDto(); // all null
AccountOverrideMerger.Apply(baseAccount, overrides);

Assert.That(baseAccount.ShowFavorites, Is.False);
Assert.That(baseAccount.ImagesFromDays, Is.EqualTo(7));
Assert.That(baseAccount.Rating, Is.EqualTo(3));
}

[Test]
public void Apply_WhenOverrideSetsEmptyLists_ClearsSelections()
{
var baseAccount = new ServerAccountSettings
{
ImmichServerUrl = "http://mock",
ApiKey = "k",
Albums = new() { Guid.NewGuid() },
People = new() { Guid.NewGuid() }
};

var overrides = new AccountOverrideDto
{
Albums = new List<Guid>(),
People = new List<Guid>()
};

AccountOverrideMerger.Apply(baseAccount, overrides);

Assert.That(baseAccount.Albums, Is.Empty);
Assert.That(baseAccount.People, Is.Empty);
}
}


155 changes: 154 additions & 1 deletion ImmichFrame.WebApi/Controllers/ConfigController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using ImmichFrame.Core.Interfaces;
using ImmichFrame.WebApi.Models;
using ImmichFrame.WebApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text;

namespace ImmichFrame.WebApi.Controllers
{
Expand All @@ -10,11 +13,28 @@ public class ConfigController : ControllerBase
{
private readonly ILogger<AssetController> _logger;
private readonly IGeneralSettings _settings;
private readonly IAccountOverrideStore _overrideStore;
private readonly IAccountOverrideChangeNotifier _overrideChangeNotifier;
private readonly IGeneralSettingsStore _generalStore;
private readonly GeneralSettingsRuntime _generalRuntime;
private readonly IGeneralSettingsChangeNotifier _generalChangeNotifier;

public ConfigController(ILogger<AssetController> logger, IGeneralSettings settings)
public ConfigController(
ILogger<AssetController> logger,
IGeneralSettings settings,
IAccountOverrideStore overrideStore,
IAccountOverrideChangeNotifier overrideChangeNotifier,
IGeneralSettingsStore generalStore,
GeneralSettingsRuntime generalRuntime,
IGeneralSettingsChangeNotifier generalChangeNotifier)
{
_logger = logger;
_settings = settings;
_overrideStore = overrideStore;
_overrideChangeNotifier = overrideChangeNotifier;
_generalStore = generalStore;
_generalRuntime = generalRuntime;
_generalChangeNotifier = generalChangeNotifier;
}

[HttpGet(Name = "GetConfig")]
Expand All @@ -30,5 +50,138 @@ public string GetVersion()
{
return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
}

[HttpGet("account-overrides", Name = "GetAccountOverrides")]
public async Task<ActionResult<AccountOverrideDto?>> GetAccountOverrides(CancellationToken ct)
{
var dto = await _overrideStore.GetAsync(ct);
return Ok(dto);
}

[HttpGet("account-overrides/version", Name = "GetAccountOverridesVersion")]
public async Task<ActionResult<long>> GetAccountOverridesVersion(CancellationToken ct)
{
var version = await _overrideStore.GetVersionAsync(ct);
return Ok(version);
}

/// <summary>
/// Server-Sent Events stream: emits the override "version" (ticks) whenever account overrides change.
/// </summary>
[HttpGet("account-overrides/events", Name = "GetAccountOverridesEvents")]
public async Task GetAccountOverridesEvents(CancellationToken ct)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("X-Accel-Buffering", "no");

// Send current version immediately (helps clients initialize).
var initialVersion = await _overrideStore.GetVersionAsync(ct);
await WriteSseAsync("account-overrides", initialVersion.ToString(), ct);

var reader = _overrideChangeNotifier.Subscribe(ct);
while (!ct.IsCancellationRequested && await reader.WaitToReadAsync(ct))
{
while (reader.TryRead(out var version))
{
await WriteSseAsync("account-overrides", version.ToString(), ct);
}
}
}

[Authorize]
[HttpPut("account-overrides", Name = "PutAccountOverrides")]
public async Task<IActionResult> PutAccountOverrides([FromBody] AccountOverrideDto dto, CancellationToken ct)
{
if (dto.ImagesFromDays is < 0)
return BadRequest("ImagesFromDays must be >= 0");

if (dto.ImagesFromDate.HasValue && dto.ImagesUntilDate.HasValue && dto.ImagesFromDate > dto.ImagesUntilDate)
return BadRequest("ImagesFromDate must be <= ImagesUntilDate");

if (dto.Rating is < 0 or > 5)
return BadRequest("Rating must be between 0 and 5");

await _overrideStore.UpsertAsync(dto, ct);
var version = await _overrideStore.GetVersionAsync(ct);
_overrideChangeNotifier.NotifyChanged(version);
return Ok();
}

[Authorize]
[HttpGet("general-settings", Name = "GetGeneralSettings")]
public async Task<ActionResult<GeneralSettingsDto>> GetGeneralSettings(CancellationToken ct)
{
// Always return a non-null snapshot
var dto = await _generalStore.GetAsync(ct) ?? await _generalStore.GetOrCreateFromBaseAsync(_settings, ct);
return Ok(dto);
}

[HttpGet("general-settings/version", Name = "GetGeneralSettingsVersion")]
public async Task<ActionResult<long>> GetGeneralSettingsVersion(CancellationToken ct)
{
var version = await _generalStore.GetVersionAsync(ct);
return Ok(version);
}

/// <summary>
/// Server-Sent Events stream: emits the general settings "version" (ticks) whenever general settings change.
/// Kept unauthenticated so browser EventSource works even when AuthenticationSecret is enabled.
/// </summary>
[HttpGet("general-settings/events", Name = "GetGeneralSettingsEvents")]
public async Task GetGeneralSettingsEvents(CancellationToken ct)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("X-Accel-Buffering", "no");

var initialVersion = await _generalStore.GetVersionAsync(ct);
await WriteSseAsync("general-settings", initialVersion.ToString(), ct);

var reader = _generalChangeNotifier.Subscribe(ct);
while (!ct.IsCancellationRequested && await reader.WaitToReadAsync(ct))
{
while (reader.TryRead(out var version))
{
await WriteSseAsync("general-settings", version.ToString(), ct);
}
}
}

[Authorize]
[HttpPut("general-settings", Name = "PutGeneralSettings")]
public async Task<IActionResult> PutGeneralSettings([FromBody] GeneralSettingsDto dto, CancellationToken ct)
{
if (dto.Interval <= 0) return BadRequest("Interval must be > 0");
if (dto.TransitionDuration < 0) return BadRequest("TransitionDuration must be >= 0");
if (dto.RenewImagesDuration < 0) return BadRequest("RenewImagesDuration must be >= 0");
if (dto.RefreshAlbumPeopleInterval < 0) return BadRequest("RefreshAlbumPeopleInterval must be >= 0");
if (string.IsNullOrWhiteSpace(dto.Language)) return BadRequest("Language is required");
if (string.IsNullOrWhiteSpace(dto.Style)) return BadRequest("Style is required");
if (string.IsNullOrWhiteSpace(dto.Layout)) return BadRequest("Layout is required");

dto.Webcalendars ??= new();

await _generalStore.UpsertAsync(dto, ct);
var version = await _generalStore.GetVersionAsync(ct);

// Update live runtime settings so weather/calendar/webhook clients update without restart.
_generalRuntime.ApplyFromDb(dto, version);
_generalChangeNotifier.NotifyChanged(version);
return Ok();
}

private async Task WriteSseAsync(string eventName, string data, CancellationToken ct)
{
// SSE format:
// event: <name>
// data: <payload>
// \n
var payload = $"event: {eventName}\n" +
$"data: {data}\n\n";
var bytes = Encoding.UTF8.GetBytes(payload);
await Response.Body.WriteAsync(bytes, ct);
await Response.Body.FlushAsync(ct);
}
}
}
Loading