Files
inkwell/src/thoughtsync/notes/__init__.py
T
bvandeusen bc22f8e249
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Remove [[wiki-links]], backlinks and the graph
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00

894 lines
34 KiB
Python

"""Notes API (the `/api/notes` blueprint).
The bulk of the shared logic lives in cohesive sibling modules — serialization
(`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle.
External callers import from `thoughtsync.notes` (see `__all__`); those names are
re-exported here so the package is a drop-in replacement for the old module."""
from __future__ import annotations
import hashlib
import io
import json
import os
import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from quart import Response, g, jsonify, request, send_file
from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user
from ..auth import login_required
from ..colors import NOTE_COLORS, normalize_color
from ..common import coerce_bool, iso, parse_dt
from ..config import Config
from ..db import session_scope
from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision
from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note
from ..settings import get_setting
from ..unfurl import UnfurlError, unfurl
from ._bp import bp
from .helpers import (
ALLOWED_IMAGE_MIMES,
VALID_FILTERS,
_attachment_ext,
_get_owned,
_header_filename,
_safe_filename,
_slugify,
apply_filter,
derive_display_title,
is_empty_note,
parse_list_items,
)
from .import_export import (
IMPORT_MAX_ENTRIES,
_ImportBudget,
_ImportTooLarge,
_create_imported_note,
_keep_spec,
_native_spec,
_note_markdown,
_read_import_specs,
_usec_to_dt,
)
from .tags import (
_reconcile_tags,
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
__all__ = [
"bp",
"derive_display_title",
"is_empty_note",
"parse_list_items",
"parse_tags",
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_reconcile_tags",
"_serialize_notes",
"_safe_filename",
"_attachment_ext",
"_header_filename",
"_slugify",
"_keep_spec",
"_native_spec",
"_usec_to_dt",
]
@bp.get("")
@login_required
async def list_notes():
filter_name = request.args.get("filter", "active")
if filter_name not in VALID_FILTERS:
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")
color = request.args.get("color")
kind = request.args.get("kind")
has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip()
# Optional creation-date range — the "browse by when" / Timeline lens. Both bounds
# are ISO-8601 instants forming a HALF-OPEN interval [created_after, created_before),
# so a client can pass local day-boundaries (start-of-day .. start-of-next-day)
# without off-by-one. `sort=created` orders newest-captured first for a chronological
# timeline; the default keeps the board's pinned/position/updated order.
after_param = request.args.get("created_after")
before_param = request.args.get("created_before")
sort = request.args.get("sort")
async with session_scope() as db:
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:
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 json_error("invalid color", 400)
stmt = stmt.where(Note.color == color)
if kind is not None:
if kind not in ("text", "list"):
return json_error("invalid kind", 400)
stmt = stmt.where(Note.kind == kind)
if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment:
stmt = stmt.where(Note.id.in_(select(NoteAttachment.note_id)))
if after_param:
after_dt = parse_dt(after_param)
if after_dt is None:
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 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),
# ranked — so the facet bar's text box searches, not just filters.
tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc()
)
elif sort == "created":
stmt = stmt.order_by(Note.created_at.desc())
else:
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/search")
@login_required
async def search_notes():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"notes": []})
async with session_scope() as db:
tsquery = func.websearch_to_tsquery("english", q)
# search_vector is a generated column (migration 0005), not mapped on the ORM.
search_col = literal_column("notes.search_vector")
stmt = (
select(Note)
.where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.deleted_at.is_(None),
search_col.op("@@")(tsquery),
)
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
.limit(100)
)
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/reminders")
@login_required
async def list_reminders():
async with session_scope() as db:
stmt = (
select(Note)
.where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.deleted_at.is_(None),
Note.remind_at.is_not(None),
)
.order_by(Note.remind_at.asc())
)
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.post("/<note_id>/reminder/complete")
@login_required
async def complete_reminder(note_id: str):
"""Mark a reminder handled: a recurring reminder advances to its next occurrence;
a one-off clears its reminder."""
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
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:
note.remind_at = None
note.recurrence = None
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/reminder/snooze")
@login_required
async def snooze_reminder(note_id: str):
"""Re-fire a reminder a little later — remind_at moves to now + `minutes`."""
data = await request.get_json(silent=True) or {}
try:
minutes = int(data.get("minutes", 10))
except (ValueError, TypeError):
minutes = 10
minutes = max(1, min(minutes, 60 * 24 * 30)) # 1 minute .. 30 days
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.get("/export")
@login_required
async def export_notes():
"""Download all of the caller's notes as a zip: a machine-readable notes.json,
a Markdown file per note, and the attachment media. 'Your data is yours.'"""
async with session_scope() as db:
notes_list = (
await db.scalars(
select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)).order_by(Note.created_at)
)
).all()
ids = [n.id for n in notes_list]
labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
att_rows = (
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
)
att_by_note: dict = {}
for a in att_rows:
att_by_note.setdefault(a.note_id, []).append(a)
all_labels = (
await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))
).all()
payload: dict = {
"app": "thoughtsync",
"version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(),
"labels": [{"name": lb.name, "color": lb.color} for lb in all_labels],
"notes": [],
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for n in notes_list:
labels = labels_map.get(n.id, [])
items = items_map.get(n.id, [])
atts = att_by_note.get(n.id, [])
short = str(n.id)[:8]
payload["notes"].append(
{
"id": str(n.id),
"title": n.title,
"display_title": n.display_title,
"body": n.body,
"color": n.color,
"kind": n.kind,
"pinned": n.pinned,
"archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
"recurrence": n.recurrence,
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels],
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
"attachments": [
{"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts
],
}
)
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels, items))
for a in atts:
src = Config.media_root() / a.path
if src.is_file():
zf.write(src, f"attachments/{short}/{os.path.basename(a.path)}")
zf.writestr("notes.json", json.dumps(payload, indent=2, ensure_ascii=False))
data = buf.getvalue()
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
data,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="thoughtsync-export-{stamp}.zip"',
},
)
@bp.post("/import")
@login_required
async def import_notes():
"""Import notes from an uploaded zip — either a ThoughtSync export (round-trip)
or a Google Keep Takeout archive. Additive: imported notes are appended, never
overwriting existing ones. Returns a per-run summary."""
files = await request.files
upload = files.get("file")
if upload is None:
return json_error("no file provided", 400)
raw = upload.stream.read()
if not raw:
return json_error("empty upload", 400)
try:
zf = zipfile.ZipFile(io.BytesIO(raw))
except zipfile.BadZipFile:
return json_error("that file isn't a valid .zip archive", 400)
if len(zf.infolist()) > IMPORT_MAX_ENTRIES:
return json_error("that archive has too many files to import", 413)
# Bound total decompressed bytes so a zip bomb can't exhaust memory/disk. Any entry
# (or the cumulative total) blowing the budget aborts the whole import — nothing is
# committed, since the session rolls back when the exception exits its block.
budget = _ImportBudget()
try:
specs, source = _read_import_specs(zf, budget)
if not specs:
return json_error(
"no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip", 400
)
imported = 0
skipped = 0
async with session_scope() as db:
max_pos = await db.scalar(
select(func.coalesce(func.max(Note.position), 0)).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
pos = int(max_pos)
for spec in specs:
if await _create_imported_note(db, g.user_id, spec, zf, pos + 1, budget):
pos += 1
imported += 1
else:
skipped += 1
await db.commit()
except _ImportTooLarge:
return json_error("that archive is too large to import", 413)
return jsonify({"source": source, "imported": imported, "skipped": skipped}), 201
@bp.get("/titles")
@login_required
async def list_titles():
"""Owner's non-trashed notes, keyed by their display NAME.
Survived the removal of [[wiki-links]] (note 2897) because it was serving two
different things, and only one of them was linking. This is what the command
palette lists so someone can jump to a note by name — which is recall, the thing
this app is actually for. The `[[` autocomplete that also read it is gone.
"""
async with session_scope() as db:
rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
return jsonify(
{"titles": [{"id": str(n.id), "title": n.display_title} for n in rows if n.display_title]}
)
@bp.post("/reorder")
@login_required
async def reorder_notes():
data = await request.get_json(silent=True) or {}
ids = data.get("ids")
if not isinstance(ids, list):
return json_error("ids must be a list", 400)
parsed: list = []
for rid in ids:
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
for n in (await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.id.in_(parsed)))).all()
}
total = len(parsed)
for index, nid in enumerate(parsed):
note = owned.get(nid)
if note is not None:
note.position = total - index
await db.commit()
return jsonify({"ok": True})
@bp.post("")
@login_required
async def create_note():
data = await request.get_json(silent=True) or {}
title = data.get("title") if isinstance(data.get("title"), str) else ""
body = data.get("body") if isinstance(data.get("body"), str) else ""
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text"
# A checklist note's "content" is its items, not the body — so it's non-empty
# when it has a title or at least one item (quick-add can create one in one shot).
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
if kind == "list":
if not (title.strip() or item_texts):
return json_error("note is empty", 400)
elif is_empty_note(title, body):
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(
select(func.coalesce(func.max(Note.position), 0)).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
clean_title = title.strip() or None
note = Note(
owner_id=g.user_id,
title=clean_title,
display_title=derive_display_title(clean_title, body),
body=body,
kind=kind,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
)
db.add(note)
await db.flush() # assign note.id before writing items/links
for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note)), 201
@bp.get("/<note_id>")
@login_required
async def get_note(note_id: str):
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 not_found()
return jsonify(await _serialize_note(db, note))
@bp.patch("/<note_id>")
@login_required
async def update_note(note_id: str):
data = await request.get_json(silent=True) or {}
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
old_title = note.title
old_body = note.body
if "title" in data:
title = data["title"] if isinstance(data["title"], str) else ""
note.title = title.strip() or None
if "body" in data and isinstance(data["body"], str):
note.body = data["body"]
if "color" in data:
note.color = normalize_color(data["color"])
if "kind" in data and data["kind"] in ("text", "list"):
note.kind = data["kind"]
if "pinned" in data:
note.pinned = bool(data["pinned"])
if "archived" in data:
note.archived = bool(data["archived"])
if "remind_at" in data:
raw = data["remind_at"]
if raw in (None, ""):
note.remind_at = None
note.recurrence = None # no reminder → recurrence is moot
else:
remind_dt = parse_dt(raw)
if remind_dt is None:
return json_error("invalid remind_at", 400)
note.remind_at = remind_dt
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
# Recompute the display name (explicit title, else first body line) whenever
# the title or body may have changed.
if "title" in data or "body" in data:
note.display_title = derive_display_title(note.title, note.body)
if "body" in data:
await _reconcile_tags(db, note)
# Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
def _serialize_revision(rev: NoteRevision) -> dict:
return {
"id": str(rev.id),
"title": rev.title,
"body": rev.body,
"created_at": iso(rev.created_at),
}
@bp.get("/<note_id>/revisions")
@login_required
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 not_found()
rows = (
await db.scalars(
select(NoteRevision)
.where(NoteRevision.note_id == note.id)
.order_by(NoteRevision.created_at.desc())
.limit(50)
)
).all()
return jsonify({"revisions": [_serialize_revision(r) for r in rows]})
@bp.post("/<note_id>/revisions/<rev_id>/restore")
@login_required
async def restore_revision(note_id: str, rev_id: str):
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 not_found()
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None:
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
# the revision — with the same title/body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@bp.put("/<note_id>/labels")
@login_required
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 json_error("label_ids must be a list", 400)
label_ids: list = []
for rid in raw_ids:
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 not_found()
# The picker manages MANUAL labels only; tag-sourced (via_tag=True) rows are
# governed by the body #tags and survive a picker save (see labeling module).
owned = await resolve_owned_label_ids(db, label_ids, g.user_id)
await reconcile_manual_labels(db, note, owned)
await db.commit()
return jsonify(await _serialize_note(db, note))
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
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))
@bp.post("/<note_id>/items")
@login_required
async def add_item(note_id: str):
data = await request.get_json(silent=True) or {}
text = data["text"].strip() if isinstance(data.get("text"), str) else ""
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
max_pos = await db.scalar(
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
)
db.add(NoteItem(note_id=note.id, text=text, position=int(max_pos) + 1))
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
@bp.patch("/<note_id>/items/<item_id>")
@login_required
async def update_item(note_id: str, item_id: str):
data = await request.get_json(silent=True) or {}
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
item = await _get_item(db, note, item_id)
if item is None:
return not_found()
if "text" in data and isinstance(data["text"], str):
item.text = data["text"]
if "checked" in data:
item.checked = bool(data["checked"])
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>/items/<item_id>")
@login_required
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 not_found()
item = await _get_item(db, note, item_id)
if item is None:
return not_found()
await db.delete(item)
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/items/reorder")
@login_required
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 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 not_found()
existing = {
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
}
pos = 0
for iid in order:
item = existing.get(str(iid))
if item is not None:
item.position = pos
pos += 1
await db.commit()
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 not_found()
files = await request.files
upload = files.get("file")
if upload is None:
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)
ext = _attachment_ext(filename, mime)
# A native client may supply the attachment's id so an offline-attached file
# keeps its identity across sync. Re-uploading an id it already has is a no-op.
form = await request.form
raw_id = (form.get("id") or "").strip()
att_id = uuid.uuid4()
if raw_id:
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)
)
if existing is not None:
return jsonify(await _serialize_note(db, note)) # already have this blob
raw = upload.stream.read()
max_mb = int(await get_setting(db, "max_attachment_mb"))
if len(raw) > max_mb * 1024 * 1024:
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)
dest.write_bytes(raw)
db.add(
NoteAttachment(
id=att_id,
note_id=note.id,
path=rel,
filename=filename,
mime=mime,
size=len(raw),
sha256=hashlib.sha256(raw).hexdigest(),
)
)
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):
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 not_found()
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
if att is None:
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 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"
# Only trusted raster image types render inline; everything else — notably
# image/svg+xml, which can carry script — downloads, so a shared note's attachment
# can't execute script in a viewer's session (rule 47 = shares).
disposition = "inline" if mime in ALLOWED_IMAGE_MIMES else "attachment"
response.headers["Content-Disposition"] = f'{disposition}; filename="{_header_filename(filename)}"'
return response
@bp.delete("/<note_id>/attachments/<att_id>")
@login_required
async def delete_attachment(note_id: str, att_id: str):
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 not_found()
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id))
if att is None:
return not_found()
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>/unfurl")
@login_required
async def unfurl_link(note_id: str):
"""Fetch a link preview for a URL in this note and store it. Opt-in via the
enable_url_unfurl setting; SSRF-guarded server-side fetch (see unfurl.py)."""
data = await request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
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 not_found()
if not await get_setting(db, "enable_url_unfurl"):
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 json_error(str(e), 502)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
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(
select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id, NoteLinkPreview.url == url)
)
if row is None:
row = NoteLinkPreview(note_id=note.id, url=url)
db.add(row)
row.title = preview["title"]
row.description = preview["description"]
row.image_url = preview["image_url"]
row.site_name = preview["site_name"]
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
@bp.delete("/<note_id>/previews/<preview_id>")
@login_required
async def delete_preview(note_id: str, preview_id: str):
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 not_found()
row = await db.scalar(
select(NoteLinkPreview).where(NoteLinkPreview.id == pid, NoteLinkPreview.note_id == note.id)
)
if row is None:
return not_found()
await db.delete(row)
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/trash")
@login_required
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 not_found()
note.deleted_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/restore")
@login_required
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 not_found()
note.deleted_at = None
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>")
@login_required
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 not_found()
if note.deleted_at is None:
return json_error("note must be trashed before permanent delete", 409)
# A tombstone, not a dropped row. Deleting the row outright would leave the
# server with no record the note ever existed, so a linked device that was
# offline at the time would keep its copy forever — and push it back the
# next time it was edited. The delete has to be something clients can LEARN.
await purge_note(db, note)
await db.commit()
return jsonify({"ok": True})