Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ body:
attributes:
label: Telemetry instance ID (optional)
description: >
Only if telemetry is enabled and you're comfortable sharing it. Find it in Settings, then
Anonymous Telemetry, then Preview exact JSON (the `instance_id` field). This lets us correlate your
report with your anonymous pings. Heads up, pasting it into a public issue links that ID to your past
pings. Once you've shared it, you can regenerate your ID for future privacy using the
"Copy + regenerate ID" option in the preview modal.
Only if telemetry is enabled and you're comfortable sharing it. Find it on the Jellyscribe admin page under
Integrations, then Anonymous telemetry (it is shown there, and as `instance_id` in Preview). This lets us
correlate your report with your anonymous pings. Heads up, pasting it into a public issue links that ID to
your past pings. Once you've shared it, you can regenerate your ID for future privacy with Regenerate ID,
or the "Copy + regenerate ID" option in the Preview window.
placeholder: "e.g. 8a6f4f6e-1f2b-4c43-9a57-2f0e6f3b9d1c"
validations:
required: false
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Deploy a build to a Jellyfin server: `JELLYSCRIBE_DEPLOY_TARGET=user@host ./depl
- Stored secrets (Letterboxd and Serializd passwords, raw cookies, the Seerr API key) are write-only: the plaintext properties are `[JsonIgnore]`, responses carry only `Has*`/`has*` flags, and `Configuration/SecretMerge.cs` turns every save back into the intended change (blank keeps, typed replaces, `ClearRawCookies`/`ClearJellyseerrApiKey` drop, and the write-only `Original*` fields carry secrets across a rename or an admin's owner change). `Plugin.UpdateConfiguration` applies it to Jellyfin's own plugin-config POST. Both Verify endpoints use the stored password when the field is blank, and `TestJellyseerr` sends the stored key only to the stored URL.
- `Api/LibrariesController.cs` lists the film, TV, and mixed libraries the caller can access, for the per-account "Excluded libraries" checklist on both settings pages (Jellyfin's own `/Library/VirtualFolders` is admin-only).
- `Web/*.html` and `Web/*.js` are embedded resources (see `LetterboxdSync.csproj`) served as the plugin's config pages. Both dashboards scope every CSS rule to their root (never `html`/`body`), pick their light or dark palette from the background Jellyfin's theme paints (`applyTheme`; the OS setting only outside Jellyfin), and use native `<dialog>` modals opened with `showModal()`. `DashboardMarkupTests` pins their contrast (4.5:1 in both palettes), scoping, phone layout and keyboard markup.
- Telemetry and log bundles (`Telemetry/TelemetryService.cs`, the `Telemetry/*` actions in `LetterboxdController`): the admin page previews the exact ping (`Telemetry/Preview`) and log bundle (`Telemetry/PreviewLogs`, built by the same method as the send) before anything leaves the server, and `Telemetry/RegenerateId` replaces the instance id. Never log a Serializd email: name the account with `LogRedaction.AccountTag`. The shared log reader masks emails with `LogRedaction.RedactEmails` (encoded forms too) and cuts review-reply bodies older releases logged, so the Logs tab, the preview and the upload match. Error counts in the payload stay exact integers (the ingest Worker drops non-numbers); the docs say so.
- `SidebarScriptStartupFilter.cs` injects the sidebar link without any other plugin (kill switch: `PluginConfiguration.DisableSidebarScriptMiddleware`, XML only). `SidebarInjection.cs` also registers the same injection with the File Transformation plugin when it is installed.

## Releasing
Expand Down
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>2.10.10.0</Version>
<AssemblyVersion>2.10.10.0</AssemblyVersion>
<FileVersion>2.10.10.0</FileVersion>
<Version>2.11.0.0</Version>
<AssemblyVersion>2.11.0.0</AssemblyVersion>
<FileVersion>2.11.0.0</FileVersion>
</PropertyGroup>
</Project>
1 change: 1 addition & 0 deletions LetterboxdSync.Tests/ControllerAuthorizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class ControllerAuthorizationTests
"LetterboxdController.GetLogs",
"LetterboxdController.GetTelemetryPreview",
"LetterboxdController.PreviewLogs",
"LetterboxdController.RegenerateTelemetryId",
"LetterboxdController.SendLogs",
"LetterboxdController.TestJellyseerr",
};
Expand Down
21 changes: 21 additions & 0 deletions LetterboxdSync.Tests/DashboardMarkupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,25 @@ public void Controls_AreKeyboardReachable_AndNamed(string file, string root)
Assert.DoesNotContain("current-password", page, StringComparison.Ordinal);
Assert.Contains(root + " :focus-visible { outline: 2px solid var(--ws-focus);", page, StringComparison.Ordinal);
}

[Fact]
public void AdminPage_OffersThePromisedTelemetryAndLogBundlePreviews()
{
// README, the telemetry and log-bundle specs, and the bug report form all send admins to these.
var page = Read("configPage.html");
foreach (var id in new[] { "telemetryPreviewBtn", "telemetryRegenBtn", "telemetryRegenYes", "telemetryCopyBtn",
"telemetryCopyRegenBtn", "sendLogsPreview", "telemetryNotice", "telemetryNoticeEnable", "telemetryNoticeDismiss" })
Assert.Contains($"id=\"{id}\"", page, StringComparison.Ordinal);
Assert.Contains("<dialog class=\"ws-modal-ov\" id=\"telemetryModal\" aria-labelledby=\"telemetryModalTitle\">", page, StringComparison.Ordinal);
Assert.Contains("<dialog class=\"ws-modal-ov\" id=\"logsPreviewModal\" aria-labelledby=\"logsPreviewTitle\">", page, StringComparison.Ordinal);
foreach (var endpoint in new[] { "Telemetry/Preview'", "Telemetry/PreviewLogs'", "Telemetry/RegenerateId'" })
Assert.Contains(endpoint, page, StringComparison.Ordinal);
// The note goes in a POST body, never in the URL.
Assert.Contains("Telemetry/PreviewLogs'), type: 'POST'", page, StringComparison.Ordinal);
// The notice is answered through the stored flag, and opens hidden until the config says to show it.
Assert.Contains("BannerDismissed", page, StringComparison.Ordinal);
Assert.Matches("id=\"telemetryNotice\"[^>]* hidden>", page);
// The consent text no longer promises what the payload does not keep.
Assert.DoesNotContain("exact numbers", page, StringComparison.Ordinal);
}
}
19 changes: 19 additions & 0 deletions LetterboxdSync.Tests/ListLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;

namespace LetterboxdSync.Tests;

/// <summary>Keeps every formatted log line, so a test can check what a class writes to the log.</summary>
internal sealed class ListLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
57 changes: 57 additions & 0 deletions LetterboxdSync.Tests/LogRedactionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Linq;
using LetterboxdSync;
using Xunit;

namespace LetterboxdSync.Tests;

public class LogRedactionTests
{
[Theory]
[InlineData("failed for alex as demo@example.com: 503", "failed for alex as [email]: 503")]
[InlineData("First.Last+tv@mail.example.co.uk logged in", "[email] logged in")]
[InlineData("two: a@example.com, b_c@jellyfin.example.", "two: [email], [email].")]
[InlineData("(user%x@sub-domain.example.org)", "([email])")]
[InlineData("POST /login?username=demo%40example.com&x=1", "POST /login?username=[email]&x=1")]
[InlineData("POST /login?username=demo%40EXAMPLE.com", "POST /login?username=[email]")]
[InlineData("{\"email\":\"demo\\u0040example.com\"}", "{\"email\":\"[email]\"}")]
[InlineData("<a>demo&#64;example.com</a>", "<a>[email]</a>")]
[InlineData("for o'brien@example.com", "for [email]")]
[InlineData("for user@münchen.de now", "for [email] now")]
[InlineData("next=%2Flogin%3Fu%3Ddemo%2540example.com", "next=[email]")] // an encoded URL is masked whole
public void RedactEmails_MasksEveryAddress(string line, string expected)
=> Assert.Equal(expected, LogRedaction.RedactEmails(line));

[Theory]
[InlineData("Logged Sinners (2025) to Letterboxd for alex as demo-cinephile")]
[InlineData("Jellyscribe 2.10.0.0 on Jellyfin 10.11.11")]
[InlineData("package@1.2.3 and an @mention")]
[InlineData("")]
public void RedactEmails_LeavesLinesWithoutAnAddressAlone(string line)
=> Assert.Equal(line, LogRedaction.RedactEmails(line));

[Fact]
public void TryCutLegacyReviewBody_KeepsTheStatus_DropsTheBody()
{
var line = "[2026-06-14 10:00:00.000 +00:00] [INF] [1] LetterboxdSync.LetterboxdDiary: Review response for sinners: status=201, body={\"review\":{\"text\":\"my draft\"}}";

Assert.True(LogRedaction.TryCutLegacyReviewBody(ref line));
Assert.EndsWith("Review response for sinners: status=201, body=[removed]", line);

var other = "[2026-06-14 10:00:00.000 +00:00] [INF] [1] LetterboxdSync.LetterboxdDiary: Posted review for sinners: status=200, bodyLen=40";
Assert.False(LogRedaction.TryCutLegacyReviewBody(ref other));
Assert.EndsWith("bodyLen=40", other);
}

[Fact]
public void AccountTag_IsShortStableAndNeverTheAddress()
{
var tag = LogRedaction.AccountTag("Demo@Example.com ");

Assert.Matches("^serializd-[0-9a-f]{6}$", tag);
Assert.Equal(tag, LogRedaction.AccountTag("demo@example.com"));
Assert.NotEqual(tag, LogRedaction.AccountTag("other@example.com"));
Assert.DoesNotContain("demo", tag);
Assert.Equal("serializd-none", LogRedaction.AccountTag(null));
Assert.Equal("serializd-none", LogRedaction.AccountTag(" "));
}
}
72 changes: 72 additions & 0 deletions LetterboxdSync.Tests/PostReviewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,78 @@ public async Task PostReviewAsync_DateOmitted_UsesTodaysDate()
Assert.Contains($"\"diaryDate\":\"{today}\"", capturedBody!);
}

private static ReviewMockHandler EchoingReviewEndpoint(HttpStatusCode status, string body)
{
var handler = new ReviewMockHandler();
handler.Responder = (request, _) =>
{
var path = request.RequestUri?.AbsolutePath ?? "";
if (request.Method == HttpMethod.Get && path == "/")
return CsrfRootResponse(handler);
if (request.Method == HttpMethod.Get && path == "/film/sinners/")
return FilmPageResponse();
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v0/production-log-entries"))
return new HttpResponseMessage(status) { Content = new StringContent(body) };
return new HttpResponseMessage(HttpStatusCode.NotFound);
};
return handler;
}

[Fact]
public async Task PostReviewAsync_Success_LogsStatusAndLength_NeverTheReplyBody()
{
// Letterboxd's reply to a new log entry can echo the review back.
const string review = "a private first draft about the ending";
var reply = "{\"id\":\"abc\",\"review\":{\"text\":\"" + review + "\"}}";
var logger = new ListLogger();

await EchoingReviewEndpoint(HttpStatusCode.OK, reply).CreateDiary(logger).PostReviewAsync("sinners", review, date: null);

Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("private first draft"));
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information
&& e.Message.Contains("Posted review for sinners") && e.Message.Contains("status=200")
&& e.Message.Contains($"bodyLen={reply.Length}"));
}

[Fact]
public async Task PostReviewAsync_Failure_LogsAShortStartOfTheReply()
{
var reply = "{\"error\":\"invalid rating\"}" + new string('x', 1000);
var logger = new ListLogger();

await Assert.ThrowsAnyAsync<Exception>(() =>
EchoingReviewEndpoint(HttpStatusCode.BadRequest, reply).CreateDiary(logger).PostReviewAsync("sinners", "a fine film", date: null));

var entry = Assert.Single(logger.Entries, e => e.Message.Contains("Review post for sinners failed"));
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("status=400", entry.Message);
Assert.Contains("invalid rating", entry.Message);
Assert.DoesNotContain(new string('x', 400), entry.Message);
}

[Theory]
[InlineData("{\"error\":\"invalid rating\"}", "ok", "{\"error\":\"invalid rating\"}")]
[InlineData("{\"error\":\"quoted: an honest review\"}", "an honest review", "{\"error\":\"quoted: [review]\"}")]
[InlineData("{\"text\":\"caf\\u00E9 noir\"}", "café noir", "{\"text\":\"[review]\"}")]
[InlineData("plain café noir", "café noir", "plain [review]")]
public void WithoutReview_CutsTheReview_ButLeavesAShortOneAlone(string body, string review, string expected)
=> Assert.Equal(expected, LetterboxdDiary.WithoutReview(body, review));

[Fact]
public async Task PostReviewAsync_FailureThatQuotesTheReview_KeepsTheReviewOutOfTheLogAndTheError()
{
const string review = "my \"secret\" draft\nsecond line";
var reply = "{\"error\":\"review too long\",\"review\":{\"text\":" + System.Text.Json.JsonSerializer.Serialize(review) + "}}";
var logger = new ListLogger();

var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
EchoingReviewEndpoint(HttpStatusCode.BadRequest, reply).CreateDiary(logger).PostReviewAsync("sinners", review, date: null));

Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("secret"));
Assert.DoesNotContain("secret", ex.Message);
Assert.Contains(logger.Entries, e => e.Message.Contains("review too long") && e.Message.Contains("[review]"));
}

/// <summary>
/// Self-contained mock handler that wires up an HttpClient + LetterboxdHttpClient
/// + LetterboxdDiary chain. The Responder captures requests and produces responses;
Expand Down
90 changes: 90 additions & 0 deletions LetterboxdSync.Tests/SendLogsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public async Task SendLogBundle_NullSender_ReturnsNullOnFailure()
[InlineData("PreviewLogs")]
[InlineData("GetLogs")] // raw server logs name every user's Letterboxd account + films
[InlineData("GetTelemetryPreview")]
[InlineData("RegenerateTelemetryId")]
public void SensitiveEndpoints_RequireElevation(string methodName)
{
var method = typeof(LetterboxdController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance);
Expand Down Expand Up @@ -119,6 +120,95 @@ public void PreviewLogs_ReturnsFullBundle_IncludingLogLines()
Assert.Contains("\"log_lines\"", content.Content!);
}

[Fact]
public async Task PreviewAndSend_MaskEveryEmail_AndShowTheSameLines()
{
// Older logs (and any email typed as a login or quoted in an error) still carry
// addresses; none may reach the preview or the upload.
var logFile = System.IO.Path.Combine(_h.LogDir, "log_20260614.log");
System.IO.File.WriteAllText(logFile,
"[2026-06-14 10:00:00.000 +00:00] [ERR] [1] LetterboxdSync.Serializd: catch-up failed for alex as demo@example.com: 503\n" +
"[2026-06-14 10:00:01.000 +00:00] [INF] [1] LetterboxdSync.Foo: login for First.Last+tv@mail.example.co.uk ok\n" +
" at LetterboxdSync.Foo.Bar() reported by ops@jellyfin.example\n");

var preview = Assert.IsType<ContentResult>(_h.Controller.PreviewLogs()).Content!;
await _h.Controller.SendLogs(new SendLogsRequest { Note = null });
var (_, sent) = Assert.Single(_sent);

foreach (var bundle in new[] { preview, sent })
{
Assert.DoesNotContain("demo@example.com", bundle);
Assert.DoesNotContain("First.Last+tv@mail.example.co.uk", bundle);
Assert.DoesNotContain("ops@jellyfin.example", bundle);
Assert.Contains("catch-up failed for alex as [email]: 503", bundle);
Assert.Contains("login for [email] ok", bundle);
Assert.Contains("reported by [email]", bundle);
}
Assert.Equal(preview, sent);
}

[Fact]
public async Task PreviewWithTheTypedNote_IsByteForByteWhatTheSendUploads()
{
// Telemetry has never been on here, so the bundle carries a one-off id: the preview
// must show the same one the send then uploads.
Assert.True(string.IsNullOrEmpty(_h.Config.Telemetry.InstanceId));
var logFile = System.IO.Path.Combine(_h.LogDir, "log_20260614.log");
System.IO.File.WriteAllText(logFile,
"[2026-06-14 10:00:00.000 +00:00] [INF] [1] LetterboxdSync.Foo: a diagnostic line\n");

var preview = Assert.IsType<ContentResult>(_h.Controller.PreviewLogs(new SendLogsRequest { Note = "sync stops at film 3" })).Content!;
await _h.Controller.SendLogs(new SendLogsRequest { Note = "sync stops at film 3" });

var (_, sent) = Assert.Single(_sent);
Assert.Equal(preview, sent);
Assert.Contains("sync stops at film 3", preview);
}

[Fact]
public async Task ALongNote_IsCutToWhatTheBackendKeeps_InThePreviewAndTheSend()
{
var note = new string('n', 2500);
var preview = Assert.IsType<ContentResult>(_h.Controller.PreviewLogs(new SendLogsRequest { Note = note })).Content!;
await _h.Controller.SendLogs(new SendLogsRequest { Note = note });

var (_, sent) = Assert.Single(_sent);
Assert.Equal(preview, sent);
using var doc = JsonDocument.Parse(sent);
Assert.Equal(2000, doc.RootElement.GetProperty("note").GetString()!.Length);
}

[Fact]
public void ReviewRepliesLoggedByOlderReleases_LoseTheirBody_ContinuationLinesIncluded()
{
var logFile = System.IO.Path.Combine(_h.LogDir, "log_20260614.log");
System.IO.File.WriteAllText(logFile,
"[2026-06-14 10:00:00.000 +00:00] [INF] [1] LetterboxdSync.LetterboxdDiary: Review response for sinners: status=201, body={\"text\":\"first line of my draft\n" +
"second line of my draft\"}\n" +
"[2026-06-14 10:00:01.000 +00:00] [INF] [1] LetterboxdSync.Foo: next entry\n" +
" at LetterboxdSync.Foo.Bar()\n");

var preview = Assert.IsType<ContentResult>(_h.Controller.PreviewLogs()).Content!;

Assert.DoesNotContain("my draft", preview);
Assert.Contains("Review response for sinners: status=201, body=[removed]", preview);
Assert.Contains("next entry", preview);
Assert.Contains("at LetterboxdSync.Foo.Bar()", preview);
}

[Fact]
public void TheLogsTab_MasksEmailsToo()
{
var logFile = System.IO.Path.Combine(_h.LogDir, "log_20260614.log");
System.IO.File.WriteAllText(logFile,
"[2026-06-14 10:00:00.000 +00:00] [ERR] [1] LetterboxdSync.Serializd: catch-up failed for alex as demo@example.com\n");

var ok = Assert.IsType<OkObjectResult>(_h.Controller.GetLogs());
var body = JsonSerializer.Serialize(ok.Value);
Assert.DoesNotContain("demo@example.com", body);
Assert.Contains("catch-up failed for alex as [email]", body);
}

[Fact]
public async Task SendLogs_Success_ReturnsRefCode()
{
Expand Down
Loading
Loading