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 Pleroma.Repo
alias Pleroma.Object
alias Pleroma.Maps
alias Pleroma.Pagination
alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.Utils
import Ecto.Query
require Logger
@type source ::
@ -301,4 +307,38 @@ defmodule Pleroma.Upload do
public_endpoint || upload_base_url
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

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
alias Pleroma.Web.ApiSpec.Helpers
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
@ -73,6 +74,20 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
}
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
%Schema{
title: "MediaUpdateRequest",
@ -101,7 +116,15 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do
tags: ["Media attachments"],
summary: "Attachment",
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"]}],
responses: %{
200 => Operation.response("Media", "application/json", Attachment),

View file

@ -393,4 +393,28 @@ defmodule Pleroma.Web.ApiSpec.PleromaUtilOperation do
}
}
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

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Repo
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Plugs.OAuthScopesPlug
@ -78,13 +79,51 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
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)
@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),
:ok <- Object.authorize_access(object, user) do
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})
end

View file

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

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
alias Pleroma.Emoji
alias Pleroma.Healthcheck
alias Pleroma.User
alias Pleroma.Pagination
alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator
@ -38,7 +39,8 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
OAuthScopesPlug,
%{scopes: ["read:accounts"]}
when action in [
:list_aliases
:list_aliases,
:uploaded_media
]
)
@ -274,4 +276,19 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
|> put_status(:service_unavailable)
|> json(info)
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

View file

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

View file

@ -17,6 +17,7 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
"object" => %{"data" => %{"attachment" => [_ | _] = attachments, "actor" => actor}}
}
}) do
if Pleroma.Config.get([:instance, :cleanup_attachments], false) do
attachments
|> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end)
@ -29,6 +30,41 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
{:ok, :success}
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}
@impl true
@ -61,6 +97,8 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do
# we should delete 1 object for any given attachment, but don't delete
# files if there are more than 1 object for it
defp filter_objects(objects) do
IO.inspect(objects)
Enum.reduce(objects, {[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} ->
with 1 <- count do
{ids ++ [id], hrefs ++ [href]}