diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index 733afd1..0e73a40 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -19,6 +19,7 @@ from .auth import login_required from .common import coerce_bool, parse_dt from .config import Config from .db import session_scope +from .responses import json_error, not_found, parse_uuid from .settings import get_setting from .unfurl import UnfurlError, unfurl 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: """Fetch a note the current user OWNS (mutations are owner-only in M1/M2).""" - try: - nid = uuid.UUID(note_id) - except (ValueError, TypeError): + nid = parse_uuid(note_id) + if nid is None: return None 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(): filter_name = request.args.get("filter", "active") 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 / # saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. 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 = apply_filter(stmt, filter_name) for raw_label in label_params: - try: - lid = uuid.UUID(raw_label) - except (ValueError, TypeError): - return jsonify({"error": "invalid label"}), 400 + lid = parse_uuid(raw_label) + if lid is None: + return json_error("invalid label", 400) stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid))) if color is not None: if color not in NOTE_COLORS: - return jsonify({"error": "invalid color"}), 400 + return json_error("invalid color", 400) stmt = stmt.where(Note.color == color) if kind is not None: if kind not in ("text", "list"): - return jsonify({"error": "invalid kind"}), 400 + return json_error("invalid kind", 400) stmt = stmt.where(Note.kind == kind) if has_reminder: stmt = stmt.where(Note.remind_at.is_not(None)) @@ -410,12 +409,12 @@ async def list_notes(): if after_param: after_dt = parse_dt(after_param) 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) if before_param: before_dt = parse_dt(before_param) 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) if query_text: # 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: note = await _get_owned(db, note_id) 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: note.remind_at = next_occurrence(note.remind_at, note.recurrence, datetime.now(timezone.utc)) else: @@ -505,7 +504,7 @@ async def snooze_reminder(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 + return not_found() note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes) await db.commit() return jsonify(await _serialize_note(db, note)) @@ -877,20 +876,19 @@ async def import_notes(): files = await request.files upload = files.get("file") if upload is None: - return jsonify({"error": "no file provided"}), 400 + return json_error("no file provided", 400) raw = upload.stream.read() if not raw: - return jsonify({"error": "empty upload"}), 400 + return json_error("empty upload", 400) try: zf = zipfile.ZipFile(io.BytesIO(raw)) 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) if not specs: - return jsonify( - {"error": "no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip"} - ), 400 + return json_error( + "no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip", 400 + ) imported = 0 skipped = 0 @@ -965,16 +963,15 @@ async def link_search(): @bp.get("//backlinks") @login_required async def note_backlinks(note_id: str): - try: - nid = uuid.UUID(note_id) - except (ValueError, TypeError): - return jsonify({"error": "not found"}), 404 + nid = parse_uuid(note_id) + if nid is None: + return not_found() async with session_scope() as db: 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 + return not_found() if not note.display_title: return jsonify({"backlinks": []}) norm = note.display_title.strip().lower() @@ -1005,13 +1002,13 @@ async def reorder_notes(): data = await request.get_json(silent=True) or {} ids = data.get("ids") 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 = [] for rid in ids: - try: - parsed.append(uuid.UUID(str(rid))) - except (ValueError, TypeError): - return jsonify({"error": "invalid id"}), 400 + parsed_id = parse_uuid(rid) + if parsed_id is None: + return json_error("invalid id", 400) + parsed.append(parsed_id) async with session_scope() as db: owned = { n.id: n @@ -1038,9 +1035,9 @@ async def create_note(): item_texts = parse_list_items(data.get("items")) if kind == "list" else [] if kind == "list": 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): - return jsonify({"error": "note is empty"}), 400 + return json_error("note is empty", 400) async with session_scope() as db: # New notes go to the top of the manual order. max_pos = await db.scalar( @@ -1072,16 +1069,15 @@ async def create_note(): @bp.get("/") @login_required async def get_note(note_id: str): - try: - nid = uuid.UUID(note_id) - except (ValueError, TypeError): - return jsonify({"error": "not found"}), 404 + nid = parse_uuid(note_id) + if nid is None: + return not_found() async with session_scope() as db: 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 + return not_found() return jsonify(await _serialize_note(db, note)) @@ -1092,7 +1088,7 @@ async def update_note(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 + return not_found() old_display = note.display_title old_title = note.title old_body = note.body @@ -1117,7 +1113,7 @@ async def update_note(note_id: str): else: remind_dt = parse_dt(raw) if remind_dt is None: - return jsonify({"error": "invalid remind_at"}), 400 + return json_error("invalid remind_at", 400) note.remind_at = remind_dt if "recurrence" in data: note.recurrence = normalize_recurrence(data["recurrence"]) @@ -1157,7 +1153,7 @@ async def list_revisions(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 + return not_found() rows = ( await db.scalars( select(NoteRevision) @@ -1172,17 +1168,16 @@ async def list_revisions(note_id: str): @bp.post("//revisions//restore") @login_required async def restore_revision(note_id: str, rev_id: str): - try: - rid = uuid.UUID(rev_id) - except (ValueError, TypeError): - return jsonify({"error": "not found"}), 404 + rid = parse_uuid(rev_id) + if rid is None: + return not_found() async with session_scope() as db: note = await _get_owned(db, note_id) 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)) if rev is None: - return jsonify({"error": "not found"}), 404 + return not_found() if note.title == rev.title and note.body == rev.body: 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 @@ -1208,17 +1203,17 @@ async def set_note_labels(note_id: str): data = await request.get_json(silent=True) or {} raw_ids = data.get("label_ids") 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 = [] for rid in raw_ids: - try: - label_ids.append(uuid.UUID(str(rid))) - except (ValueError, TypeError): - return jsonify({"error": "invalid label id"}), 400 + parsed_label = parse_uuid(rid) + if parsed_label is None: + return json_error("invalid label id", 400) + label_ids.append(parsed_label) async with session_scope() as db: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() owned: set = set() if label_ids: 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: - try: - iid = uuid.UUID(item_id) - except (ValueError, TypeError): + iid = parse_uuid(item_id) + if iid is None: return None 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: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() max_pos = await db.scalar( 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: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() item = await _get_item(db, note, item_id) if item is None: - return jsonify({"error": "not found"}), 404 + return not_found() if "text" in data and isinstance(data["text"], str): item.text = data["text"] if "checked" in data: @@ -1293,10 +1287,10 @@ async def delete_item(note_id: str, item_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 + return not_found() item = await _get_item(db, note, item_id) if item is None: - return jsonify({"error": "not found"}), 404 + return not_found() await db.delete(item) await db.commit() 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 {} order = data.get("item_ids") 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: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() existing = { 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: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() files = await request.files upload = files.get("file") 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. mime = (upload.content_type or "application/octet-stream").split(";")[0].strip().lower() filename = _safe_filename(upload.filename) @@ -1366,10 +1360,10 @@ async def upload_attachment(note_id: str): raw_id = (form.get("id") or "").strip() att_id = uuid.uuid4() if raw_id: - try: - att_id = uuid.UUID(raw_id) - except (ValueError, TypeError): - return jsonify({"error": "invalid attachment id"}), 400 + parsed_att = parse_uuid(raw_id) + if parsed_att is None: + return json_error("invalid attachment id", 400) + att_id = parsed_att existing = await db.scalar( 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() max_mb = int(await get_setting(db, "max_attachment_mb")) 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}") dest = Config.media_root() / rel dest.parent.mkdir(parents=True, exist_ok=True) @@ -1401,26 +1395,25 @@ async def upload_attachment(note_id: str): @bp.get("//attachments/") @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 + nid = parse_uuid(note_id) + aid = parse_uuid(att_id) + if nid is None or aid is None: + return not_found() 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 + return not_found() att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid)) if att is None: - return jsonify({"error": "not found"}), 404 + return not_found() mime = att.mime filename = att.filename or os.path.basename(att.path) file_path = Config.media_root() / att.path 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.headers["X-Content-Type-Options"] = "nosniff" response.headers["Cache-Control"] = "private, max-age=86400" @@ -1433,17 +1426,16 @@ async def get_attachment(note_id: str, att_id: str): @bp.delete("//attachments/") @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 + aid = parse_uuid(att_id) + if aid is None: + return not_found() async with session_scope() as db: note = await _get_owned(db, note_id) 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)) if att is None: - return jsonify({"error": "not found"}), 404 + return not_found() file_path = Config.media_root() / att.path await db.delete(att) await db.commit() @@ -1463,22 +1455,22 @@ async def unfurl_link(note_id: str): data = await request.get_json(silent=True) or {} url = (data.get("url") or "").strip() if not url: - return jsonify({"error": "url is required"}), 400 + return json_error("url is required", 400) async with session_scope() as db: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() 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. try: preview = await unfurl(url) except UnfurlError as e: - return jsonify({"error": str(e)}), 502 + return json_error(str(e), 502) async with session_scope() as db: note = await _get_owned(db, note_id) 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 # matches it) — re-unfurling the same link updates the cached preview in place. row = await db.scalar( @@ -1498,19 +1490,18 @@ async def unfurl_link(note_id: str): @bp.delete("//previews/") @login_required async def delete_preview(note_id: str, preview_id: str): - try: - pid = uuid.UUID(preview_id) - except (ValueError, TypeError): - return jsonify({"error": "not found"}), 404 + pid = parse_uuid(preview_id) + if pid is None: + return not_found() async with session_scope() as db: note = await _get_owned(db, note_id) if note is None: - return jsonify({"error": "not found"}), 404 + return not_found() row = await db.scalar( select(NoteLinkPreview).where(NoteLinkPreview.id == pid, NoteLinkPreview.note_id == note.id) ) if row is None: - return jsonify({"error": "not found"}), 404 + return not_found() await db.delete(row) await db.commit() return jsonify(await _serialize_note(db, note)) @@ -1522,7 +1513,7 @@ async def trash_note(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 + return not_found() note.deleted_at = datetime.now(timezone.utc) await db.commit() await db.refresh(note) @@ -1535,7 +1526,7 @@ async def restore_note(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 + return not_found() note.deleted_at = None await db.commit() await db.refresh(note) @@ -1548,9 +1539,9 @@ async def delete_note(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 + return not_found() 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.commit() return jsonify({"ok": True}) diff --git a/src/thoughtsync/responses.py b/src/thoughtsync/responses.py new file mode 100644 index 0000000..f464377 --- /dev/null +++ b/src/thoughtsync/responses.py @@ -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