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:
parent
ac04cf6817
commit
5774d83ece
@ -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 |
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
20
devplacepy/schemas/uploads.py
Normal file
20
devplacepy/schemas/uploads.py
Normal 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
|
||||||
@ -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
227
tests/api/uploads/manage.py
Normal 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"])
|
||||||
Loading…
Reference in New Issue
Block a user