From 8fecf7de2dc4edfd2709e1a3e402526cce4c0457 Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 10:55:12 +0200 Subject: [PATCH] A persona's avatar and header can be removed Mastodon's DELETE /api/v1/profile/avatar and /api/v1/profile/header: the picture goes to the trash (its file stops being served at once), the profile shows the default again, followers are sent the updated profile, and the answer is the account with its source. The profile Update is the one a persona that never had a picture sends, so nothing new leaves PrivaPub. MediaLifecycleTests: the avatar, then the header, removed and no longer served. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MediaLifecycleTests.cs | 23 +++++++++++++++++++ .../Controllers/AccountsController.cs | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/PrivaPub.Tests/Http/MediaLifecycleTests.cs b/PrivaPub.Tests/Http/MediaLifecycleTests.cs index 72c6f17..c049d83 100644 --- a/PrivaPub.Tests/Http/MediaLifecycleTests.cs +++ b/PrivaPub.Tests/Http/MediaLifecycleTests.cs @@ -98,6 +98,29 @@ namespace PrivaPub.Tests.Http Assert.Equal(second, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok().Body.Text("avatar")); } + // Mastodon's DELETE /api/v1/profile/avatar and /header: the picture goes, the default shows again + [Fact] + public async Task A_picture_is_removed_and_the_default_shows_again() + { + var alice = await _host.Mastodon($"pic{Guid.NewGuid():N}"[..12]); + var form = MastodonHelpers.Multipart(("avatar", MastodonHelpers.JpegWithMetadata(200, 200), "image/jpeg", "me.jpg"), + ("header", MastodonHelpers.JpegWithMetadata(900, 300), "image/jpeg", "top.jpg")); + var set = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") { Content = form })).Ok(); + var avatar = set.Body.Text("avatar"); + var header = set.Body.Text("header"); + + var removed = (await alice.Client.Delete("/api/v1/profile/avatar")).Ok(); + + Assert.Contains("missing", removed.Body.Text("avatar")); + Assert.Equal(header, removed.Body.Text("header")); + Assert.Equal(HttpStatusCode.NotFound, await Served(avatar)); + Assert.Equal(HttpStatusCode.OK, await Served(header)); + + Assert.Contains("missing", (await alice.Client.Delete("/api/v1/profile/header")).Ok().Body.Text("header")); + Assert.Equal(HttpStatusCode.NotFound, await Served(header)); + Assert.False(await DB.Default.Find().Match(m => m.ProfileOfAvatarId == alice.Persona.Id && m.TrashedAt == null).ExecuteAnyAsync(Token)); + } + [Fact] public async Task A_removed_roots_media_and_pictures_are_trashed_and_its_scheduled_posts_dropped() { diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index ec3e426..5577713 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -123,6 +123,28 @@ namespace PrivaPub.Api.Mastodon.Controllers return Json(await _mapper.Local(actor, withSource: true, token)); } + // Mastodon's: the picture goes (to the trash), the profile shows the default again, and followers are told + [HttpDelete("/api/v1/profile/avatar"), Scope("write:accounts")] + public Task DeleteAvatar([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "avatar", token); + + [HttpDelete("/api/v1/profile/header"), Scope("write:accounts")] + public Task DeleteHeader([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "header", token); + + async Task DeletePicture(IMediaService media, string kind, CancellationToken token) + { + var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token); + if (kind == "avatar") + avatar.PictureURL = default; + else + avatar.ThumbnailURL = default; + avatar.UpdatedAt = DateTime.UtcNow; + await DB.Default.SaveAsync(avatar, token); + await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind, "removed", token); + var actor = _localActors.FromAvatar(avatar); + await _outbox.PublishProfile(actor, token); + return Json(await _mapper.Local(actor, withSource: true, token)); + } + [HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Lookup(CancellationToken token) {