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:
retoor 2026-07-24 20:30:49 +02:00
parent ac04cf6817
commit 5774d83ece
12 changed files with 592 additions and 13 deletions

View File

@ -69,7 +69,7 @@ devplacepy/
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` | | `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator | | `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) | | `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` | | `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform | | `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) | | `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets | | `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |

View File

@ -546,6 +546,21 @@ def delete_attachment(uid):
_delete_attachment_row(row) _delete_attachment_row(row)
def rename_attachment(uid, filename):
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
ext = Path(row.get("stored_name", "")).suffix.lower()
stem = Path(str(filename)).name.strip()
if ext:
stem = Path(stem).stem
if not stem:
return None
clean = f"{stem}{ext}"
get_table("attachments").update({"uid": uid, "original_filename": clean}, ["uid"])
return clean
def soft_delete_attachment(uid, deleted_by="system"): def soft_delete_attachment(uid, deleted_by="system"):
row = get_table("attachments").find_one(uid=uid) row = get_table("attachments").find_one(uid=uid)
if not row or row.get("deleted_at"): if not row or row.get("deleted_at"):

View File

@ -37,7 +37,7 @@ from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_se
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
@ -234,6 +234,8 @@ __all__ = [
"delete_attachments", "delete_attachments",
"_delete_attachment_file", "_delete_attachment_file",
"get_user_media", "get_user_media",
"get_user_attachments",
"get_user_attachment",
"get_deleted_media", "get_deleted_media",
"_stats_cache", "_stats_cache",
"get_site_stats", "get_site_stats",

View File

@ -124,6 +124,53 @@ def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
return items, pagination return items, pagination
def _decorate_attachment(row: dict) -> dict:
from devplacepy.attachments import _row_to_attachment
item = _row_to_attachment(row)
item["linked"] = bool(item.get("target_type"))
item["target_url"] = (
resolve_object_url(item["target_type"], item["target_uid"])
if item["linked"]
else None
)
return item
def get_user_attachments(
user_uid: str, page: int = 1, per_page: int = 24, linked=None
) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
clause = "user_uid=:u AND deleted_at IS NULL"
if linked is True:
clause += " AND target_type != ''"
elif linked is False:
clause += " AND target_type = ''"
total = list(
db.query(f"SELECT COUNT(*) AS n FROM attachments WHERE {clause}", u=user_uid)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
f"SELECT * FROM attachments WHERE {clause} "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
return [_decorate_attachment(row) for row in rows], pagination
def get_user_attachment(uid: str) -> dict | None:
if "attachments" not in db.tables:
return None
row = db["attachments"].find_one(uid=uid, deleted_at=None)
if not row:
return None
return _decorate_attachment(row)
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple: def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables: if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page) return [], build_pagination(page, 0, per_page)

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 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. `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 Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests. four ways to sign requests.
@ -83,13 +88,62 @@ four ways to sign requests.
}, },
), ),
endpoint( endpoint(
id="uploads-delete", id="uploads-list",
method="DELETE", method="GET",
path="/uploads/delete/{attachment_uid}", path="/uploads",
title="Delete an attachment", title="List your attachments",
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).", 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", auth="user",
destructive=True,
params=[ params=[
field( field(
"attachment_uid", "attachment_uid",
@ -100,6 +154,90 @@ four ways to sign requests.
"UID of the attachment.", "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"}, sample_response={"status": "deleted"},
), ),
], ],

View File

@ -289,6 +289,10 @@ class UploadUrlForm(BaseModel):
filename: Optional[str] = Field(default=None, max_length=255) filename: Optional[str] = Field(default=None, max_length=255)
class AttachmentRenameForm(BaseModel):
filename: str = Field(min_length=1, max_length=255)
class ProjectFileWriteForm(BaseModel): class ProjectFileWriteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024) path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000) content: str = Field(default="", max_length=400000)

View File

@ -30,7 +30,7 @@ Prefixes are wired in `main.py`:
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) | | `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py | | `/gists` | gists.py |
| `/news` | news.py | | `/news` | news.py |
| `/uploads` | uploads.py | | `/uploads` | uploads.py - attachment management CRUD for the signed-in user over the ONE `attachments` table (the same rows that appear on posts/comments/projects/gists/issues). Create: `POST /upload` (multipart), `POST /upload-url` (server-side fetch). Read: `GET ""` (own attachments, paginated 24/page newest-first, `?page=`, `?linked=true|false` via `database.get_user_attachments`), `GET /{attachment_uid}` (one, via `database.get_user_attachment`). Update: `PATCH /{attachment_uid}` (rename display filename via `attachments.rename_attachment`; the original file extension is ALWAYS preserved - renaming can never change the file type, the upload-time security control - audit `attachment.rename`). Delete: `DELETE /delete/{attachment_uid}` (soft delete). All `require_user_api` (401 for guests); read/rename/delete of another user's row is owner-or-admin. JSON-only router (no HTML/`respond`); list uses `UploadsListOut`, single/rename return `UploadItemOut`. Devii tools mirror every face: `upload_file`/`attach_url`/`list_attachments`/`get_attachment`/`rename_attachment`/`delete_attachment` |
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) | | `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
| `/openai` | openai_gateway.py | | `/openai` | openai_gateway.py |
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` | | `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |

View File

@ -5,16 +5,23 @@ from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import Depends, APIRouter, Request from fastapi import Depends, APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_int_setting from devplacepy.database import (
from devplacepy.models import UploadUrlForm get_table,
get_int_setting,
get_user_attachments,
get_user_attachment,
)
from devplacepy.models import UploadUrlForm, AttachmentRenameForm
from devplacepy.utils import require_user_api, is_admin, track_action from devplacepy.utils import require_user_api, is_admin, track_action
from devplacepy.attachments import ( from devplacepy.attachments import (
store_attachment, store_attachment,
store_attachment_from_url, store_attachment_from_url,
soft_delete_attachment, soft_delete_attachment,
rename_attachment,
is_extension_allowed, is_extension_allowed,
RemoteFetchError, RemoteFetchError,
) )
from devplacepy.schemas import UploadItemOut, UploadsListOut
from urllib.parse import urlparse from urllib.parse import urlparse
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
from devplacepy.dependencies import json_or_form from devplacepy.dependencies import json_or_form
@ -91,6 +98,74 @@ async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Depen
) )
return JSONResponse(result, status_code=201) return JSONResponse(result, status_code=201)
def _linked_filter(linked):
if linked is None:
return None
return linked.strip().lower() in ("1", "true", "yes", "linked")
@router.get("")
async def list_attachments(
request: Request, page: int = 1, linked: str | None = None
):
user = require_user_api(request)
items, pagination = get_user_attachments(
user["uid"], page=page, linked=_linked_filter(linked)
)
payload = {
"attachments": items,
"pagination": pagination,
"total": pagination.get("total", len(items)),
}
return JSONResponse(UploadsListOut.model_validate(payload).model_dump(mode="json"))
@router.get("/{attachment_uid}")
async def get_attachment_route(request: Request, attachment_uid: str):
user = require_user_api(request)
item = get_user_attachment(attachment_uid)
if not item:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
if item.get("user_uid") and item["user_uid"] != user["uid"] and not is_admin(user):
return JSONResponse({"error": "Not authorized"}, status_code=403)
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
@router.patch("/{attachment_uid}")
async def rename_attachment_route(
request: Request,
attachment_uid: str,
data: Annotated[
AttachmentRenameForm, Depends(json_or_form(AttachmentRenameForm))
],
):
user = require_user_api(request)
att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
return JSONResponse({"error": "Not authorized"}, status_code=403)
old_name = att.get("original_filename")
new_name = rename_attachment(attachment_uid, data.filename)
if new_name is None:
return JSONResponse({"error": "Invalid filename"}, status_code=400)
logger.info(f"Attachment {attachment_uid} renamed to {new_name} by {user['username']}")
audit.record(
request,
"attachment.rename",
user=user,
target_type="attachment",
target_uid=attachment_uid,
target_label=new_name,
old_value=old_name,
new_value=new_name,
summary=f"{user['username']} renamed attachment to {new_name}",
links=[audit.attachment_link(attachment_uid, new_name)],
)
item = get_user_attachment(attachment_uid)
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
@router.delete("/delete/{attachment_uid}") @router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str): async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user_api(request) user = require_user_api(request)

View File

@ -115,6 +115,10 @@ from devplacepy.schemas.gateway import (
GatewayUsageOut, GatewayUsageOut,
UserAiUsageOut, UserAiUsageOut,
) )
from devplacepy.schemas.uploads import (
UploadItemOut,
UploadsListOut,
)
from devplacepy.schemas.statistics import StatisticsOut from devplacepy.schemas.statistics import StatisticsOut
from devplacepy.schemas.auth import ( from devplacepy.schemas.auth import (
AuthPageOut, AuthPageOut,

View File

@ -0,0 +1,20 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.profile import MediaItemOut
class UploadItemOut(MediaItemOut):
is_audio: bool = False
linked: bool = False
user_uid: Optional[str] = None
class UploadsListOut(_Out):
attachments: list[UploadItemOut] = []
pagination: Optional[Any] = None
total: int = 0

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from ..spec import Action from ..spec import Action
from ._shared import body, confirm, path, upload from ._shared import body, confirm, path, query, upload
UPLOAD_ACTIONS: tuple[Action, ...] = ( UPLOAD_ACTIONS: tuple[Action, ...] = (
@ -14,6 +14,44 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
summary="Upload a local file and obtain its attachment uid", summary="Upload a local file and obtain its attachment uid",
params=(upload("file", "Local filesystem path of the file to upload."),), params=(upload("file", "Local filesystem path of the file to upload."),),
), ),
Action(
name="list_attachments",
method="GET",
path="/uploads",
summary="List your own uploaded attachments, newest first",
description=(
"Returns the signed-in user's attachments as {attachments, pagination, total}. Each item "
"carries its uid, original_filename, mime_type, url, size, target_type/target_uid/target_url "
"(where it is used, if any), and a 'linked' flag. Pass linked=true to list only attachments "
"already attached to a post/comment/project/gist/issue, or linked=false for orphaned uploads."
),
params=(
query("page", "1-based page number, 24 per page."),
query("linked", "Filter: true = attached only, false = orphaned only."),
),
),
Action(
name="get_attachment",
method="GET",
path="/uploads/{attachment_uid}",
summary="Get the metadata of one of your attachments by uid",
params=(path("attachment_uid", "Uid of the attachment."),),
),
Action(
name="rename_attachment",
method="PATCH",
path="/uploads/{attachment_uid}",
summary="Rename an attachment you own (its display filename); the file extension is preserved",
description=(
"Updates only the display filename of the attachment; the stored file and its extension are "
"unchanged (the original extension is always kept, so the file type cannot be altered). "
"Administrators may rename any user's attachment."
),
params=(
path("attachment_uid", "Uid of the attachment."),
body("filename", "New display filename.", required=True),
),
),
Action( Action(
name="attach_url", name="attach_url",
method="POST", method="POST",
@ -39,7 +77,16 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
name="delete_attachment", name="delete_attachment",
method="DELETE", method="DELETE",
path="/uploads/delete/{attachment_uid}", path="/uploads/delete/{attachment_uid}",
summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Soft delete, confirmation required", summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Confirmation required",
description=(
"Removes one attachment by uid. Use list_attachments (or get_attachment) to find the uid of the "
"file the user wants gone, then delete it. The attachment leaves the owner's attachment list and "
"disappears from every post, comment, project, gist, message, or issue it was attached to, and its "
"file stops being served. Only the owner may delete their own; an administrator may delete anyone's "
"(otherwise 403); an unknown or already-removed uid returns 404. This is confirmation-gated: the "
"first call is refused so you can show the user the exact attachment (filename + where it is used) "
"first, and only a repeat call with confirm=true performs it."
),
params=(path("attachment_uid", "Uid of the attachment."), confirm()), params=(path("attachment_uid", "Uid of the attachment."), confirm()),
), ),
) )

227
tests/api/uploads/manage.py Normal file
View File

@ -0,0 +1,227 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import pytest
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
JSON_manage = {"Accept": "application/json"}
@pytest.fixture(scope="module", autouse=True)
def _manage_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
}.items():
set_setting(key, value)
yield
def _db_user(name):
refresh_snapshot()
return get_table("users").find_one(username=name)
def _user_manage(prefix="mng"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _png_bytes():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
return buf.getvalue()
def _upload(s, name="a.png", content=None, content_type="image/png"):
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": (name, content or _png_bytes(), content_type)},
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _admin_session(seeded_db):
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def test_list_requires_login(app_server):
r = requests.get(f"{BASE_URL}/uploads", headers=JSON_manage, allow_redirects=False)
assert r.status_code == 401
def test_list_own_attachments_newest_first(app_server):
s, _ = _user_manage()
first = _upload(s, "first.png")
second = _upload(s, "second.txt", b"hello world", "text/plain")
r = s.get(f"{BASE_URL}/uploads", headers=JSON_manage)
assert r.status_code == 200, r.text
body = r.json()
uids = [a["uid"] for a in body["attachments"]]
assert first in uids and second in uids
assert uids.index(second) < uids.index(first)
assert body["total"] >= 2
assert body["pagination"]["page"] == 1
def test_list_is_isolated_per_user(app_server):
alice, _ = _user_manage()
uid = _upload(alice, "alice.png")
bob, _ = _user_manage()
r = bob.get(f"{BASE_URL}/uploads", headers=JSON_manage)
assert r.status_code == 200
assert uid not in [a["uid"] for a in r.json()["attachments"]]
def test_list_linked_filter(app_server):
s, _ = _user_manage()
orphan = _upload(s, "orphan.png")
attached = _upload(s, "used.png")
post = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_manage,
data={
"title": f"manage {uuid.uuid4().hex[:6]}",
"content": "a post that carries an attachment",
"topic": "devlog",
"attachment_uids": attached,
},
)
assert post.status_code in (200, 201), post.text
linked = s.get(f"{BASE_URL}/uploads", headers=JSON_manage, params={"linked": "true"})
linked_uids = [a["uid"] for a in linked.json()["attachments"]]
assert attached in linked_uids
assert orphan not in linked_uids
unlinked = s.get(
f"{BASE_URL}/uploads", headers=JSON_manage, params={"linked": "false"}
)
unlinked_uids = [a["uid"] for a in unlinked.json()["attachments"]]
assert orphan in unlinked_uids
assert attached not in unlinked_uids
attached_item = next(
a for a in linked.json()["attachments"] if a["uid"] == attached
)
assert attached_item["linked"] is True
assert attached_item["target_type"] == "post"
assert attached_item["target_url"]
def test_get_one_attachment(app_server):
s, _ = _user_manage()
uid = _upload(s, "single.png")
r = s.get(f"{BASE_URL}/uploads/{uid}", headers=JSON_manage)
assert r.status_code == 200, r.text
body = r.json()
assert body["uid"] == uid
assert body["original_filename"] == "single.png"
assert body["is_image"] is True
def test_get_missing_attachment_404(app_server):
s, _ = _user_manage()
r = s.get(f"{BASE_URL}/uploads/{uuid.uuid4().hex}", headers=JSON_manage)
assert r.status_code == 404
def test_get_other_users_attachment_forbidden(app_server):
alice, _ = _user_manage()
uid = _upload(alice, "private.png")
bob, _ = _user_manage()
r = bob.get(f"{BASE_URL}/uploads/{uid}", headers=JSON_manage)
assert r.status_code == 403
def test_admin_can_get_any_attachment(seeded_db):
member, _ = _user_manage()
uid = _upload(member, "member.png")
admin = _admin_session(seeded_db)
r = admin.get(f"{BASE_URL}/uploads/{uid}", headers=JSON_manage)
assert r.status_code == 200
assert r.json()["uid"] == uid
def test_rename_attachment(app_server):
s, _ = _user_manage()
uid = _upload(s, "before.png")
r = s.patch(
f"{BASE_URL}/uploads/{uid}", headers=JSON_manage, data={"filename": "after"}
)
assert r.status_code == 200, r.text
assert r.json()["original_filename"] == "after.png"
check = s.get(f"{BASE_URL}/uploads/{uid}", headers=JSON_manage)
assert check.json()["original_filename"] == "after.png"
def test_rename_preserves_extension(app_server):
s, _ = _user_manage()
uid = _upload(s, "safe.png")
r = s.patch(
f"{BASE_URL}/uploads/{uid}", headers=JSON_manage, data={"filename": "evil.html"}
)
assert r.status_code == 200, r.text
assert r.json()["original_filename"] == "evil.png"
def test_rename_other_users_attachment_forbidden(app_server):
alice, _ = _user_manage()
uid = _upload(alice, "keep.png")
bob, _ = _user_manage()
r = bob.patch(
f"{BASE_URL}/uploads/{uid}", headers=JSON_manage, data={"filename": "hijack"}
)
assert r.status_code == 403
def test_rename_missing_attachment_404(app_server):
s, _ = _user_manage()
r = s.patch(
f"{BASE_URL}/uploads/{uuid.uuid4().hex}",
headers=JSON_manage,
data={"filename": "x"},
)
assert r.status_code == 404
def test_rename_recorded(seeded_db):
s, _ = _user_manage()
uid = _upload(s, "audited.png")
s.patch(
f"{BASE_URL}/uploads/{uid}", headers=JSON_manage, data={"filename": "renamed"}
)
admin = _admin_session(seeded_db)
log = admin.get(
f"{BASE_URL}/admin/audit-log",
headers=JSON_manage,
params={"event_key": "attachment.rename"},
)
assert log.status_code == 200
assert any(e.get("target_uid") == uid for e in log.json()["entries"])