server: one credential check, one coerce_bool, one top-position query, and the shared response helpers
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 49s

From the audit (#5179, server half).

- auth: login and device-login share _check_credentials (dummy hash for a
  missing account, throttle bookkeeping, the failure log line) and
  _bad_credentials.
- settings uses common.coerce_bool. Its private copy differed only in
  treating a non-string as its truthiness, which the shared one now does.
- notes: create and import share helpers.top_position.
- auth, settings_api, sync and client_dist return errors through
  responses.json_error / not_found, and parse ids with parse_uuid.
- sync: push replies are built by _result(id, entity, status, **extra).

Already merged by earlier steps, so nothing to do here: attachment storage
(store_attachment), the preview upsert (only unfurl_queue writes one now),
and _serialize_note (delegates to _serialize_notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:55:01 -04:00
co-authored by Claude Opus 5.5
parent b8f13cfc4a
commit 646a115701
9 changed files with 125 additions and 133 deletions
+58 -56
View File
@@ -16,6 +16,7 @@ from .password_resets import INVALID as INVALID_RESET, claim as claim_reset, mai
from .models.device_token import DeviceToken
from .models.user import User
from .proxy import client_address
from .responses import json_error, not_found, parse_uuid
from .ratelimit import (
register_by_address,
reset_mail_by_account,
@@ -81,11 +82,10 @@ def _session_user_id() -> uuid.UUID | None:
raw = session.get(SESSION_KEY)
if not raw:
return None
try:
return uuid.UUID(raw)
except (ValueError, TypeError):
uid = parse_uuid(raw)
if uid is None:
_sign_out()
return None
return uid
def _bearer_token() -> str | None:
@@ -131,7 +131,7 @@ def login_required(fn):
if uid is None:
uid = await _user_id_from_bearer()
if uid is None:
return jsonify({"error": "authentication required"}), 401
return json_error("authentication required", 401)
g.user_id = uid
return await fn(*args, **kwargs)
@@ -146,13 +146,13 @@ def require_admin(fn):
async def wrapper(*args, **kwargs):
uid = _session_user_id()
if uid is None:
return jsonify({"error": "authentication required"}), 401
return json_error("authentication required", 401)
async with session_scope() as db:
user = await db.get(User, uid)
if not _session_is_current(user.session_epoch if user else None):
return jsonify({"error": "authentication required"}), 401
return json_error("authentication required", 401)
if not user.is_admin:
return jsonify({"error": "admin access required"}), 403
return json_error("admin access required", 403)
g.user_id = uid
return await fn(*args, **kwargs)
@@ -168,11 +168,7 @@ def _throttled(retry_after: int):
genuinely needs.
"""
logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after)
return (
jsonify({"error": "too many attempts — try again shortly"}),
429,
{"Retry-After": str(retry_after)},
)
return (*json_error("too many attempts — try again shortly", 429), {"Retry-After": str(retry_after)})
def _sign_in_block(email: str) -> int | None:
@@ -215,9 +211,9 @@ async def register():
invite = (data.get("invite") or "").strip()
if not email or "@" not in email:
return jsonify({"error": "a valid email is required"}), 400
return json_error("a valid email is required", 400)
if len(password) < MIN_PASSWORD_LEN:
return jsonify({"error": f"password must be at least {MIN_PASSWORD_LEN} characters"}), 400
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
if not display_name:
display_name = email.split("@", 1)[0]
@@ -242,15 +238,15 @@ async def register():
invite_id = await redeem(db, invite, email)
if invite_id is None:
logger.warning("registration refused (bad invite) email=%s from=%s", email, client_address())
return jsonify({"error": INVALID_INVITE}), 403
return json_error(INVALID_INVITE, 403)
elif not is_first and not await get_setting(db, "allow_registration"):
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
return jsonify({"error": "registration is closed"}), 403
return json_error("registration is closed", 403)
# Returning without a commit rolls back the redemption above with it, so a
# taken email leaves the invite usable.
existing = await db.scalar(select(User).where(User.email == email))
if existing is not None:
return jsonify({"error": "an account with that email already exists"}), 409
return json_error("an account with that email already exists", 409)
user = User(
email=email,
password_hash=hash_password(password),
@@ -284,6 +280,32 @@ async def register():
return jsonify(_serialize_user(user)), 201
async def _check_credentials(db, email: str, password: str, route: str) -> User | None:
"""The account these credentials open, or None. Counts the failure against the
throttle, logs it under `route`, and clears the account's history on success.
A missing account still pays for a hash. Without that, "no such account" returns
in microseconds while a wrong password takes bcrypt's deliberate ~100ms, and the
difference is a reliable oracle for which emails have accounts here.
"""
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash:
dummy_verify(password)
reason = "no such account"
elif not verify_password(password, user.password_hash):
reason = "bad password"
else:
_sign_in_succeeded(email)
return user
_sign_in_failed(email)
logger.warning("%s failed (%s) email=%s from=%s", route, reason, email, client_address())
return None
def _bad_credentials():
return json_error("invalid email or password", 401)
@bp.post("/login")
async def login():
data = await request.get_json(silent=True) or {}
@@ -295,20 +317,9 @@ async def login():
return _throttled(wait)
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash:
# Hash anyway. Without this, "no such account" returns in microseconds
# while a wrong password takes bcrypt's deliberate ~100ms, and the
# difference is a reliable oracle for which emails have accounts here.
dummy_verify(password)
_sign_in_failed(email)
logger.warning("sign-in failed (no such account) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
if not verify_password(password, user.password_hash):
_sign_in_failed(email)
logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
_sign_in_succeeded(email)
user = await _check_credentials(db, email, password, "sign-in")
if user is None:
return _bad_credentials()
_sign_in(user)
logger.info("sign-in ok email=%s from=%s", email, client_address())
return jsonify(_serialize_user(user))
@@ -327,7 +338,7 @@ async def me():
user = await db.get(User, g.user_id)
if user is None:
_sign_out()
return jsonify({"error": "authentication required"}), 401
return json_error("authentication required", 401)
return jsonify(_serialize_user(user))
@@ -347,7 +358,7 @@ async def forgot_password():
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
if not email or "@" not in email:
return jsonify({"error": "a valid email is required"}), 400
return json_error("a valid email is required", 400)
wait = _sign_in_block("")
if wait is not None:
@@ -356,7 +367,7 @@ async def forgot_password():
async with session_scope() as db:
if not await mail_configured(db):
return jsonify({"error": "this server can't send email; ask your admin for a reset link"}), 400
return json_error("this server can't send email; ask your admin for a reset link", 400)
if reset_mail_by_account.retry_after(email) is None:
reset_mail_by_account.record(email)
@@ -379,7 +390,7 @@ async def reset_password():
token = (data.get("token") or "").strip()
password = data.get("password") or ""
if len(password) < MIN_PASSWORD_LEN:
return jsonify({"error": f"password must be at least {MIN_PASSWORD_LEN} characters"}), 400
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
# The sign-in budget for this address. A token can't be guessed, but each attempt
# is still a credential attempt, and the log should show a run of them.
@@ -392,7 +403,7 @@ async def reset_password():
if user_id is None:
_sign_in_failed("")
logger.warning("password reset refused (bad link) from=%s", client_address())
return jsonify({"error": INVALID_RESET}), 403
return json_error(INVALID_RESET, 403)
user = await db.get(User, user_id)
user.password_hash = hash_password(password)
user.session_epoch = User.session_epoch + 1
@@ -439,7 +450,7 @@ async def device_login():
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
return json_error("email and password are required", 400)
# Same budget as the web sign-in, and the SAME counters — this route hands out a
# long-lived bearer token, so leaving it unthrottled would just move the guessing
@@ -449,17 +460,9 @@ async def device_login():
return _throttled(wait)
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash:
dummy_verify(password)
_sign_in_failed(email)
logger.warning("device-login failed (no such account) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
if not verify_password(password, user.password_hash):
_sign_in_failed(email)
logger.warning("device-login failed (bad password) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
_sign_in_succeeded(email)
user = await _check_credentials(db, email, password, "device-login")
if user is None:
return _bad_credentials()
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
# A device token outlives the session that made it, so its creation is the
# most consequential thing on this blueprint.
@@ -513,7 +516,7 @@ async def revoke_own_device():
# A session-cookie caller holds no device token, so "revoke the one I'm
# using" is meaningless rather than merely unauthorized. The web app
# revokes by id.
return jsonify({"error": "no device token was presented"}), 400
return json_error("no device token was presented", 400)
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(
@@ -524,7 +527,7 @@ async def revoke_own_device():
)
)
if row is None:
return jsonify({"error": "not found"}), 404
return not_found()
await db.delete(row)
await db.commit()
return jsonify({"ok": True})
@@ -533,16 +536,15 @@ async def revoke_own_device():
@bp.delete("/devices/<device_id>")
@login_required
async def revoke_device(device_id: str):
try:
did = uuid.UUID(device_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
did = parse_uuid(device_id)
if did is None:
return not_found()
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(DeviceToken.id == did, DeviceToken.user_id == g.user_id)
)
if row is None:
return jsonify({"error": "not found"}), 404
return not_found()
await db.delete(row)
await db.commit()
return jsonify({"ok": True})
+4 -3
View File
@@ -80,6 +80,7 @@ from quart import Blueprint, jsonify, send_from_directory
from .auth import login_required
from .config import Config
from .responses import json_error
@dataclass(frozen=True)
@@ -323,7 +324,7 @@ async def client_metadata(platform_id: str):
"""
found = release(platform_id)
if found is None:
return jsonify({"error": f"this server has no {platform_id} client"}), 404
return json_error(f"this server has no {platform_id} client", 404)
return jsonify(found)
@@ -338,10 +339,10 @@ async def client_download(platform_id: str):
"""
platform = BY_ID.get(platform_id)
if platform is None:
return jsonify({"error": f"unknown client platform '{platform_id}'"}), 404
return json_error(f"unknown client platform '{platform_id}'", 404)
resolved = _resolve(platform)
if resolved is None:
return jsonify({"error": f"this server has no {platform_id} client"}), 404
return json_error(f"this server has no {platform_id} client", 404)
# From the SAME directory the metadata came from, or a drop-in appearing between
# the two calls would serve bytes the metadata does not describe.
root, _ = resolved
+3 -2
View File
@@ -32,10 +32,11 @@ def coerce_bool(raw: object) -> bool:
"""Truthy for the common flag spellings ('true'/'1'/'yes'/'on', or a real bool).
Used for query-string booleans (?has_reminder=true) and DB-backed bool settings,
which arrive as strings.
which arrive as strings; anything else (a number from a JSON body, None) is its
ordinary truthiness.
"""
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
return raw.strip().lower() in ("true", "1", "yes", "on")
return False
return bool(raw)
+3 -13
View File
@@ -53,6 +53,7 @@ from .helpers import (
derive_display_title,
is_empty_note,
store_attachment,
top_position,
unlink_media,
parse_list_items,
)
@@ -331,12 +332,7 @@ async def import_notes():
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)
pos = await top_position(db, g.user_id)
for spec in specs:
if await _create_imported_note(db, g.user_id, spec, zf, pos + 1, budget):
pos += 1
@@ -417,17 +413,11 @@ async def create_note():
if is_empty_note(body, item_texts):
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)
)
)
# Items still arrive separately — a client holds a list, not a blob — but they
# are folded into the body, which is where a checklist lives now (M304).
for text in item_texts:
body = append_item(body, text)
note = Note(owner_id=g.user_id, body="", display_title="", position=int(max_pos) + 1)
note = Note(owner_id=g.user_id, body="", display_title="", position=await top_position(db, g.user_id) + 1)
db.add(note)
# The FOLDED body: an item can carry a #tag too.
changed = await write_body(db, note, body, snapshot="never")
+13 -1
View File
@@ -13,7 +13,7 @@ import re
import uuid
from quart import g
from sqlalchemy import select
from sqlalchemy import func, select
from ..acl import EDIT, visible_to_user
from ..config import Config
@@ -88,6 +88,18 @@ def apply_filter(stmt, filter_name: str, archived):
return stmt.where(Note.deleted_at.is_(None), archived.is_(False))
async def top_position(db, owner_id) -> int:
"""The highest manual position among `owner_id`'s live notes, 0 for none. A new
note goes one above it, to the top of the board."""
return int(
await db.scalar(
select(func.coalesce(func.max(Note.position), 0)).where(
Note.owner_id == owner_id, Note.deleted_at.is_(None)
)
)
)
async def _get_owned(db, note_id: str) -> Note | None:
"""Fetch a note the current user OWNS, for every change only an owner may make:
trash, delete, labels, reminders, attachments, previews, history and sharing
+3 -10
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
from .common import coerce_bool
from .models.settings import Setting
SettingType = Literal["string", "bool", "int"]
@@ -215,17 +216,9 @@ _BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
SECRET_KEY_SETTING = "secret_key"
def _coerce_bool(raw: Any) -> bool:
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
return raw.strip().lower() in ("1", "true", "yes", "on")
return bool(raw)
def _coerce(defn: SettingDef, raw: Any) -> Any:
if defn.type == "bool":
return _coerce_bool(raw)
return coerce_bool(raw)
if defn.type == "int":
try:
return int(raw)
@@ -374,7 +367,7 @@ def validate_updates(updates: dict) -> tuple[dict, str | None]:
return {}, f"{defn.label} must be at most {defn.maximum}"
clean[key] = n
elif defn.type == "bool":
clean[key] = _coerce_bool(val)
clean[key] = coerce_bool(val)
else:
text = str(val if val is not None else "").strip()
if defn.secret and not text:
+4 -3
View File
@@ -9,6 +9,7 @@ from quart import Blueprint, current_app, g, jsonify, request
from .auth import require_admin
from .db import session_scope
from .mailer import mail_settings, send
from .responses import json_error
from .models.user import User
from .settings import get_admin_settings, get_setting, refresh_live, set_settings, validate_updates
@@ -32,11 +33,11 @@ async def update_settings():
# Accept either {settings: {...}} or a bare {key: value} object.
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
if not isinstance(updates, dict):
return jsonify({"error": "expected an object of settings"}), 400
return json_error("expected an object of settings", 400)
clean, error = validate_updates(updates)
if error is not None:
return jsonify({"error": error}), 400
return json_error(error, 400)
async with session_scope() as db:
await set_settings(db, clean)
@@ -77,5 +78,5 @@ async def test_email():
)
except Exception as e: # any SMTP, socket or TLS failure; the admin needs its text
logger.warning("test email failed: %s", e)
return jsonify({"error": f"Couldn't send: {e}"}), 502
return json_error(f"Couldn't send: {e}", 502)
return jsonify({"ok": True, "to": admin.email})
+35 -45
View File
@@ -282,24 +282,25 @@ async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
raw = ch.get("label_ids")
if not isinstance(raw, list):
return
wanted: set = set()
for r in raw:
try:
wanted.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
continue # sync is lenient: skip a malformed id rather than reject the push
# Sync is lenient: a malformed id is skipped rather than rejecting the push.
wanted = {lid for lid in map(parse_uuid, raw) if lid is not None}
owned = await resolve_owned_label_ids(db, wanted, g.user_id)
await reconcile_manual_labels(db, note, owned)
def _result(rid, entity: str, status: str, **extra) -> dict:
"""One entry of a push reply: the change's id and entity, what became of it
(created, applied, kept, noop or rejected), plus `error` or `sync_revision`."""
return {"id": rid, "entity": entity, "status": status, **extra}
async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> dict:
"""Apply one pushed note. A note whose text changed is appended to `previews` as
(id, final body), for the caller to queue link previews once the batch commits."""
raw_id = ch.get("id")
try:
nid = uuid.UUID(str(raw_id))
except (ValueError, TypeError):
return {"id": raw_id, "entity": "note", "status": "rejected", "error": "invalid id"}
nid = parse_uuid(raw_id)
if nid is None:
return _result(raw_id, "note", "rejected", error="invalid id")
op = ch.get("op", "upsert")
edited_at = parse_dt(ch.get("edited_at"))
@@ -309,13 +310,13 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di
if op == "delete":
if note is None:
return {"id": str(nid), "entity": "note", "status": "noop"}
return _result(str(nid), "note", "noop")
if not client_wins(edited_at, note.updated_at):
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
return _result(str(nid), "note", "kept", sync_revision=note.sync_revision)
await purge_note(db, note, edited_at)
await db.flush()
await db.refresh(note, ["sync_revision"])
return {"id": str(nid), "entity": "note", "status": "applied", "sync_revision": note.sync_revision}
return _result(str(nid), "note", "applied", sync_revision=note.sync_revision)
creating = note is None
if creating:
@@ -325,7 +326,7 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di
note.created_at = created
db.add(note)
elif not client_wins(edited_at, note.updated_at):
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
return _result(str(nid), "note", "kept", sync_revision=note.sync_revision)
elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone
@@ -348,12 +349,7 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di
await _apply_note_manual_labels(db, note, ch)
await db.flush()
await db.refresh(note, ["sync_revision"])
return {
"id": str(nid),
"entity": "note",
"status": "created" if creating else "applied",
"sync_revision": note.sync_revision,
}
return _result(str(nid), "note", "created" if creating else "applied", sync_revision=note.sync_revision)
async def _apply_shared_note(db, note: Note, ch: dict, edited_at, previews: list[tuple[uuid.UUID, str]]) -> dict:
@@ -375,8 +371,8 @@ async def _apply_shared_note(db, note: Note, ch: dict, edited_at, previews: list
"""
nid = str(note.id)
if await _get_visible(db, nid) is None:
return {"id": nid, "entity": "note", "status": "rejected", "error": "cannot apply"}
owner_only = {"id": nid, "entity": "note", "status": "rejected", "error": "only its owner can change that"}
return _result(nid, "note", "rejected", error="cannot apply")
owner_only = _result(nid, "note", "rejected", error="only its owner can change that")
if ch.get("op", "upsert") != "upsert":
return owner_only
applied = kept = False
@@ -409,35 +405,34 @@ async def _apply_shared_note(db, note: Note, ch: dict, edited_at, previews: list
async def _apply_label(db, ch: dict) -> dict:
raw_id = ch.get("id")
try:
lid = uuid.UUID(str(raw_id))
except (ValueError, TypeError):
return {"id": raw_id, "entity": "label", "status": "rejected", "error": "invalid id"}
lid = parse_uuid(raw_id)
if lid is None:
return _result(raw_id, "label", "rejected", error="invalid id")
op = ch.get("op", "upsert")
edited_at = parse_dt(ch.get("edited_at"))
label = await db.scalar(select(Label).where(Label.id == lid))
if label is not None and label.owner_id != g.user_id:
# Generic rejection (see _apply_note): don't confirm a foreign-owned id exists.
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "cannot apply"}
return _result(str(lid), "label", "rejected", error="cannot apply")
if op == "delete":
if label is None:
return {"id": str(lid), "entity": "label", "status": "noop"}
return _result(str(lid), "label", "noop")
if not client_wins(edited_at, label.updated_at):
return {"id": str(lid), "entity": "label", "status": "kept", "sync_revision": label.sync_revision}
return _result(str(lid), "label", "kept", sync_revision=label.sync_revision)
await db.execute(sa_delete(NoteLabel).where(NoteLabel.label_id == label.id))
label.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
label.updated_at = edited_at
await db.flush()
await db.refresh(label, ["sync_revision"])
return {"id": str(lid), "entity": "label", "status": "applied", "sync_revision": label.sync_revision}
return _result(str(lid), "label", "applied", sync_revision=label.sync_revision)
name = (ch.get("name") or "").strip()
creating = label is None
if not creating and not client_wins(edited_at, label.updated_at):
return {"id": str(lid), "entity": "label", "status": "kept", "sync_revision": label.sync_revision}
return _result(str(lid), "label", "kept", sync_revision=label.sync_revision)
# Names are unique per owner — a same-name clash on a DIFFERENT id can't be an insert.
if name:
clash = await db.scalar(
@@ -446,11 +441,11 @@ async def _apply_label(db, ch: dict) -> dict:
)
)
if clash is not None:
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "name in use"}
return _result(str(lid), "label", "rejected", error="name in use")
if creating:
if not name:
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "name required"}
return _result(str(lid), "label", "rejected", error="name required")
label = Label(id=lid, owner_id=g.user_id, name=name, color=normalize_color(ch.get("color")))
db.add(label)
else:
@@ -463,12 +458,7 @@ async def _apply_label(db, ch: dict) -> dict:
label.updated_at = edited_at
await db.flush()
await db.refresh(label, ["sync_revision"])
return {
"id": str(lid),
"entity": "label",
"status": "created" if creating else "applied",
"sync_revision": label.sync_revision,
}
return _result(str(lid), "label", "created" if creating else "applied", sync_revision=label.sync_revision)
# Entities a push may only DELETE. Each is a note's child, so deleting one bumps its
@@ -492,20 +482,20 @@ async def _apply_child_delete(db, ch: dict, removed_files: list[str]) -> dict:
raw_id = ch.get("id")
child_id = parse_uuid(str(raw_id)) if raw_id is not None else None
if child_id is None:
return {"id": raw_id, "entity": entity, "status": "rejected", "error": "invalid id"}
return _result(raw_id, entity, "rejected", error="invalid id")
if ch.get("op") != "delete":
return {"id": str(child_id), "entity": entity, "status": "rejected", "error": "only delete is supported"}
return _result(str(child_id), entity, "rejected", error="only delete is supported")
model = _CHILD_ENTITIES[entity]
row = await db.scalar(
select(model).join(Note, Note.id == model.note_id).where(model.id == child_id, Note.owner_id == g.user_id)
)
if row is None:
return {"id": str(child_id), "entity": entity, "status": "noop"}
return _result(str(child_id), entity, "noop")
if isinstance(row, NoteAttachment):
removed_files.append(row.path)
await db.delete(row)
await db.flush()
return {"id": str(child_id), "entity": entity, "status": "applied"}
return _result(str(child_id), entity, "applied")
@bp.post("/push")
@@ -516,9 +506,9 @@ async def push():
body = await request.get_json(silent=True) or {}
changes = body.get("changes")
if not isinstance(changes, list):
return jsonify({"error": "changes must be a list"}), 400
return json_error("changes must be a list", 400)
if len(changes) > MAX_PUSH:
return jsonify({"error": f"too many changes in one push (max {MAX_PUSH})"}), 400
return json_error(f"too many changes in one push (max {MAX_PUSH})", 400)
results = []
previews: list[tuple[uuid.UUID, str]] = []
+2
View File
@@ -375,6 +375,8 @@ def test_coerce_bool():
assert not coerce_bool(None)
assert not coerce_bool("")
assert not coerce_bool(False)
# A bool setting can arrive as a JSON number.
assert coerce_bool(1) and not coerce_bool(0)
def test_normalize_recurrence():