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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 11:00:04 +02:00
1 parent a4ced664cf
commit 5252b8f320
4 files changed
+87 -2

No files matched your search

@@ -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<IActionResult> Update(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
var attachment = await DB.Default.Find<MediaAttachment>().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"))
@@ -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<MediaChange> MediaAttributes()
{
var changes = new List<MediaChange>();
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<IActionResult> Delete(string id, CancellationToken token)
{
+22
View File
@@ -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<string> Recipients { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> MediaIds { get; init; }
public IReadOnlyList<MediaChange> MediaChanges { get; init; } = Array.Empty<MediaChange>();
// 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<MediaAttachment>().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<MediaAttachment>().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;