Files
ersatztv/ErsatzTV/Controllers/Api/LogsController.cs
T
timothyandClaude Opus 4.8 37155c866b security(#197): fail-closed API auth, sensitive-read tier, CORS/ForwardedHeaders lockdown (Bundle A)
Backend of #197 Bundle A (auth posture). Owner decisions: single API key;
Api:RequireKeyForReads defaults true (whole /api surface gated; /iptv streaming
+ guide unaffected — outside the filter's /api scope).

- #280 S1: writes are fail-closed. New IApiKeyProvider resolves the key once
  (Api:WriteKey config, else persisted /config/api.key, else a generated 256-bit
  key written 0600). The empty-key open branch is gone; there is no open mode.
- #282 S3/S5: reads under /api require the key when Api:RequireKeyForReads (default
  true) or the endpoint carries the new [RequiresApiKey]. Applied [RequiresApiKey]
  to Troubleshoot/Logs/Settings/Maintenance so the sensitive tier stays gated even
  if reads are opened. OPTIONS preflight is exempt.
- #281 S2: delete SortController (dead Blazor SortableJS residue; SPA uses PUT
  /api/collections/{id}/custom-order) and AccountController (dead OIDC logout) —
  both non-/api persistent surfaces that bypassed the key.
- #284 S6: replace CORS AllowAll with an opt-in exact-origin allowlist
  (Api:CorsAllowedOrigins; permits X-Api-Key/If-Match, exposes ETag). Default is
  no cross-origin (SPA is same-origin).
- #285 S7/S10: gc GET->POST (spec regenerated); ForwardedHeaders trust configurable
  via ForwardedHeaders:KnownProxies/KnownNetworks (warns when unrestricted);
  ScannerController gains [LocalhostOnly] (scanner always calls back over localhost).

Filter unit tests rewritten for fail-closed + read-gating + tier + OPTIONS;
ApiControllerSecurityTests assert the sensitive tier + scanner-loopback reflectively.
search/all-items paging deferred (SPA add-all coupling) — exposure closed by read-gating.

Refs #197 #280 #281 #282 #284 #285

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:03:55 +02:00

69 lines
2.7 KiB
C#

using System.Linq.Expressions;
using ErsatzTV.Application.Logs;
using ErsatzTV.Core.Api.Logs;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[RequiresApiKey]
public class LogsController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
// Timestamp/Level; Message was never sortable there either).
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
[HttpGet("/api/logs", Name = "GetLogs")]
[Tags("Logs")]
[EndpointSummary("Get recent log entries")]
[EndpointDescription(
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
"pre-existing default, newest first).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
? sortField!.ToLowerInvariant()
: "timestamp";
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
{
"level" => le => le.Level,
_ => le => le.Timestamp
};
PagedLogEntriesViewModel result = await mediator.Send(
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
{
SortExpression = sortExpression,
SortDescending = descending
},
cancellationToken);
return new PagedLogEntriesResponseModel(
result.TotalCount,
result.Page.Map(ProjectToResponseModel).ToList());
}
private static LogEntryResponseModel ProjectToResponseModel(LogEntryViewModel viewModel) =>
new(viewModel.Timestamp, viewModel.Level.ToString(), viewModel.Message);
}