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:
@@ -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"},
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user