Add attachment management CRUD to the /uploads API

Complete the read and update faces of the signed-in user's attachment
management over the existing attachments table:

- GET /uploads: paginated list of the user's own attachments, newest
  first, with an optional linked/orphaned filter
- GET /uploads/{uid}: fetch one attachment (owner or admin)
- PATCH /uploads/{uid}: rename the display filename, always preserving
  the original extension (owner or admin, audited as attachment.rename)

Adds get_user_attachments/get_user_attachment data helpers, the
rename_attachment operation, AttachmentRenameForm, the UploadItemOut and
UploadsListOut schemas, the Devii tools list_attachments/get_attachment/
rename_attachment, expanded API reference documentation for the full
lifecycle including delete, and api-tier tests.
This commit is contained in:
2026-07-26 16:46:41 +02:00
parent ac04cf6817
commit 5774d83ece
12 changed files with 592 additions and 13 deletions
+144 -6
View File
@@ -15,6 +15,11 @@ comment, project, gist, message, or issue - see
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.
You manage your own attachments over the full lifecycle: **list** every file you uploaded, **get**
one by uid, **rename** its display filename, and **delete** it. The list is the same set of
attachments that appear on your posts and other content - listing, renaming, or deleting one is
reflected everywhere it is used.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
@@ -83,13 +88,62 @@ four ways to sign requests.
},
),
endpoint(
id="uploads-delete",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
title="Delete an attachment",
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
id="uploads-list",
method="GET",
path="/uploads",
title="List your attachments",
summary="List every attachment you uploaded, newest first, paginated (24 per page).",
auth="user",
params=[
field(
"page",
"query",
"integer",
False,
"1",
"1-based page number.",
),
field(
"linked",
"query",
"string",
False,
"",
"Filter: `true` returns only attachments already used on a post/comment/project/gist/issue, `false` returns only orphaned uploads. Omit for all.",
),
],
notes=[
"Each item carries `uid`, `original_filename`, `mime_type`, `url`, `file_size`, its `target_type`/`target_uid`/`target_url` when linked, and a `linked` flag.",
],
sample_response={
"attachments": [
{
"uid": "ATTACHMENT_UID",
"original_filename": "photo.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"is_video": False,
"is_audio": False,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
}
],
"pagination": {"page": 1, "per_page": 24, "total": 1, "total_pages": 1},
"total": 1,
},
),
endpoint(
id="uploads-get",
method="GET",
path="/uploads/{attachment_uid}",
title="Get one attachment",
summary="Fetch the metadata of a single attachment you own; administrators may fetch any user's attachment.",
auth="user",
destructive=True,
params=[
field(
"attachment_uid",
@@ -100,6 +154,90 @@ four ways to sign requests.
"UID of the attachment.",
)
],
notes=["Returns `404` if the attachment does not exist, `403` if it is not yours."],
sample_response={
"uid": "ATTACHMENT_UID",
"original_filename": "photo.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"is_video": False,
"is_audio": False,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
},
),
endpoint(
id="uploads-rename",
method="PATCH",
path="/uploads/{attachment_uid}",
title="Rename an attachment",
summary="Change the display filename of an attachment you own; administrators may rename any user's attachment.",
auth="user",
params=[
field(
"attachment_uid",
"path",
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment.",
),
field(
"filename",
"form",
"string",
True,
"renamed.png",
"New display filename.",
),
],
notes=[
"Only the display filename changes; the stored file and its extension are untouched. The original extension is always preserved, so the file type cannot be altered.",
"Returns the updated attachment record. `404` if it does not exist, `403` if it is not yours, `400` for an empty filename.",
],
sample_response={
"uid": "ATTACHMENT_UID",
"original_filename": "renamed.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
},
),
endpoint(
id="uploads-delete",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
title="Delete an attachment",
summary="Remove an attachment you previously uploaded; administrators may remove any user's attachment.",
auth="user",
destructive=True,
params=[
field(
"attachment_uid",
"path",
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment (the `uid` returned by Upload a file, Attach a file from a URL, or List your attachments).",
)
],
notes=[
"Only the owner may delete their own attachment; an administrator may delete any user's. Deleting one you do not own returns `403`.",
"The attachment is removed everywhere at once: it leaves your attachment list (List your attachments) and disappears from every post, comment, project, gist, message, or issue it was attached to, and its file stops being served under `/static/uploads/`.",
"Idempotent from the caller's view: an already-removed or unknown uid returns `404`. A successful delete returns `200` with `{\"status\": \"deleted\"}`.",
"To detach a file from a single post/comment without removing the upload itself, edit that object's attachment list instead - deleting here removes the attachment from every place it is used.",
],
sample_response={"status": "deleted"},
),
],