M2 attachments: image upload + owner-scoped media serving
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s

- Migration 0007: note_attachments (path/mime/size). Upload POST
  /api/notes/<id>/attachments (multipart, png/jpeg/gif/webp, 12MB cap via
  MAX_CONTENT_LENGTH) stored under Config.media_root() (first use of DATA_DIR);
  owner/ACL-scoped GET serves the file (nosniff); DELETE removes row + file.
  Note responses include attachments[].
- Frontend: notes store uploadAttachment (FormData)/deleteAttachment; editor
  image button + paste-to-upload + thumbnail grid with remove; card shows the
  first image as a cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 22:21:26 -04:00
co-authored by Claude Opus 4.8
parent 31be66ac60
commit e4c898cd1b
10 changed files with 278 additions and 3 deletions
+32
View File
@@ -0,0 +1,32 @@
"""note_attachments
Revision ID: 0007
Revises: 0006
Create Date: 2026-07-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"note_attachments",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("mime", sa.Text(), nullable=False),
sa.Column("size", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_note_attachments_note", "note_attachments", ["note_id"])
def downgrade() -> None:
op.drop_index("ix_note_attachments_note", table_name="note_attachments")
op.drop_table("note_attachments")
+1
View File
@@ -15,6 +15,7 @@ const paths: Record<string, string> = {
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>', plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>', check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>', checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
}; };
</script> </script>
+11 -1
View File
@@ -19,6 +19,14 @@ function cardClass(color: NoteColor): string {
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md" class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="cardClass(note.color)" :class="cardClass(note.color)"
> >
<img
v-if="note.attachments.length"
:src="note.attachments[0].url"
alt=""
class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
@click="emit('open', note)"
/>
<!-- Checklist notes can't nest interactive controls in a <button>, so use a <!-- Checklist notes can't nest interactive controls in a <button>, so use a
focusable div; text notes keep a semantic button. --> focusable div; text notes keep a semantic button. -->
<template v-if="note.kind === 'list'"> <template v-if="note.kind === 'list'">
@@ -48,7 +56,9 @@ function cardClass(color: NoteColor): string {
<p v-if="note.body" class="whitespace-pre-wrap break-words text-sm text-neutral-700 dark:text-neutral-300"> <p v-if="note.body" class="whitespace-pre-wrap break-words text-sm text-neutral-700 dark:text-neutral-300">
{{ note.body }} {{ note.body }}
</p> </p>
<p v-if="!note.title && !note.body" class="text-sm italic text-neutral-400">Empty note</p> <p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
Empty note
</p>
</button> </button>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1"> <div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
+64
View File
@@ -67,6 +67,38 @@ async function toggleKind() {
await notes.setKind(props.note.id, "list"); await notes.setKind(props.note.id, "list");
} }
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
function pickImage() {
fileInput.value?.click();
}
async function uploadFile(file: File) {
uploadError.value = "";
try {
await notes.uploadAttachment(props.note.id, file);
} catch (e) {
uploadError.value = (e as { error?: string }).error ?? "Could not upload image.";
}
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) await uploadFile(file);
input.value = "";
}
async function onPaste(e: ClipboardEvent) {
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile();
if (file) {
e.preventDefault();
await uploadFile(file);
}
}
async function close() { async function close() {
const changed = const changed =
(title.value.trim() || null) !== (props.note.title ?? null) || (title.value.trim() || null) !== (props.note.title ?? null) ||
@@ -94,8 +126,23 @@ async function act(fn: () => Promise<void>) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@keydown.esc="close" @keydown.esc="close"
@paste="onPaste"
> >
<div class="flex flex-col gap-2 p-4"> <div class="flex flex-col gap-2 p-4">
<div v-if="liveNote.attachments.length" class="flex flex-wrap gap-2">
<div v-for="att in liveNote.attachments" :key="att.id" class="group/att relative">
<img :src="att.url" alt="" class="h-24 w-24 rounded-lg object-cover" />
<button
type="button"
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove image"
@click="notes.deleteAttachment(note.id, att.id)"
>
×
</button>
</div>
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<input <input
v-model="title" v-model="title"
type="text" type="text"
@@ -134,6 +181,23 @@ async function act(fn: () => Promise<void>) {
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800"> <div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" /> <ColorPicker v-model="color" />
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button
v-if="!note.trashed"
type="button"
class="icon-btn"
title="Add image"
aria-label="Add image"
@click="pickImage"
>
<Icon name="image" />
</button>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
@change="onFileChange"
/>
<button <button
v-if="!note.trashed" v-if="!note.trashed"
type="button" type="button"
+28
View File
@@ -18,6 +18,12 @@ export interface ChecklistItem {
position: number; position: number;
} }
export interface Attachment {
id: string;
url: string;
mime: string;
}
export interface Note { export interface Note {
id: string; id: string;
title: string | null; title: string | null;
@@ -29,6 +35,7 @@ export interface Note {
trashed: boolean; trashed: boolean;
labels: NoteLabel[]; labels: NoteLabel[];
items: ChecklistItem[]; items: ChecklistItem[];
attachments: Attachment[];
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
} }
@@ -113,6 +120,25 @@ export const useNotesStore = defineStore("notes", () => {
reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`)); reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`));
} }
async function uploadAttachment(id: string, file: File): Promise<void> {
const form = new FormData();
form.append("file", file);
const resp = await fetch(`/api/notes/${id}/attachments`, { method: "POST", credentials: "include", body: form });
const data: unknown = await resp.json().catch(() => ({}));
if (!resp.ok) {
const message =
typeof data === "object" && data !== null && "error" in data
? String((data as { error: unknown }).error)
: "Upload failed.";
throw { error: message, status: resp.status };
}
reconcile(data as Note);
}
async function deleteAttachment(id: string, attId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
}
async function trash(id: string): Promise<void> { async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`)); reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
} }
@@ -143,6 +169,8 @@ export const useNotesStore = defineStore("notes", () => {
addItem, addItem,
updateItem, updateItem,
deleteItem, deleteItem,
uploadAttachment,
deleteAttachment,
trash, trash,
restore, restore,
deleteForever, deleteForever,
+1
View File
@@ -28,6 +28,7 @@ def create_app() -> Quart:
app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.config["MAX_CONTENT_LENGTH"] = 12 * 1024 * 1024 # image-upload body cap
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
+1 -1
View File
@@ -3,4 +3,4 @@
Imported for side effects only (model registration on Base.metadata). Imported for side effects only (model registration on Base.metadata).
""" """
from . import group, label, note, note_item, settings, share, user # noqa: F401 from . import group, label, note, note_attachment, note_item, settings, share, user # noqa: F401
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteAttachment(Base):
"""An image uploaded onto a note. Bytes live under Config.media_root(); this
row records the relative path + metadata."""
__tablename__ = "note_attachments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
path: Mapped[str] = mapped_column(Text(), nullable=False) # relative to media_root
mime: Mapped[str] = mapped_column(Text(), nullable=False)
size: Mapped[int] = mapped_column(BigInteger(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+108 -1
View File
@@ -1,18 +1,23 @@
from __future__ import annotations from __future__ import annotations
import os
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request, send_file
from sqlalchemy import delete, func, literal_column, select from sqlalchemy import delete, func, literal_column, select
from .acl import visible_to_user from .acl import visible_to_user
from .auth import login_required from .auth import login_required
from .config import Config
from .db import session_scope from .db import session_scope
from .models.label import Label, NoteLabel from .models.label import Label, NoteLabel
from .models.note import NOTE_COLORS, Note from .models.note import NOTE_COLORS, Note
from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem from .models.note_item import NoteItem
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
bp = Blueprint("notes", __name__, url_prefix="/api/notes") bp = Blueprint("notes", __name__, url_prefix="/api/notes")
VALID_FILTERS = {"active", "archived", "trash"} VALID_FILTERS = {"active", "archived", "trash"}
@@ -70,12 +75,34 @@ async def _items_for_notes(db, note_ids: list) -> dict:
return result return result
def _attachment_url(note_id, att_id) -> str:
return f"/api/notes/{note_id}/attachments/{att_id}"
async def _attachments_for_notes(db, note_ids: list) -> dict:
result: dict = {}
if not note_ids:
return result
rows = (
await db.scalars(
select(NoteAttachment).where(NoteAttachment.note_id.in_(note_ids)).order_by(NoteAttachment.created_at)
)
).all()
for att in rows:
result.setdefault(att.note_id, []).append(
{"id": str(att.id), "url": _attachment_url(att.note_id, att.id), "mime": att.mime}
)
return result
async def _serialize_note(db, note: Note) -> dict: async def _serialize_note(db, note: Note) -> dict:
data = note.serialize() data = note.serialize()
labels = await _labels_for_notes(db, [note.id]) labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, []) data["labels"] = labels.get(note.id, [])
items = await _items_for_notes(db, [note.id]) items = await _items_for_notes(db, [note.id])
data["items"] = items.get(note.id, []) data["items"] = items.get(note.id, [])
attachments = await _attachments_for_notes(db, [note.id])
data["attachments"] = attachments.get(note.id, [])
return data return data
@@ -83,11 +110,13 @@ async def _serialize_notes(db, notes: list) -> list:
ids = [n.id for n in notes] ids = [n.id for n in notes]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids) items_map = await _items_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids)
out = [] out = []
for n in notes: for n in notes:
data = n.serialize() data = n.serialize()
data["labels"] = labels_map.get(n.id, []) data["labels"] = labels_map.get(n.id, [])
data["items"] = items_map.get(n.id, []) data["items"] = items_map.get(n.id, [])
data["attachments"] = attach_map.get(n.id, [])
out.append(data) out.append(data)
return out return out
@@ -327,6 +356,84 @@ async def reorder_items(note_id: str):
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/attachments")
@login_required
async def upload_attachment(note_id: str):
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
files = await request.files
upload = files.get("file")
if upload is None:
return jsonify({"error": "no file provided"}), 400
mime = (upload.content_type or "").split(";")[0].strip().lower()
ext = ALLOWED_IMAGE_MIMES.get(mime)
if ext is None:
return jsonify({"error": "unsupported image type (png, jpeg, gif, webp only)"}), 415
att_id = uuid.uuid4()
rel = os.path.join(str(note.id), f"{att_id}{ext}")
dest = Config.media_root() / rel
dest.parent.mkdir(parents=True, exist_ok=True)
await upload.save(str(dest))
db.add(NoteAttachment(id=att_id, note_id=note.id, path=rel, mime=mime, size=dest.stat().st_size))
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
@bp.get("/<note_id>/attachments/<att_id>")
@login_required
async def get_attachment(note_id: str, att_id: str):
try:
nid = uuid.UUID(note_id)
aid = uuid.UUID(att_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
# Owner OR shared may view (rule 47).
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None:
return jsonify({"error": "not found"}), 404
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
if att is None:
return jsonify({"error": "not found"}), 404
mime = att.mime
file_path = Config.media_root() / att.path
if not file_path.is_file():
return jsonify({"error": "not found"}), 404
response = await send_file(str(file_path), mimetype=mime)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "private, max-age=86400"
return response
@bp.delete("/<note_id>/attachments/<att_id>")
@login_required
async def delete_attachment(note_id: str, att_id: str):
try:
aid = uuid.UUID(att_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id))
if att is None:
return jsonify({"error": "not found"}), 404
file_path = Config.media_root() / att.path
await db.delete(att)
await db.commit()
result = await _serialize_note(db, note)
try:
file_path.unlink(missing_ok=True)
except OSError:
pass
return jsonify(result)
@bp.post("/<note_id>/trash") @bp.post("/<note_id>/trash")
@login_required @login_required
async def trash_note(note_id: str): async def trash_note(note_id: str):
+6
View File
@@ -62,3 +62,9 @@ async def test_add_item_requires_auth(app):
client = app.test_client() client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"}) resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"})
assert resp.status_code == 401 assert resp.status_code == 401
async def test_upload_attachment_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments")
assert resp.status_code == 401