Work-in-progress media management

Adds a way to list media files uploaded by the user and delete
them. Ideally needs to at least check if the attachment is used
somewhere before actually deleting it, still.
This commit is contained in:
Ekaterina Vaartis 2026-11-03 12:14:40 +03:00
commit e0dce797ad
8 changed files with 188 additions and 3 deletions

View file

@ -34,9 +34,15 @@ defmodule Pleroma.Upload do
""" """
alias Ecto.UUID alias Ecto.UUID
alias Pleroma.Repo
alias Pleroma.Object
alias Pleroma.Maps alias Pleroma.Maps
alias Pleroma.Pagination
alias Pleroma.Utils.URIEncoding alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
import Ecto.Query
require Logger require Logger
@type source :: @type source ::
@ -301,4 +307,38 @@ defmodule Pleroma.Upload do
public_endpoint || upload_base_url public_endpoint || upload_base_url
end end
end end
def list_duplicate_attachments(%{"id" => id, "url" => [%{"href" => href}]}) do
clean_href = %URI{ URI.parse(href) | query: nil } |> URI.to_string()
same_attachment_count =
from(
o in Object,
where: fragment("STARTS_WITH((?)->'url'->0->>'href', (?))", o.data, ^clean_href) and fragment("(?)->>'id' <> (?)", o.data, ^id),
) |> Repo.all()
end
def list_objects_with_attachment(href) do
clean_href = %URI{ URI.parse(href) | query: nil } |> URI.to_string()
# Ugly as sin, but I could not figure out a better way to do this in ecto
# This tries to find any objects with this attachment
# This DOES NOT handle chat messages and SKIPS them, because they have attachments as an object instead of an array
Repo.query!(
"""
SELECT * FROM
(SELECT objects.data->>'id' AS id, jsonb_array_elements(objects.data->'attachment') as json_attachments FROM objects WHERE jsonb_typeof(objects.data->'attachment') = 'array')
WHERE STARTS_WITH(json_attachments->'url'->0->>'href', $1)
""",
[clean_href]
).rows |> Enum.map(&List.first(&1))
end
def list_uploaded_by(user, offset, limit) do
from(obj in Object,
where: fragment("(?)->>'type' = ?", obj.data, "Document") and fragment("(?)->>'actor' = ?", obj.data, ^user.ap_id),
order_by: [desc: obj.id]
)
|> Pagination.fetch_paginated(%{"offset" => offset, "limit" => limit}, :offset)
end
end end

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Helpers
alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.Attachment alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
def open_api_operation(action) do def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation") operation = String.to_existing_atom("#{action}_operation")
@ -73,6 +74,20 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
} }
end end
def delete_operation do
%Operation{
tags: ["Media attachments"],
summary: "Delete attachment",
description: "Deletes an attachment.",
operationId: "MediaController.delete",
security: [%{"oAuth" => ["write:media"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Media", "application/json", Attachment),
}
}
end
defp update_request do defp update_request do
%Schema{ %Schema{
title: "MediaUpdateRequest", title: "MediaUpdateRequest",
@ -101,7 +116,15 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
tags: ["Media attachments"], tags: ["Media attachments"],
summary: "Attachment", summary: "Attachment",
operationId: "MediaController.show", operationId: "MediaController.show",
parameters: [id_param()], parameters: [
id_param(),
Operation.parameter(
:attachment_usage,
:query,
%Schema{type: :boolean, default: false},
"Whether to include known usages of attachment"
)
],
security: [%{"oAuth" => ["read:media"]}], security: [%{"oAuth" => ["read:media"]}],
responses: %{ responses: %{
200 => Operation.response("Media", "application/json", Attachment), 200 => Operation.response("Media", "application/json", Attachment),

View file

@ -393,4 +393,28 @@ defmodule Pleroma.Web.ApiSpec.PleromaUtilOperation do
} }
} }
end end
def uploaded_media_operation do
%Operation{
tags: ["Media"],
summary: "List uploaded media",
security: [%{"oAuth" => ["read:accounts"]}],
operationId: "PleromaAPI.UtilController.uploaded_media",
responses: %{
200 =>
Operation.response("Success", "application/json", %Schema{
type: :object,
properties: %{
aliases: %Schema{
type: :array,
items: %Schema{type: :string},
example: ["foo@example.org"]
}
}
}),
400 => Operation.response("Error", "application/json", ApiError),
403 => Operation.response("Error", "application/json", ApiError)
}
}
end
end end

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Repo
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
@ -78,13 +79,51 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
end end
end end
def delete(
%{
assigns: %{user: user},
private: %{open_api_spex: %{params: %{id: id}}}
} = conn,
_
) do
with %Object{} = object <- Object.get_by_id(id),
:ok <- Object.authorize_access(object, user),
{:ok, _, _} <- Object.delete(object) do
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, "")
end
end
def update(conn, data), do: show(conn, data) def update(conn, data), do: show(conn, data)
@doc "GET /api/v1/media/:id" @doc "GET /api/v1/media/:id"
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id, attachment_usage: attachment_usage}}}} = conn, _) do
with %Object{data: data, id: object_id} = object <- Object.get_by_id(id), with %Object{data: data, id: object_id} = object <- Object.get_by_id(id),
:ok <- Object.authorize_access(object, user) do :ok <- Object.authorize_access(object, user) do
attachment_data = Map.put(data, "id", object_id) attachment_data = Map.put(data, "id", object_id)
attachment_data =
if attachment_usage do
href = data["url"] |> List.first |> Map.get("href")
used_in_objects = Pleroma.Upload.list_objects_with_attachment(href)
attachment_duplicates =
Pleroma.Upload.list_duplicate_attachments(object.data)
|> Enum.group_by(& if &1.data["actor"] == user.ap_id, do: :own, else: :other)
|> Map.new(fn {key, val} -> {key, length(val)} end)
put_in(
attachment_data["usage"],
%{
objects: used_in_objects,
duplicates: attachment_duplicates
}
)
else
attachment_data
end
render(conn, "attachment.json", %{attachment: attachment_data}) render(conn, "attachment.json", %{attachment: attachment_data})
end end

View file

@ -658,6 +658,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
pleroma = pleroma =
%{mime_type: media_type} %{mime_type: media_type}
|> Maps.put_if_present(:name, name) |> Maps.put_if_present(:name, name)
|> Maps.put_if_present(:usage, attachment["usage"])
%{ %{
id: attachment_id, id: attachment_id,

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
alias Pleroma.Emoji alias Pleroma.Emoji
alias Pleroma.Healthcheck alias Pleroma.Healthcheck
alias Pleroma.User alias Pleroma.User
alias Pleroma.Pagination
alias Pleroma.Utils.URIEncoding alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator
@ -38,7 +39,8 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:accounts"]} %{scopes: ["read:accounts"]}
when action in [ when action in [
:list_aliases :list_aliases,
:uploaded_media
] ]
) )
@ -274,4 +276,19 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
|> put_status(:service_unavailable) |> put_status(:service_unavailable)
|> json(info) |> json(info)
end end
def uploaded_media(
%{assigns: %{user: user}} = conn,
_
) do
result =
Pleroma.Upload.list_uploaded_by(user, 0, 50)
|> Enum.map(
fn %Pleroma.Object{ id: id, data: %{ "url" => [ mediaUrl ], "name" => name }, inserted_at: uploaded } ->
Map.merge(%{ id: id, uploaded: uploaded, name: name }, mediaUrl) |> Map.drop(["type"]) end
)
json(conn, result)
end
end end

View file

@ -583,6 +583,8 @@ defmodule Pleroma.Web.Router do
put("/aliases", UtilController, :add_alias) put("/aliases", UtilController, :add_alias)
get("/aliases", UtilController, :list_aliases) get("/aliases", UtilController, :list_aliases)
delete("/aliases", UtilController, :delete_alias) delete("/aliases", UtilController, :delete_alias)
get("/uploaded_media", UtilController, :uploaded_media)
end end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do scope "/api/pleroma", Pleroma.Web.PleromaAPI do
@ -803,6 +805,7 @@ defmodule Pleroma.Web.Router do
post("/media", MediaController, :create) post("/media", MediaController, :create)
get("/media/:id", MediaController, :show) get("/media/:id", MediaController, :show)
put("/media/:id", MediaController, :update) put("/media/:id", MediaController, :update)
delete("/media/:id", MediaController, :delete)
get("/notifications", NotificationController, :index) get("/notifications", NotificationController, :index)
get("/notifications/:id", NotificationController, :show) get("/notifications/:id", NotificationController, :show)

View file

@ -17,6 +17,7 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
"object" => %{"data" => %{"attachment" => [_ | _] = attachments, "actor" => actor}} "object" => %{"data" => %{"attachment" => [_ | _] = attachments, "actor" => actor}}
} }
}) do }) do
if Pleroma.Config.get([:instance, :cleanup_attachments], false) do if Pleroma.Config.get([:instance, :cleanup_attachments], false) do
attachments attachments
|> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end) |> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end)
@ -29,6 +30,41 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
{:ok, :success} {:ok, :success}
end end
def perform(%Job{
args: %{
"op" => "cleanup_attachments",
"object" => %{"data" => %{ "type" => "Document", "url" => [%{"href" => href}]}} = object_data
}
}) do
if Pleroma.Config.get([:instance, :cleanup_attachments], false) do
clean_href = %URI{ URI.parse(href) | query: nil } |> URI.to_string()
same_attachment_count = Pleroma.Upload.list_duplicate_attachments(object_data)
# No copies of this attachment in the database
if same_attachment_count == 0 do
attachment_post_ids = Pleroma.Upload.list_objects_with_attachment(clean_href)
# No posts with this attachment, either
if attachment_post_ids == [] do
base_url =
String.trim_trailing(
Pleroma.Upload.base_url(),
"/"
)
uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
clean_href
|> String.trim_leading("#{base_url}")
|> uploader.delete_file()
end
end
end
{:ok, :success}
end
def perform(%Job{args: %{"op" => "cleanup_attachments", "object" => _object}}), do: {:ok, :skip} def perform(%Job{args: %{"op" => "cleanup_attachments", "object" => _object}}), do: {:ok, :skip}
@impl true @impl true
@ -61,6 +97,8 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
# we should delete 1 object for any given attachment, but don't delete # we should delete 1 object for any given attachment, but don't delete
# files if there are more than 1 object for it # files if there are more than 1 object for it
defp filter_objects(objects) do defp filter_objects(objects) do
IO.inspect(objects)
Enum.reduce(objects, {[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> Enum.reduce(objects, {[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} ->
with 1 <- count do with 1 <- count do
{ids ++ [id], hrefs ++ [href]} {ids ++ [id], hrefs ++ [href]}