Merge remote-tracking branch 'origin/media-management' into shigusegubu-new

This commit is contained in:
Henry Jameson 2026-11-05 18:03:27 +02:00
commit ab9647458e
11 changed files with 238 additions and 5 deletions

View file

@ -0,0 +1 @@
Added information about duplicates and usage when fetching attachments and allow to delete said attachments via `DELETE /api/v1/media/:id`

View file

@ -35,8 +35,14 @@ defmodule Pleroma.Upload do
""" """
alias Ecto.UUID alias Ecto.UUID
alias Pleroma.Maps alias Pleroma.Maps
alias Pleroma.Object
alias Pleroma.Pagination
alias Pleroma.Repo
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,48 @@ 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],
limit: ^limit,
offset: ^offset
)
|> Repo.all()
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,21 @@ 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", "text/plain", nil),
400 => Operation.response("Media", "application/json", ApiError)
}
}
end
defp update_request do defp update_request do
%Schema{ %Schema{
title: "MediaUpdateRequest", title: "MediaUpdateRequest",
@ -101,7 +117,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

@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaUtilOperation do
alias OpenApiSpex.Operation alias OpenApiSpex.Operation
alias OpenApiSpex.Schema alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
import Pleroma.Web.ApiSpec.Helpers import Pleroma.Web.ApiSpec.Helpers
@ -393,4 +394,38 @@ defmodule Pleroma.Web.ApiSpec.PleromaUtilOperation do
} }
} }
end end
def uploaded_media_operation do
%Operation{
tags: ["Media attachments"],
summary: "List media uploaded by the current user",
security: [%{"oAuth" => ["read:accounts"]}],
operationId: "PleromaAPI.UtilController.uploaded_media",
parameters: [
Operation.parameter(
:offset,
:query,
%Schema{type: :integer, default: 0},
"Offset for uploaded media pagination"
)
],
responses: %{
200 =>
Operation.response("Success", "application/json", %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
uploaded: %Schema{
type: :string,
description: "Date and time when the attachment was uploaded",
format: :"date-time"
},
attachment: Attachment
}
}
})
}
}
end
end end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
@ -78,14 +79,67 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
end end
end end
@doc "DELETE /api/v1/media/:id"
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),
href <- object.data["url"] |> List.first() |> Map.get("href"),
{:used, []} <- {:used, Pleroma.Upload.list_objects_with_attachment(href)},
{:ok, _, _} <- Object.delete(object) do
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, "")
else
{:used, list} ->
conn
|> put_status(:bad_request)
|> json(%{
error: "Refusing to delete attachment that is still used by #{Enum.count(list)} objects"
})
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
end end

View file

@ -220,7 +220,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
if Pleroma.Language.LanguageDetector.configured?() do if Pleroma.Language.LanguageDetector.configured?() do
"pleroma:language_detection" "pleroma:language_detection"
end, end,
"pleroma:block_expiration" "pleroma:block_expiration",
"pleroma:user_media_management"
] ]
|> Enum.filter(& &1) |> Enum.filter(& &1)
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

@ -10,6 +10,7 @@ defmodule Pleroma.Web.PleromaAPI.UtilController do
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Emoji alias Pleroma.Emoji
alias Pleroma.Healthcheck alias Pleroma.Healthcheck
alias Pleroma.Pagination
alias Pleroma.User alias Pleroma.User
alias Pleroma.Utils.URIEncoding alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -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,30 @@ 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}, private: %{open_api_spex: %{params: %{offset: offset}}}} = conn,
_
) do
result =
Pleroma.Upload.list_uploaded_by(user, offset, 50)
|> Enum.map(fn %Pleroma.Object{
id: id,
data: %{"url" => [media_url], "name" => name},
inserted_at: uploaded
} ->
%{
uploaded: uploaded,
attachment: %{
description: name,
id: to_string(id),
url: media_url["href"],
pleroma: %{mime_type: media_url["mediaType"]},
type: "unknown"
}
}
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

@ -29,6 +29,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

View file

@ -117,7 +117,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do
"pleroma:get:main/ostatus", "pleroma:get:main/ostatus",
"pleroma:group_actors", "pleroma:group_actors",
"pleroma:bookmark_folders", "pleroma:bookmark_folders",
"pleroma:block_expiration" "pleroma:block_expiration",
"pleroma:user_media_management"
] ]
@configurable_features [ @configurable_features [