S1: unify error responses + id parsing (responses.py) across notes.py
M9 section S1, commit 2. Add src/thoughtsync/responses.py — the app's single
JSON-error shape and the two guards that pair with it:
- json_error(message, status): the one ({"error": ...}, code) builder
- not_found(): the standard 404, by far the most common note-route error
- parse_uuid(raw): parse a path/body id, None on malformed → pair with not_found()
notes.py adopts them everywhere: ~25 hand-built `jsonify({"error":"not found"}),404`
collapse to not_found(); ~20 other error returns to json_error(...); ~13 repeated
`try: uuid.UUID(x) except: ...` blocks to parse_uuid(). Behavior-preserving — same
bodies and status codes, one definition. jsonify stays for the success responses.
Other blueprints (auth, labels, saved_filters, sync, settings_api) adopt the same
helpers in their own M9 sections.
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:
+90
-99
@@ -19,6 +19,7 @@ from .auth import login_required
|
|||||||
from .common import coerce_bool, parse_dt
|
from .common import coerce_bool, parse_dt
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
|
from .responses import json_error, not_found, parse_uuid
|
||||||
from .settings import get_setting
|
from .settings import get_setting
|
||||||
from .unfurl import UnfurlError, unfurl
|
from .unfurl import UnfurlError, unfurl
|
||||||
from .models.label import Label, NoteLabel
|
from .models.label import Label, NoteLabel
|
||||||
@@ -237,9 +238,8 @@ async def _serialize_notes(db, notes: list) -> list:
|
|||||||
|
|
||||||
async def _get_owned(db, note_id: str) -> Note | None:
|
async def _get_owned(db, note_id: str) -> Note | None:
|
||||||
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
||||||
try:
|
nid = parse_uuid(note_id)
|
||||||
nid = uuid.UUID(note_id)
|
if nid is None:
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
return None
|
||||||
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
|
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ def next_occurrence(remind_at: datetime, recurrence: str, after: datetime) -> da
|
|||||||
async def list_notes():
|
async def list_notes():
|
||||||
filter_name = request.args.get("filter", "active")
|
filter_name = request.args.get("filter", "active")
|
||||||
if filter_name not in VALID_FILTERS:
|
if filter_name not in VALID_FILTERS:
|
||||||
return jsonify({"error": "invalid filter"}), 400
|
return json_error("invalid filter", 400)
|
||||||
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
|
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
|
||||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||||
label_params = request.args.getlist("label")
|
label_params = request.args.getlist("label")
|
||||||
@@ -390,18 +390,17 @@ async def list_notes():
|
|||||||
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||||
stmt = apply_filter(stmt, filter_name)
|
stmt = apply_filter(stmt, filter_name)
|
||||||
for raw_label in label_params:
|
for raw_label in label_params:
|
||||||
try:
|
lid = parse_uuid(raw_label)
|
||||||
lid = uuid.UUID(raw_label)
|
if lid is None:
|
||||||
except (ValueError, TypeError):
|
return json_error("invalid label", 400)
|
||||||
return jsonify({"error": "invalid label"}), 400
|
|
||||||
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
||||||
if color is not None:
|
if color is not None:
|
||||||
if color not in NOTE_COLORS:
|
if color not in NOTE_COLORS:
|
||||||
return jsonify({"error": "invalid color"}), 400
|
return json_error("invalid color", 400)
|
||||||
stmt = stmt.where(Note.color == color)
|
stmt = stmt.where(Note.color == color)
|
||||||
if kind is not None:
|
if kind is not None:
|
||||||
if kind not in ("text", "list"):
|
if kind not in ("text", "list"):
|
||||||
return jsonify({"error": "invalid kind"}), 400
|
return json_error("invalid kind", 400)
|
||||||
stmt = stmt.where(Note.kind == kind)
|
stmt = stmt.where(Note.kind == kind)
|
||||||
if has_reminder:
|
if has_reminder:
|
||||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||||
@@ -410,12 +409,12 @@ async def list_notes():
|
|||||||
if after_param:
|
if after_param:
|
||||||
after_dt = parse_dt(after_param)
|
after_dt = parse_dt(after_param)
|
||||||
if after_dt is None:
|
if after_dt is None:
|
||||||
return jsonify({"error": "invalid created_after"}), 400
|
return json_error("invalid created_after", 400)
|
||||||
stmt = stmt.where(Note.created_at >= after_dt)
|
stmt = stmt.where(Note.created_at >= after_dt)
|
||||||
if before_param:
|
if before_param:
|
||||||
before_dt = parse_dt(before_param)
|
before_dt = parse_dt(before_param)
|
||||||
if before_dt is None:
|
if before_dt is None:
|
||||||
return jsonify({"error": "invalid created_before"}), 400
|
return json_error("invalid created_before", 400)
|
||||||
stmt = stmt.where(Note.created_at < before_dt)
|
stmt = stmt.where(Note.created_at < before_dt)
|
||||||
if query_text:
|
if query_text:
|
||||||
# Full-text match over title+body (generated tsvector, migration 0005),
|
# Full-text match over title+body (generated tsvector, migration 0005),
|
||||||
@@ -482,7 +481,7 @@ async def complete_reminder(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if note.remind_at is not None and note.recurrence in REMINDER_RECURRENCES:
|
if note.remind_at is not None and note.recurrence in REMINDER_RECURRENCES:
|
||||||
note.remind_at = next_occurrence(note.remind_at, note.recurrence, datetime.now(timezone.utc))
|
note.remind_at = next_occurrence(note.remind_at, note.recurrence, datetime.now(timezone.utc))
|
||||||
else:
|
else:
|
||||||
@@ -505,7 +504,7 @@ async def snooze_reminder(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
@@ -877,20 +876,19 @@ async def import_notes():
|
|||||||
files = await request.files
|
files = await request.files
|
||||||
upload = files.get("file")
|
upload = files.get("file")
|
||||||
if upload is None:
|
if upload is None:
|
||||||
return jsonify({"error": "no file provided"}), 400
|
return json_error("no file provided", 400)
|
||||||
raw = upload.stream.read()
|
raw = upload.stream.read()
|
||||||
if not raw:
|
if not raw:
|
||||||
return jsonify({"error": "empty upload"}), 400
|
return json_error("empty upload", 400)
|
||||||
try:
|
try:
|
||||||
zf = zipfile.ZipFile(io.BytesIO(raw))
|
zf = zipfile.ZipFile(io.BytesIO(raw))
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
return jsonify({"error": "that file isn't a valid .zip archive"}), 400
|
return json_error("that file isn't a valid .zip archive", 400)
|
||||||
|
|
||||||
specs, source = _read_import_specs(zf)
|
specs, source = _read_import_specs(zf)
|
||||||
if not specs:
|
if not specs:
|
||||||
return jsonify(
|
return json_error(
|
||||||
{"error": "no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip"}
|
"no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip", 400
|
||||||
), 400
|
)
|
||||||
|
|
||||||
imported = 0
|
imported = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
@@ -965,16 +963,15 @@ async def link_search():
|
|||||||
@bp.get("/<note_id>/backlinks")
|
@bp.get("/<note_id>/backlinks")
|
||||||
@login_required
|
@login_required
|
||||||
async def note_backlinks(note_id: str):
|
async def note_backlinks(note_id: str):
|
||||||
try:
|
nid = parse_uuid(note_id)
|
||||||
nid = uuid.UUID(note_id)
|
if nid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await db.scalar(
|
note = await db.scalar(
|
||||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||||
)
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if not note.display_title:
|
if not note.display_title:
|
||||||
return jsonify({"backlinks": []})
|
return jsonify({"backlinks": []})
|
||||||
norm = note.display_title.strip().lower()
|
norm = note.display_title.strip().lower()
|
||||||
@@ -1005,13 +1002,13 @@ async def reorder_notes():
|
|||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
ids = data.get("ids")
|
ids = data.get("ids")
|
||||||
if not isinstance(ids, list):
|
if not isinstance(ids, list):
|
||||||
return jsonify({"error": "ids must be a list"}), 400
|
return json_error("ids must be a list", 400)
|
||||||
parsed: list = []
|
parsed: list = []
|
||||||
for rid in ids:
|
for rid in ids:
|
||||||
try:
|
parsed_id = parse_uuid(rid)
|
||||||
parsed.append(uuid.UUID(str(rid)))
|
if parsed_id is None:
|
||||||
except (ValueError, TypeError):
|
return json_error("invalid id", 400)
|
||||||
return jsonify({"error": "invalid id"}), 400
|
parsed.append(parsed_id)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
owned = {
|
owned = {
|
||||||
n.id: n
|
n.id: n
|
||||||
@@ -1038,9 +1035,9 @@ async def create_note():
|
|||||||
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
||||||
if kind == "list":
|
if kind == "list":
|
||||||
if not (title.strip() or item_texts):
|
if not (title.strip() or item_texts):
|
||||||
return jsonify({"error": "note is empty"}), 400
|
return json_error("note is empty", 400)
|
||||||
elif is_empty_note(title, body):
|
elif is_empty_note(title, body):
|
||||||
return jsonify({"error": "note is empty"}), 400
|
return json_error("note is empty", 400)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# New notes go to the top of the manual order.
|
# New notes go to the top of the manual order.
|
||||||
max_pos = await db.scalar(
|
max_pos = await db.scalar(
|
||||||
@@ -1072,16 +1069,15 @@ async def create_note():
|
|||||||
@bp.get("/<note_id>")
|
@bp.get("/<note_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_note(note_id: str):
|
async def get_note(note_id: str):
|
||||||
try:
|
nid = parse_uuid(note_id)
|
||||||
nid = uuid.UUID(note_id)
|
if nid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await db.scalar(
|
note = await db.scalar(
|
||||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||||
)
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
@@ -1092,7 +1088,7 @@ async def update_note(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
old_display = note.display_title
|
old_display = note.display_title
|
||||||
old_title = note.title
|
old_title = note.title
|
||||||
old_body = note.body
|
old_body = note.body
|
||||||
@@ -1117,7 +1113,7 @@ async def update_note(note_id: str):
|
|||||||
else:
|
else:
|
||||||
remind_dt = parse_dt(raw)
|
remind_dt = parse_dt(raw)
|
||||||
if remind_dt is None:
|
if remind_dt is None:
|
||||||
return jsonify({"error": "invalid remind_at"}), 400
|
return json_error("invalid remind_at", 400)
|
||||||
note.remind_at = remind_dt
|
note.remind_at = remind_dt
|
||||||
if "recurrence" in data:
|
if "recurrence" in data:
|
||||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||||
@@ -1157,7 +1153,7 @@ async def list_revisions(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
select(NoteRevision)
|
select(NoteRevision)
|
||||||
@@ -1172,17 +1168,16 @@ async def list_revisions(note_id: str):
|
|||||||
@bp.post("/<note_id>/revisions/<rev_id>/restore")
|
@bp.post("/<note_id>/revisions/<rev_id>/restore")
|
||||||
@login_required
|
@login_required
|
||||||
async def restore_revision(note_id: str, rev_id: str):
|
async def restore_revision(note_id: str, rev_id: str):
|
||||||
try:
|
rid = parse_uuid(rev_id)
|
||||||
rid = uuid.UUID(rev_id)
|
if rid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
||||||
if rev is None:
|
if rev is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if note.title == rev.title and note.body == rev.body:
|
if note.title == rev.title and note.body == rev.body:
|
||||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||||
@@ -1208,17 +1203,17 @@ async def set_note_labels(note_id: str):
|
|||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
raw_ids = data.get("label_ids")
|
raw_ids = data.get("label_ids")
|
||||||
if not isinstance(raw_ids, list):
|
if not isinstance(raw_ids, list):
|
||||||
return jsonify({"error": "label_ids must be a list"}), 400
|
return json_error("label_ids must be a list", 400)
|
||||||
label_ids: list = []
|
label_ids: list = []
|
||||||
for rid in raw_ids:
|
for rid in raw_ids:
|
||||||
try:
|
parsed_label = parse_uuid(rid)
|
||||||
label_ids.append(uuid.UUID(str(rid)))
|
if parsed_label is None:
|
||||||
except (ValueError, TypeError):
|
return json_error("invalid label id", 400)
|
||||||
return jsonify({"error": "invalid label id"}), 400
|
label_ids.append(parsed_label)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
owned: set = set()
|
owned: set = set()
|
||||||
if label_ids:
|
if label_ids:
|
||||||
owned = set(
|
owned = set(
|
||||||
@@ -1244,9 +1239,8 @@ async def set_note_labels(note_id: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
||||||
try:
|
iid = parse_uuid(item_id)
|
||||||
iid = uuid.UUID(item_id)
|
if iid is None:
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
return None
|
||||||
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
|
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
|
||||||
|
|
||||||
@@ -1259,7 +1253,7 @@ async def add_item(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
max_pos = await db.scalar(
|
max_pos = await db.scalar(
|
||||||
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
|
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
|
||||||
)
|
)
|
||||||
@@ -1275,10 +1269,10 @@ async def update_item(note_id: str, item_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
item = await _get_item(db, note, item_id)
|
item = await _get_item(db, note, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if "text" in data and isinstance(data["text"], str):
|
if "text" in data and isinstance(data["text"], str):
|
||||||
item.text = data["text"]
|
item.text = data["text"]
|
||||||
if "checked" in data:
|
if "checked" in data:
|
||||||
@@ -1293,10 +1287,10 @@ async def delete_item(note_id: str, item_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
item = await _get_item(db, note, item_id)
|
item = await _get_item(db, note, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
await db.delete(item)
|
await db.delete(item)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
@@ -1308,11 +1302,11 @@ async def reorder_items(note_id: str):
|
|||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
order = data.get("item_ids")
|
order = data.get("item_ids")
|
||||||
if not isinstance(order, list):
|
if not isinstance(order, list):
|
||||||
return jsonify({"error": "item_ids must be a list"}), 400
|
return json_error("item_ids must be a list", 400)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
existing = {
|
existing = {
|
||||||
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
||||||
}
|
}
|
||||||
@@ -1351,11 +1345,11 @@ async def upload_attachment(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
files = await request.files
|
files = await request.files
|
||||||
upload = files.get("file")
|
upload = files.get("file")
|
||||||
if upload is None:
|
if upload is None:
|
||||||
return jsonify({"error": "no file provided"}), 400
|
return json_error("no file provided", 400)
|
||||||
# Any file type is allowed — images render inline, everything else downloads.
|
# Any file type is allowed — images render inline, everything else downloads.
|
||||||
mime = (upload.content_type or "application/octet-stream").split(";")[0].strip().lower()
|
mime = (upload.content_type or "application/octet-stream").split(";")[0].strip().lower()
|
||||||
filename = _safe_filename(upload.filename)
|
filename = _safe_filename(upload.filename)
|
||||||
@@ -1366,10 +1360,10 @@ async def upload_attachment(note_id: str):
|
|||||||
raw_id = (form.get("id") or "").strip()
|
raw_id = (form.get("id") or "").strip()
|
||||||
att_id = uuid.uuid4()
|
att_id = uuid.uuid4()
|
||||||
if raw_id:
|
if raw_id:
|
||||||
try:
|
parsed_att = parse_uuid(raw_id)
|
||||||
att_id = uuid.UUID(raw_id)
|
if parsed_att is None:
|
||||||
except (ValueError, TypeError):
|
return json_error("invalid attachment id", 400)
|
||||||
return jsonify({"error": "invalid attachment id"}), 400
|
att_id = parsed_att
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(NoteAttachment).where(NoteAttachment.id == att_id, NoteAttachment.note_id == note.id)
|
select(NoteAttachment).where(NoteAttachment.id == att_id, NoteAttachment.note_id == note.id)
|
||||||
)
|
)
|
||||||
@@ -1378,7 +1372,7 @@ async def upload_attachment(note_id: str):
|
|||||||
raw = upload.stream.read()
|
raw = upload.stream.read()
|
||||||
max_mb = int(await get_setting(db, "max_attachment_mb"))
|
max_mb = int(await get_setting(db, "max_attachment_mb"))
|
||||||
if len(raw) > max_mb * 1024 * 1024:
|
if len(raw) > max_mb * 1024 * 1024:
|
||||||
return jsonify({"error": f"file is too large (max {max_mb} MB)"}), 413
|
return json_error(f"file is too large (max {max_mb} MB)", 413)
|
||||||
rel = os.path.join(str(note.id), f"{att_id}{ext}")
|
rel = os.path.join(str(note.id), f"{att_id}{ext}")
|
||||||
dest = Config.media_root() / rel
|
dest = Config.media_root() / rel
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1401,26 +1395,25 @@ async def upload_attachment(note_id: str):
|
|||||||
@bp.get("/<note_id>/attachments/<att_id>")
|
@bp.get("/<note_id>/attachments/<att_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_attachment(note_id: str, att_id: str):
|
async def get_attachment(note_id: str, att_id: str):
|
||||||
try:
|
nid = parse_uuid(note_id)
|
||||||
nid = uuid.UUID(note_id)
|
aid = parse_uuid(att_id)
|
||||||
aid = uuid.UUID(att_id)
|
if nid is None or aid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# Owner OR shared may view (rule 47).
|
# Owner OR shared may view (rule 47).
|
||||||
note = await db.scalar(
|
note = await db.scalar(
|
||||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||||
)
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
|
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
|
||||||
if att is None:
|
if att is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
mime = att.mime
|
mime = att.mime
|
||||||
filename = att.filename or os.path.basename(att.path)
|
filename = att.filename or os.path.basename(att.path)
|
||||||
file_path = Config.media_root() / att.path
|
file_path = Config.media_root() / att.path
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
response = await send_file(str(file_path), mimetype=mime)
|
response = await send_file(str(file_path), mimetype=mime)
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["Cache-Control"] = "private, max-age=86400"
|
response.headers["Cache-Control"] = "private, max-age=86400"
|
||||||
@@ -1433,17 +1426,16 @@ async def get_attachment(note_id: str, att_id: str):
|
|||||||
@bp.delete("/<note_id>/attachments/<att_id>")
|
@bp.delete("/<note_id>/attachments/<att_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def delete_attachment(note_id: str, att_id: str):
|
async def delete_attachment(note_id: str, att_id: str):
|
||||||
try:
|
aid = parse_uuid(att_id)
|
||||||
aid = uuid.UUID(att_id)
|
if aid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id))
|
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id))
|
||||||
if att is None:
|
if att is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
file_path = Config.media_root() / att.path
|
file_path = Config.media_root() / att.path
|
||||||
await db.delete(att)
|
await db.delete(att)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -1463,22 +1455,22 @@ async def unfurl_link(note_id: str):
|
|||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
url = (data.get("url") or "").strip()
|
url = (data.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return jsonify({"error": "url is required"}), 400
|
return json_error("url is required", 400)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if not await get_setting(db, "enable_url_unfurl"):
|
if not await get_setting(db, "enable_url_unfurl"):
|
||||||
return jsonify({"error": "link previews are disabled"}), 403
|
return json_error("link previews are disabled", 403)
|
||||||
# Fetch OUTSIDE the DB session — network IO shouldn't hold a connection.
|
# Fetch OUTSIDE the DB session — network IO shouldn't hold a connection.
|
||||||
try:
|
try:
|
||||||
preview = await unfurl(url)
|
preview = await unfurl(url)
|
||||||
except UnfurlError as e:
|
except UnfurlError as e:
|
||||||
return jsonify({"error": str(e)}), 502
|
return json_error(str(e), 502)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
# Keyed by the ORIGINAL pasted url (what the note body contains, so the client
|
# Keyed by the ORIGINAL pasted url (what the note body contains, so the client
|
||||||
# matches it) — re-unfurling the same link updates the cached preview in place.
|
# matches it) — re-unfurling the same link updates the cached preview in place.
|
||||||
row = await db.scalar(
|
row = await db.scalar(
|
||||||
@@ -1498,19 +1490,18 @@ async def unfurl_link(note_id: str):
|
|||||||
@bp.delete("/<note_id>/previews/<preview_id>")
|
@bp.delete("/<note_id>/previews/<preview_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def delete_preview(note_id: str, preview_id: str):
|
async def delete_preview(note_id: str, preview_id: str):
|
||||||
try:
|
pid = parse_uuid(preview_id)
|
||||||
pid = uuid.UUID(preview_id)
|
if pid is None:
|
||||||
except (ValueError, TypeError):
|
return not_found()
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
row = await db.scalar(
|
row = await db.scalar(
|
||||||
select(NoteLinkPreview).where(NoteLinkPreview.id == pid, NoteLinkPreview.note_id == note.id)
|
select(NoteLinkPreview).where(NoteLinkPreview.id == pid, NoteLinkPreview.note_id == note.id)
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
await db.delete(row)
|
await db.delete(row)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
@@ -1522,7 +1513,7 @@ async def trash_note(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
note.deleted_at = datetime.now(timezone.utc)
|
note.deleted_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
@@ -1535,7 +1526,7 @@ async def restore_note(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
note.deleted_at = None
|
note.deleted_at = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
@@ -1548,9 +1539,9 @@ async def delete_note(note_id: str):
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return not_found()
|
||||||
if note.deleted_at is None:
|
if note.deleted_at is None:
|
||||||
return jsonify({"error": "note must be trashed before permanent delete"}), 409
|
return json_error("note must be trashed before permanent delete", 409)
|
||||||
await db.delete(note)
|
await db.delete(note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from quart import jsonify
|
||||||
|
|
||||||
|
# The app's single JSON error shape + the two guards that pair with it, so every
|
||||||
|
# blueprint returns errors and parses path ids the same way instead of hand-building
|
||||||
|
# `jsonify({"error": ...}), code` and try/except uuid blocks at ~35 call sites.
|
||||||
|
|
||||||
|
|
||||||
|
def json_error(message: str, status: int):
|
||||||
|
"""A JSON error body + status: ({"error": message}, status)."""
|
||||||
|
return jsonify({"error": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
def not_found():
|
||||||
|
"""404 with the standard body — by far the most common error in the note routes."""
|
||||||
|
return json_error("not found", 404)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uuid(raw: object) -> uuid.UUID | None:
|
||||||
|
"""Parse a path/body UUID, returning None on anything malformed. Pair with
|
||||||
|
not_found() for the ubiquitous 'bad id in the URL → 404' guard."""
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(raw))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
Reference in New Issue
Block a user