From 5774d83eceb00319cf045cd6cd995bce97a48caa Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 24 Jul 2026 20:30:49 +0200 Subject: [PATCH] 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. --- README.md | 2 +- devplacepy/attachments.py | 15 ++ devplacepy/database/__init__.py | 4 +- devplacepy/database/attachments_data.py | 47 ++++ devplacepy/docs_api/groups/uploads.py | 150 +++++++++++- devplacepy/models.py | 4 + devplacepy/routers/CLAUDE.md | 2 +- devplacepy/routers/uploads.py | 79 +++++- devplacepy/schemas/__init__.py | 4 + devplacepy/schemas/uploads.py | 20 ++ .../services/devii/actions/catalog/uploads.py | 51 +++- tests/api/uploads/manage.py | 227 ++++++++++++++++++ 12 files changed, 592 insertions(+), 13 deletions(-) create mode 100644 devplacepy/schemas/uploads.py create mode 100644 tests/api/uploads/manage.py diff --git a/README.md b/README.md index e2ddaad8..4e302ffe 100644 --- a/README.md +++ b/README.md @@ -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` | | `/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) | -| `/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 | | `/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 ` 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 | diff --git a/devplacepy/attachments.py b/devplacepy/attachments.py index 63977db2..c1a05905 100644 --- a/devplacepy/attachments.py +++ b/devplacepy/attachments.py @@ -546,6 +546,21 @@ def delete_attachment(uid): _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"): row = get_table("attachments").find_one(uid=uid) if not row or row.get("deleted_at"): diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 5764e0ba..49c8f2b6 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -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 .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 .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 .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_attachment_file", "get_user_media", + "get_user_attachments", + "get_user_attachment", "get_deleted_media", "_stats_cache", "get_site_stats", diff --git a/devplacepy/database/attachments_data.py b/devplacepy/database/attachments_data.py index 410a7d3e..8b5b1d33 100644 --- a/devplacepy/database/attachments_data.py +++ b/devplacepy/database/attachments_data.py @@ -124,6 +124,53 @@ def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple: 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: if "attachments" not in db.tables: return [], build_pagination(page, 0, per_page) diff --git a/devplacepy/docs_api/groups/uploads.py b/devplacepy/docs_api/groups/uploads.py index c913702c..d5fef6fd 100644 --- a/devplacepy/docs_api/groups/uploads.py +++ b/devplacepy/docs_api/groups/uploads.py @@ -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"}, ), ], diff --git a/devplacepy/models.py b/devplacepy/models.py index 9a9ac569..8c46b4b0 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -289,6 +289,10 @@ class UploadUrlForm(BaseModel): filename: Optional[str] = Field(default=None, max_length=255) +class AttachmentRenameForm(BaseModel): + filename: str = Field(min_length=1, max_length=255) + + class ProjectFileWriteForm(BaseModel): path: str = Field(min_length=1, max_length=1024) content: str = Field(default="", max_length=400000) diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index cd85fa83..b397f506 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -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) | | `/gists` | gists.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) | | `/openai` | openai_gateway.py | | `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` | diff --git a/devplacepy/routers/uploads.py b/devplacepy/routers/uploads.py index 648a4fa5..5f43ab34 100644 --- a/devplacepy/routers/uploads.py +++ b/devplacepy/routers/uploads.py @@ -5,16 +5,23 @@ from pathlib import Path from typing import Annotated from fastapi import Depends, APIRouter, Request from fastapi.responses import JSONResponse -from devplacepy.database import get_table, get_int_setting -from devplacepy.models import UploadUrlForm +from devplacepy.database import ( + 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.attachments import ( store_attachment, store_attachment_from_url, soft_delete_attachment, + rename_attachment, is_extension_allowed, RemoteFetchError, ) +from devplacepy.schemas import UploadItemOut, UploadsListOut from urllib.parse import urlparse from devplacepy.services.audit import record as audit 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) +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}") async def delete_attachment_route(request: Request, attachment_uid: str): user = require_user_api(request) diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index 2551e8ce..093a2339 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -115,6 +115,10 @@ from devplacepy.schemas.gateway import ( GatewayUsageOut, UserAiUsageOut, ) +from devplacepy.schemas.uploads import ( + UploadItemOut, + UploadsListOut, +) from devplacepy.schemas.statistics import StatisticsOut from devplacepy.schemas.auth import ( AuthPageOut, diff --git a/devplacepy/schemas/uploads.py b/devplacepy/schemas/uploads.py new file mode 100644 index 00000000..c9e7331a --- /dev/null +++ b/devplacepy/schemas/uploads.py @@ -0,0 +1,20 @@ +# retoor + +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 diff --git a/devplacepy/services/devii/actions/catalog/uploads.py b/devplacepy/services/devii/actions/catalog/uploads.py index 3a65ec09..dc4b70f3 100644 --- a/devplacepy/services/devii/actions/catalog/uploads.py +++ b/devplacepy/services/devii/actions/catalog/uploads.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..spec import Action -from ._shared import body, confirm, path, upload +from ._shared import body, confirm, path, query, upload UPLOAD_ACTIONS: tuple[Action, ...] = ( @@ -14,6 +14,44 @@ UPLOAD_ACTIONS: tuple[Action, ...] = ( summary="Upload a local file and obtain its attachment uid", 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( name="attach_url", method="POST", @@ -39,7 +77,16 @@ UPLOAD_ACTIONS: tuple[Action, ...] = ( name="delete_attachment", method="DELETE", 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()), ), ) diff --git a/tests/api/uploads/manage.py b/tests/api/uploads/manage.py new file mode 100644 index 00000000..5d672590 --- /dev/null +++ b/tests/api/uploads/manage.py @@ -0,0 +1,227 @@ +# retoor + +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"])