From 5252b8f320ee50988c0bc289fb760398b74bb8d7 Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 11:00:04 +0200 Subject: [PATCH] A posted picture is described again by editing its post PUT /api/v1/media/:id on a picture already posted changed the upload but never the post, which keeps its own copy, so the new description or focal point reached nobody. As on Mastodon, PUT now takes only media not posted yet (404 otherwise), and a status edit takes media_attributes[] (id, description, focus; from a JSON body or a form): the uploads and the post's copies change, and the Update the edit federates carries them. An edit that sends no media keeps them as they were described. No pasture scenario edits a description, so the sweep could not see this. MediaLifecycleTests: PUT on a posted picture is refused, an edit describes it again and keeps its warning, flag and language as sent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MediaLifecycleTests.cs | 32 +++++++++++++++++++ .../Mastodon/Controllers/MediaController.cs | 3 +- .../Controllers/StatusesController.cs | 32 ++++++++++++++++++- PrivaPub/Domain/Statuses/StatusService.cs | 22 +++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/PrivaPub.Tests/Http/MediaLifecycleTests.cs b/PrivaPub.Tests/Http/MediaLifecycleTests.cs index c049d83..c2dbc9b 100644 --- a/PrivaPub.Tests/Http/MediaLifecycleTests.cs +++ b/PrivaPub.Tests/Http/MediaLifecycleTests.cs @@ -74,6 +74,38 @@ namespace PrivaPub.Tests.Http Assert.Equal("edited out", (await DB.Default.Find().OneAsync(dropped, Token)).TrashReason); } + // a posted picture's description and focus change by editing its post (media_attributes), not by PUT, as on Mastodon + [Fact] + public async Task An_edit_describes_its_media_again_and_keeps_what_it_is_not_sent() + { + var alice = await _host.Mastodon("alice"); + var id = await Upload(alice, "a.jpg"); + var status = (await alice.Client.Post("/api/v1/statuses", ("status", "described later"), ("media_ids[]", id), + ("spoiler_text", "a warning"), ("sensitive", "true"), ("language", "it"))).Ok(); + var statusId = status.Body.Text("id"); + Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put($"/api/v1/media/{id}", ("description", "too late"))).Status); + + var edited = (await alice.Client.Json(HttpMethod.Put, $"/api/v1/statuses/{statusId}", new System.Text.Json.Nodes.JsonObject + { + ["status"] = "described now", + ["spoiler_text"] = "a warning", + ["sensitive"] = true, + ["media_ids"] = new System.Text.Json.Nodes.JsonArray(id), + ["media_attributes"] = new System.Text.Json.Nodes.JsonArray(new System.Text.Json.Nodes.JsonObject { ["id"] = id, ["description"] = "a red square", ["focus"] = "0.5,0.5" }) + })).Ok(); + + Assert.Equal("a red square", edited.Body["media_attachments"]![0]!.Text("description")); + Assert.Equal(0.5, edited.Body["media_attachments"]![0]!["meta"]!["focus"]!["x"]!.GetValue()); + Assert.Equal("a warning", edited.Body.Text("spoiler_text")); + Assert.True(edited.Body["sensitive"]!.GetValue()); + Assert.Equal("it", edited.Body.Text("language")); + Assert.Equal("a red square", (await DB.Default.Find().OneAsync(id, Token)).Description); + + // an edit that doesn't send media keeps them, described as they were + var kept = (await alice.Client.Put($"/api/v1/statuses/{statusId}", ("status", "media kept"))).Ok(); + Assert.Equal("a red square", kept.Body["media_attachments"]![0]!.Text("description")); + } + [Fact] public async Task A_replaced_avatar_is_trashed_and_a_picture_that_is_not_one_is_refused() { diff --git a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs index f7108ef..5d09ac9 100644 --- a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs @@ -64,10 +64,11 @@ namespace PrivaPub.Api.Mastodon.Controllers }; } + // as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes) [HttpPut("/api/v1/media/{id}"), Scope("write:media")] public async Task Update(string id, CancellationToken token) { - var attachment = await DB.Default.Find().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token); + var attachment = await DB.Default.Find().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.PostId == null).ExecuteFirstAsync(token); if (attachment == default) return NotFoundError(); if (Params.Has("description")) diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 72f34b9..729e1c0 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -167,11 +167,41 @@ namespace PrivaPub.Api.Mastodon.Controllers SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? false, Language = Params.Get("language"), - MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default + MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default, + MediaChanges = MediaAttributes() }, token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } + // Mastodon's media_attributes: a JSON body's array of objects, or a form's media_attributes[i][field] or + // media_attributes[][field] (each field then listed in the same order) + IReadOnlyList MediaAttributes() + { + var changes = new List(); + if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.TryGetProperty("media_attributes", out var array) + && array.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in array.EnumerateArray().Where(i => i.ValueKind == System.Text.Json.JsonValueKind.Object).Take(4)) + { + var hasDescription = item.TryGetProperty("description", out var description); + changes.Add(new MediaChange(Text(item, "id"), hasDescription, hasDescription ? Text(item, "description") : default, Text(item, "focus"))); + } + return changes; + } + for (var i = 0; i < 4 && Params.Get($"media_attributes[{i}][id]") is { } id; i++) + changes.Add(new MediaChange(id, Params.Has($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][focus]"))); + var ids = Params.List("media_attributes[][id]"); + var descriptions = Params.List("media_attributes[][description]"); + var foci = Params.List("media_attributes[][focus]"); + for (var i = 0; i < Math.Min(ids.Count, 4); i++) + changes.Add(new MediaChange(ids[i], i < descriptions.Count, i < descriptions.Count ? descriptions[i] : default, i < foci.Count ? foci[i] : default)); + return changes; + + static string Text(System.Text.Json.JsonElement item, string name) => item.TryGetProperty(name, out var value) + ? value.ValueKind == System.Text.Json.JsonValueKind.String ? value.GetString() : value.ValueKind is System.Text.Json.JsonValueKind.Null ? default : value.GetRawText() + : default; + } + [HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")] public async Task Delete(string id, CancellationToken token) { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 09a06d9..4306c47 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -24,6 +24,9 @@ using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Statuses { + // an attachment's description or focal point changed by an edit (Mastodon's media_attributes) + public sealed record MediaChange(string Id, bool HasDescription, string Description, string Focus); + public sealed class StatusDraft { public string Text { get; init; } @@ -38,6 +41,7 @@ namespace PrivaPub.Domain.Statuses public string ConversationId { get; init; } public IReadOnlyList Recipients { get; init; } = Array.Empty(); public IReadOnlyList MediaIds { get; init; } + public IReadOnlyList MediaChanges { get; init; } = Array.Empty(); // the scheduled post being published, whose media are reserved for it alone public string ScheduledStatusId { get; init; } public double? Latitude { get; init; } @@ -320,6 +324,23 @@ namespace PrivaPub.Domain.Statuses var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain; var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); + // descriptions and focal points changed by the edit, on the media it keeps or those the post already has + var described = media ?? (draft.MediaChanges.Count > 0 + ? await DB.Default.Find().Match(m => m.PostId == post.ID && m.TrashedAt == null).ExecuteAsync(token) + : default); + foreach (var change in draft.MediaChanges) + { + if (described?.FirstOrDefault(m => m.ID == change.Id) is not { } attachment) + continue; + if (change.HasDescription) + attachment.Description = string.IsNullOrWhiteSpace(change.Description) ? default : change.Description.Trim()[..Math.Min(change.Description.Trim().Length, 1500)]; + if (Domain.Media.FocalPoint.Parse(change.Focus) is { } focus) + attachment.Focus = focus; + await DB.Default.Update().MatchID(attachment.ID) + .Modify(m => m.Description, attachment.Description).Modify(m => m.Focus, attachment.Focus).ExecuteAsync(token); + } + if (media == default && described != default) + post.Media = post.Media.Select(copy => described.FirstOrDefault(m => m.ID == copy.AttachmentId) is { } changed ? ToPostMedia(changed) : copy).ToList(); if (media != default) { if (!await Claim(media, post.ID, default, token)) @@ -330,6 +351,7 @@ namespace PrivaPub.Domain.Statuses await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token); } post.Title = draft.Title == default ? post.Title : Clean(draft.Title); + // as on Mastodon, an edit sends the whole post: no warning sent is no warning (the language alone falls back) post.SpoilerText = Clean(draft.SpoilerText); post.HasContentWarning = draft.Sensitive || post.SpoilerText != default; post.Text = draft.Text;