Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite

- auth.py/auth_routes.py: Login Flow v2 (verified against real NC
  source/docs) - no OAuth2 client registration needed, backend polls
  server-side so no CORS issues. Sessions are hashed-token cookies;
  NC app passwords encrypted at rest (Fernet). Every /api/* route
  guarded by a blueprint-wide before_request, not per-route decorators,
  so future routes are protected by default.
- receipts/groups scoped per owner_nc_user_id; cross-user access 404s.
- nc_client/cospend_client take (username, app_password) per call
  instead of one shared global credential - each user's uploads/shares/
  bills now happen as themselves.
- Frontend: LoginGate component drives the login flow (open NC login
  in a new tab, poll our backend, done).
- Merged the old separate review step into the split screen, redesigned
  with Tailwind (was unstyled/broken), default-excluded-per-item
  splitting with one-click "include everyone" fixed, DD.MM.YYYY date
  field, receipt-icon branding.
- Dropped LLM bounding-box highlighting - unreliable on real receipts,
  plain photo upload instead.
- Only mention who a bill is split with in its title when there's more
  than one bill off the same receipt to disambiguate.
This commit is contained in:
2026-08-30 16:02:29 +02:00
parent d97aac0e17
commit 1b38396a2c
31 changed files with 1653 additions and 746 deletions
+11 -2
View File
@@ -15,13 +15,22 @@ def create_app() -> Flask:
os.makedirs(Config.UPLOAD_DIR, exist_ok=True)
# Frontend runs on a different origin (vite dev server) during
# development; lock this down to that origin in production.
CORS(app, resources={r"/api/*": {"origins": Config.CORS_ORIGIN}})
# development; lock this down to that origin in production. Auth now
# relies on a session cookie, so credentials must be allowed - and with
# supports_credentials, the origin allowlist can't be "*", it has to be
# this one explicit origin (flask-cors enforces that).
CORS(
app,
resources={r"/api/*": {"origins": Config.CORS_ORIGIN}, r"/auth/*": {"origins": Config.CORS_ORIGIN}},
supports_credentials=True,
)
init_db(Config.DATABASE_PATH)
from .auth_routes import bp as auth_bp
from .routes import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/auth")
return app
+213
View File
@@ -0,0 +1,213 @@
"""Login via Nextcloud's Login Flow v2 (the same mechanism NC's own desktop/
mobile clients use to obtain an app password without ever seeing a
third-party app's code touch the user's real password).
Flow, doc-verified against docs.nextcloud.com/server/stable/developer_manual
/client_apis/LoginFlow/index.html:
1. POST {NC_BASE_URL}/index.php/login/v2 (no auth) ->
{poll: {token, endpoint}, login: <url the user opens>}
2. User opens `login`, authenticates directly with Nextcloud, approves.
3. POST {poll.endpoint} with body token=<poll.token> -> 404 while pending,
200 once (single-use) with {server, loginName, appPassword} once
granted. Token valid 20 minutes.
Because our backend does the polling (not browser JS), none of the CORS
issues that rule out a pure-frontend NC login apply here - the browser only
ever does a full-page navigation to NC's own login page and back.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta
from functools import wraps
import requests
from cryptography.fernet import Fernet, InvalidToken
from flask import g, jsonify, request
from .config import Config
from .db import get_conn
# In-memory store for login flows in progress: flow_id -> {poll_token,
# poll_endpoint, created_at}. Short-lived (20 min NC-side) and low-stakes if
# lost on a backend restart (the user just retries the login button) - not
# worth a DB table.
_pending_flows: dict[str, dict] = {}
_FLOW_TTL_SECONDS = 20 * 60
def _fernet() -> Fernet:
if not Config.TOKEN_ENCRYPTION_KEY:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set (see .env.example) - refusing to "
"store or read an NC app password without it"
)
return Fernet(Config.TOKEN_ENCRYPTION_KEY.encode())
def _hash_session_id(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
# ------------------------------------------------------------ login flow
def start_login_flow() -> dict:
"""Kicks off a Login Flow v2 against NC. Returns {flow_id, login_url}."""
resp = requests.post(f"{Config.NC_BASE_URL}/index.php/login/v2", timeout=15)
resp.raise_for_status()
data = resp.json()
flow_id = secrets.token_urlsafe(24)
_pending_flows[flow_id] = {
"poll_token": data["poll"]["token"],
"poll_endpoint": data["poll"]["endpoint"],
"created_at": time.time(),
}
_gc_expired_flows()
return {"flow_id": flow_id, "login_url": data["login"]}
def poll_login_flow(flow_id: str) -> dict:
"""Returns {"status": "pending"} | {"status": "expired"} |
{"status": "done", "nc_user_id": str, "session_id": str}.
On success, provisions/updates the user row and creates a new session -
the caller still has to actually set the cookie on the response.
"""
flow = _pending_flows.get(flow_id)
if flow is None:
return {"status": "expired"}
if time.time() - flow["created_at"] > _FLOW_TTL_SECONDS:
del _pending_flows[flow_id]
return {"status": "expired"}
resp = requests.post(
flow["poll_endpoint"], data={"token": flow["poll_token"]}, timeout=15
)
if resp.status_code == 404:
return {"status": "pending"}
resp.raise_for_status()
# Single-use - NC only returns this once, so this flow is done either way.
del _pending_flows[flow_id]
granted = resp.json()
nc_user_id = granted["loginName"]
app_password = granted["appPassword"]
encrypted = _fernet().encrypt(app_password.encode())
with get_conn() as conn:
conn.execute(
"""INSERT INTO users (nc_user_id, nc_app_password_encrypted)
VALUES (?, ?)
ON CONFLICT(nc_user_id) DO UPDATE SET
nc_app_password_encrypted = excluded.nc_app_password_encrypted,
last_login_at = datetime('now')""",
(nc_user_id, encrypted),
)
session_id = create_session(nc_user_id)
return {"status": "done", "nc_user_id": nc_user_id, "session_id": session_id}
def _gc_expired_flows() -> None:
now = time.time()
expired = [fid for fid, f in _pending_flows.items() if now - f["created_at"] > _FLOW_TTL_SECONDS]
for fid in expired:
del _pending_flows[fid]
# --------------------------------------------------------------- sessions
def create_session(nc_user_id: str) -> str:
"""Returns the raw session id to set as the cookie value."""
session_id = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
with get_conn() as conn:
conn.execute(
"INSERT INTO sessions (session_id_hash, nc_user_id, expires_at) VALUES (?, ?, ?)",
(_hash_session_id(session_id), nc_user_id, expires_at),
)
return session_id
def resolve_session(session_id: str) -> str | None:
"""Returns the nc_user_id for a valid, non-expired session, refreshing
its expiry (sliding session), or None if invalid/expired."""
session_hash = _hash_session_id(session_id)
with get_conn() as conn:
row = conn.execute(
"SELECT nc_user_id, expires_at FROM sessions WHERE session_id_hash = ?",
(session_hash,),
).fetchone()
if row is None:
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.utcnow():
conn.execute("DELETE FROM sessions WHERE session_id_hash = ?", (session_hash,))
return None
new_expiry = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
conn.execute(
"UPDATE sessions SET expires_at = ? WHERE session_id_hash = ?",
(new_expiry, session_hash),
)
return row["nc_user_id"]
def destroy_session(session_id: str) -> None:
with get_conn() as conn:
conn.execute(
"DELETE FROM sessions WHERE session_id_hash = ?", (_hash_session_id(session_id),)
)
def get_app_password(nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT nc_app_password_encrypted FROM users WHERE nc_user_id = ?", (nc_user_id,)
).fetchone()
if row is None:
raise RuntimeError(f"no stored credentials for {nc_user_id!r}")
try:
return _fernet().decrypt(bytes(row["nc_app_password_encrypted"])).decode()
except InvalidToken as exc:
raise RuntimeError(
"stored app password could not be decrypted - TOKEN_ENCRYPTION_KEY "
"changed or is wrong"
) from exc
# ------------------------------------------------------------- decorator
def enforce_login():
"""Populates g.nc_user_id/g.nc_app_password from the session cookie, or
returns a 401 response if there isn't a valid one. Used both as a
blueprint-wide `before_request` (so any route added to that blueprint is
protected by default, not only ones someone remembered to decorate) and
directly by the `login_required` decorator for routes registered
elsewhere.
"""
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
nc_user_id = resolve_session(session_id) if session_id else None
if nc_user_id is None:
return jsonify(error="not logged in"), 401
g.nc_user_id = nc_user_id
g.nc_app_password = get_app_password(nc_user_id)
return None
def login_required(view):
"""Per-route variant of enforce_login(), for routes on a blueprint that
isn't (or shouldn't be) guarded wholesale."""
@wraps(view)
def wrapped(*args, **kwargs):
rejection = enforce_login()
if rejection is not None:
return rejection
return view(*args, **kwargs)
return wrapped
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from . import auth
from .config import Config
bp = Blueprint("auth", __name__)
@bp.post("/login/start")
def login_start():
try:
result = auth.start_login_flow()
except Exception as exc: # noqa: BLE001
return jsonify(error=f"could not start login: {exc}"), 502
return jsonify(result)
@bp.get("/login/poll")
def login_poll():
flow_id = request.args.get("flow_id", "")
if not flow_id:
return jsonify(error="flow_id is required"), 400
try:
result = auth.poll_login_flow(flow_id)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"login poll failed: {exc}"), 502
if result["status"] != "done":
return jsonify(status=result["status"])
resp = jsonify(status="done", nc_user_id=result["nc_user_id"])
resp.set_cookie(
Config.SESSION_COOKIE_NAME,
result["session_id"],
max_age=Config.SESSION_TTL_DAYS * 24 * 3600,
httponly=True,
secure=Config.SESSION_COOKIE_SECURE,
samesite="Lax",
path="/",
)
return resp
@bp.post("/logout")
def logout():
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
if session_id:
auth.destroy_session(session_id)
resp = jsonify(status="ok")
resp.delete_cookie(Config.SESSION_COOKIE_NAME, path="/")
return resp
+16 -3
View File
@@ -17,10 +17,10 @@ class Config:
CORS_ORIGIN = os.environ.get("CORS_ORIGIN", "http://localhost:5173")
# Server address only - who's calling is now per logged-in user (see
# auth.py), not a single shared NC_USERNAME/NC_APP_PASSWORD.
NC_BASE_URL = os.environ.get("NC_BASE_URL", "").rstrip("/")
NC_USERNAME = os.environ.get("NC_USERNAME", "")
NC_APP_PASSWORD = os.environ.get("NC_APP_PASSWORD", "")
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "wgBill").strip("/")
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "Documents/Cospend/Assets").strip("/")
# Optional - pre-selects a project in the UI; the app lists all of the
# user's Cospend projects via the API either way, so this isn't required.
@@ -29,3 +29,16 @@ class Config:
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_MODEL = os.environ.get("LLM_MODEL", "")
# Fernet key (Fernet.generate_key()) used to encrypt stored NC app
# passwords at rest. Required in production; a request-time error is
# raised if missing so this can't be silently skipped.
TOKEN_ENCRYPTION_KEY = os.environ.get("TOKEN_ENCRYPTION_KEY", "")
SESSION_COOKIE_NAME = "wgbill_session"
# Sessions are sliding - refreshed on use, so an active user never gets
# logged out; an idle one expires after this many days.
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
# Cookies need Secure (HTTPS-only) in any real deployment; only disable
# for plain-http local dev.
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true"
+14 -12
View File
@@ -1,6 +1,7 @@
"""Cospend's authenticated (NC-login) API - same app-password auth as
nc_client. This is an OCS API (same family as the Share API), confirmed
against the real routes/controller source in julien-nc/cospend-nc:
"""Cospend's authenticated (NC-login) API - same per-user app-password auth
as nc_client (see auth.py). This is an OCS API (same family as the Share
API), confirmed against the real routes/controller source in
julien-nc/cospend-nc:
appinfo/routes.php ('ocs' section):
GET /api/{v}/projects -> api#getLocalProjects
@@ -28,10 +29,6 @@ from .config import Config
_HEADERS = {"OCS-APIRequest": "true"}
def _auth() -> tuple[str, str]:
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
def _base(project_id: str | None = None) -> str:
root = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/cospend/api/v1"
if project_id is None:
@@ -39,11 +36,11 @@ def _base(project_id: str | None = None) -> str:
return f"{root}/projects/{project_id}"
def get_projects() -> list[dict[str, Any]]:
def get_projects(username: str, app_password: str) -> list[dict[str, Any]]:
"""Lists Cospend projects visible to the authenticated user."""
resp = requests.get(
f"{_base()}/projects",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
@@ -52,10 +49,10 @@ def get_projects() -> list[dict[str, Any]]:
return resp.json()["ocs"]["data"]
def get_members(project_id: str) -> list[dict[str, Any]]:
def get_members(username: str, app_password: str, project_id: str) -> list[dict[str, Any]]:
resp = requests.get(
f"{_base(project_id)}/members",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
@@ -65,6 +62,8 @@ def get_members(project_id: str) -> list[dict[str, Any]]:
def create_bill(
username: str,
app_password: str,
*,
project_id: str,
what: str,
@@ -82,7 +81,7 @@ def create_bill(
"""
resp = requests.post(
f"{_base(project_id)}/bills",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
json={
@@ -92,6 +91,9 @@ def create_bill(
"payedFor": ",".join(str(i) for i in ower_ids),
"comment": comment,
"date": date, # YYYY-MM-DD, the receipt's issue date, not today
# required by LocalProjectService::createBill (400s without it);
# 'n' = FREQUENCY_NO, i.e. this bill doesn't repeat.
"repeat": "n",
},
timeout=15,
)
-32
View File
@@ -1,32 +0,0 @@
"""Draws highlight boxes over selected items on the original receipt image."""
from __future__ import annotations
import io
from PIL import Image, ImageDraw
_BOX_COLOR = (255, 210, 0) # translucent yellow highlighter look
_BOX_WIDTH = 4
def highlight_items(image_bytes: bytes, bboxes: list[list[float]]) -> bytes:
"""`bboxes` are normalized [x, y, w, h] (0-1). Returns JPEG bytes."""
base = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
w, h = base.size
for bbox in bboxes:
if not bbox or len(bbox) != 4:
continue
x, y, bw, bh = bbox
left, top = x * w, y * h
right, bottom = (x + bw) * w, (y + bh) * h
draw.rectangle([left, top, right, bottom], fill=(*_BOX_COLOR, 70))
draw.rectangle([left, top, right, bottom], outline=(*_BOX_COLOR, 255), width=_BOX_WIDTH)
combined = Image.alpha_composite(base, overlay).convert("RGB")
out = io.BytesIO()
combined.save(out, format="JPEG", quality=90)
return out.getvalue()
+2 -7
View File
@@ -1,4 +1,4 @@
"""Receipt -> [{id, label, price, bbox}] via any OpenAI-compatible chat
"""Receipt -> [{id, label, price}] via any OpenAI-compatible chat
completions endpoint (works unmodified with OpenAI; point LLM_BASE_URL at
Gemini's OpenAI-compat layer to use that instead, no code change needed).
"""
@@ -23,16 +23,12 @@ Respond with ONLY a JSON object, no prose, no markdown fences, shaped like:
"store_name": "<store/shop name as printed, or null if unreadable>",
"date": "<receipt date as YYYY-MM-DD, or null if unreadable>",
"items": [
{"label": "<item name as printed>", "price": <number>, \
"bbox": [x, y, w, h]}
{"label": "<item name as printed>", "price": <number>}
]
}
- price is the item's price in the receipt's currency, as a plain number \
(no currency symbol).
- bbox is the item's approximate bounding box on the image, normalized to \
0-1 (x, y = top-left corner; w, h = width/height as a fraction of the \
image). Best effort is fine.
- Skip subtotal/tax/total lines, only real purchased items.
- If a line item's price is unclear, make your best guess rather than \
omitting it.
@@ -79,7 +75,6 @@ def extract_receipt(image_bytes: bytes, mime_type: str = "image/jpeg") -> dict:
"id": str(uuid.uuid4()),
"label": str(entry.get("label", "")).strip(),
"price": float(entry.get("price", 0) or 0),
"bbox": entry.get("bbox") or None,
}
)
+23 -18
View File
@@ -1,7 +1,9 @@
"""Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need.
Auth is HTTP Basic with an app password (Settings -> Security -> Devices &
Sessions -> create app password), never the account's real password.
Auth is HTTP Basic with an app password obtained per-user via Login Flow v2
(see auth.py) - callers pass (username, app_password) explicitly rather than
this module reading a single global credential, since every user acts as
themselves now.
"""
from __future__ import annotations
@@ -11,15 +13,11 @@ import requests
from .config import Config
def _auth() -> tuple[str, str]:
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
def _webdav_root(username: str) -> str:
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{username}"
def _webdav_root() -> str:
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{Config.NC_USERNAME}"
def ensure_upload_folder() -> None:
def ensure_upload_folder(username: str, app_password: str) -> None:
"""MKCOL each segment of the configured upload folder path if missing.
NC_UPLOAD_FOLDER may be nested (e.g. "Documents/Cospend/Assets"); MKCOL
@@ -32,35 +30,42 @@ def ensure_upload_folder() -> None:
partial = ""
for segment in segments:
partial = f"{partial}/{segment}" if partial else segment
url = f"{_webdav_root()}/{partial}"
resp = requests.request("MKCOL", url, auth=_auth(), timeout=15)
url = f"{_webdav_root(username)}/{partial}"
resp = requests.request("MKCOL", url, auth=(username, app_password), timeout=15)
# 201 = created, 405 = already exists. Anything else is a real problem.
if resp.status_code not in (201, 405):
resp.raise_for_status()
def upload_file(filename: str, content: bytes, content_type: str = "image/jpeg") -> str:
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT.
def upload_file(
username: str, app_password: str, filename: str, content: bytes, content_type: str = "image/jpeg"
) -> str:
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT, into
the given user's own NC files.
Returns the server-relative path (e.g. "wgBill/receipt-123.jpg"), which is
what the Share API expects as `path`.
"""
ensure_upload_folder()
ensure_upload_folder(username, app_password)
rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename
url = f"{_webdav_root()}/{rel_path}"
url = f"{_webdav_root(username)}/{rel_path}"
resp = requests.put(
url, data=content, auth=_auth(), headers={"Content-Type": content_type}, timeout=30
url,
data=content,
auth=(username, app_password),
headers={"Content-Type": content_type},
timeout=30,
)
resp.raise_for_status()
return rel_path
def create_public_share(rel_path: str) -> str:
def create_public_share(username: str, app_password: str, rel_path: str) -> str:
"""Creates a public read-only link share for `rel_path`. Returns the share URL."""
url = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares"
resp = requests.post(
url,
auth=_auth(),
auth=(username, app_password),
headers={"OCS-APIRequest": "true"},
data={
"path": f"/{rel_path}",
+88 -36
View File
@@ -6,13 +6,24 @@ import uuid
from datetime import date as date_cls
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask import Blueprint, g, jsonify, request
from . import cospend_client, highlight, llm_client, nc_client
from . import cospend_client, llm_client, nc_client
from .auth import enforce_login
from .config import Config
from .db import get_conn
bp = Blueprint("api", __name__)
# Every route on this blueprint requires a valid session by default - a
# route added here later is protected automatically, not only if someone
# remembers to decorate it. (auth_routes.py's /auth/* blueprint is
# deliberately separate and unguarded - it's the login mechanism itself.)
bp.before_request(enforce_login)
@bp.get("/me")
def me():
return jsonify(nc_user_id=g.nc_user_id)
# ---------------------------------------------------------------- receipts
@@ -43,9 +54,9 @@ def create_receipt():
with get_conn() as conn:
conn.execute(
"""INSERT INTO receipts (id, image_path, items_json, store_name, receipt_date)
VALUES (?, ?, ?, ?, ?)""",
(receipt_id, image_path, json.dumps(items), store_name, receipt_date),
"""INSERT INTO receipts (id, owner_nc_user_id, image_path, items_json, store_name, receipt_date)
VALUES (?, ?, ?, ?, ?, ?)""",
(receipt_id, g.nc_user_id, image_path, json.dumps(items), store_name, receipt_date),
)
return jsonify(id=receipt_id, items=items, store_name=store_name, date=receipt_date), 201
@@ -53,7 +64,7 @@ def create_receipt():
@bp.get("/receipts/<receipt_id>")
def get_receipt(receipt_id: str):
receipt = _load_receipt(receipt_id)
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
return jsonify(receipt)
@@ -69,8 +80,8 @@ def update_items(receipt_id: str):
with get_conn() as conn:
cur = conn.execute(
"UPDATE receipts SET items_json = ? WHERE id = ?",
(json.dumps(items), receipt_id),
"UPDATE receipts SET items_json = ? WHERE id = ? AND owner_nc_user_id = ?",
(json.dumps(items), receipt_id, g.nc_user_id),
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
@@ -95,19 +106,24 @@ def update_meta(receipt_id: str):
if not fields:
return jsonify(error="nothing to update"), 400
values.append(receipt_id)
values.extend([receipt_id, g.nc_user_id])
with get_conn() as conn:
cur = conn.execute(f"UPDATE receipts SET {', '.join(fields)} WHERE id = ?", values)
cur = conn.execute(
f"UPDATE receipts SET {', '.join(fields)} WHERE id = ? AND owner_nc_user_id = ?", values
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
receipt = _load_receipt(receipt_id)
receipt = _load_receipt(receipt_id, g.nc_user_id)
return jsonify(receipt)
def _load_receipt(receipt_id: str) -> dict | None:
def _load_receipt(receipt_id: str, owner_nc_user_id: str) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
row = conn.execute(
"SELECT * FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
if row is None:
return None
return {
@@ -125,7 +141,7 @@ def _load_receipt(receipt_id: str) -> dict | None:
@bp.get("/cospend/projects")
def cospend_projects():
try:
projects = cospend_client.get_projects()
projects = cospend_client.get_projects(g.nc_user_id, g.nc_app_password)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(projects=projects, default_project_id=Config.COSPEND_DEFAULT_PROJECT_ID or None)
@@ -134,7 +150,7 @@ def cospend_projects():
@bp.get("/cospend/projects/<project_id>/members")
def cospend_members(project_id: str):
try:
members = cospend_client.get_members(project_id)
members = cospend_client.get_members(g.nc_user_id, g.nc_app_password, project_id)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(members)
@@ -144,13 +160,16 @@ def cospend_members(project_id: str):
@bp.post("/receipts/<receipt_id>/groups")
def create_group(receipt_id: str):
"""Body: {name, cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}"""
receipt = _load_receipt(receipt_id)
"""Body: {cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}
No user-facing "name" - the group is fully described by the project +
who's in the split, which is already what payer/member_ids capture.
"""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
body = request.get_json(force=True) or {}
name = body.get("name") or "WG"
project_id = body.get("cospend_project_id")
payer_member_id = body.get("payer_member_id")
member_ids = body.get("member_ids") or []
@@ -168,13 +187,12 @@ def create_group(receipt_id: str):
with get_conn() as conn:
conn.execute(
"""INSERT INTO groups
(id, receipt_id, name, cospend_project_id, payer_member_id,
(id, receipt_id, cospend_project_id, payer_member_id,
member_ids_json, item_ids_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?)""",
(
group_id,
receipt_id,
name,
project_id,
payer_member_id,
json.dumps(member_ids),
@@ -188,6 +206,10 @@ def create_group(receipt_id: str):
@bp.get("/receipts/<receipt_id>/groups")
def list_groups(receipt_id: str):
# Ownership check on the parent receipt is enough - a group can't exist
# without a receipt row, and receipts are already scoped per-owner.
if _load_receipt(receipt_id, g.nc_user_id) is None:
return jsonify(error="not found"), 404
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM groups WHERE receipt_id = ? ORDER BY created_at", (receipt_id,)
@@ -199,7 +221,6 @@ def _group_dict(row) -> dict:
return {
"id": row["id"],
"receipt_id": row["receipt_id"],
"name": row["name"],
"cospend_project_id": row["cospend_project_id"],
"payer_member_id": row["payer_member_id"],
"member_ids": json.loads(row["member_ids_json"]),
@@ -213,8 +234,8 @@ def _group_dict(row) -> dict:
@bp.post("/receipts/<receipt_id>/groups/<group_id>/submit")
def submit_group(receipt_id: str, group_id: str):
"""Highlights selected items, uploads to NC, shares, creates the Cospend bill."""
receipt = _load_receipt(receipt_id)
"""Uploads the receipt photo to NC, shares it, creates the Cospend bill."""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="receipt not found"), 404
@@ -232,33 +253,61 @@ def submit_group(receipt_id: str, group_id: str):
return jsonify(error="no matching items on this receipt"), 400
total = round(sum(item["price"] for item in selected), 2)
bboxes = [item["bbox"] for item in selected if item.get("bbox")]
with open(_receipt_image_path(receipt_id), "rb") as f:
with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f:
original_bytes = f.read()
try:
highlighted = highlight.highlight_items(original_bytes, bboxes)
# Members list is fetched here (not stored at group-creation time) so
# the label always reflects real names - also gives us the payer's
# name for a slightly more readable title.
members = cospend_client.get_members(
g.nc_user_id, g.nc_app_password, group["cospend_project_id"]
)
names_by_id = {str(m["id"]): m["name"] for m in members}
# member ids come back from Cospend as JSON numbers, not strings, and
# may have round-tripped through JS (which doesn't distinguish) - str()
# everything here so a missed match can't leave a raw int in the list
# `join` chokes on.
ower_names = [names_by_id.get(str(m), str(m)) for m in group["member_ids"]]
members_label = " + ".join(ower_names) if ower_names else "split"
# No highlighting - LLM-provided bounding boxes were unreliable
# enough on real receipts to not be worth it. Just upload the
# original photo, into the submitting user's own NC files.
# Timestamp for human sorting/browsing in NC, group id suffix so two
# submits in the same second (or a retry) never collide/overwrite.
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", group["name"]).strip("-") or "group"
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_name}_{group_id[:8]}.jpg"
rel_path = nc_client.upload_file(filename, highlighted)
share_url = nc_client.create_public_share(rel_path)
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", members_label).strip("-") or "group"
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_label}_{group_id[:8]}.jpg"
rel_path = nc_client.upload_file(
g.nc_user_id, g.nc_app_password, filename, original_bytes
)
share_url = nc_client.create_public_share(g.nc_user_id, g.nc_app_password, rel_path)
item_lines = "\n".join(f"- {i['label']}: {i['price']}" for i in selected)
# Descriptive title: store name when we have one, else just the
# group name - either way followed by the share link, since
# Descriptive title: store name when we have one, else fall back to
# who's splitting it - either way followed by the share link, since
# Cospend's UI only recognizes a bill as having an attachment when
# the link is in `what` (the title); a link in `comment` is just
# plain text and isn't picked up.
# Only mention who it's split with when there's more than one bill
# off this receipt to tell apart - with a single bill it's just
# noise (the split is already visible in the bill itself).
with get_conn() as conn:
(bill_count,) = conn.execute(
"SELECT COUNT(*) FROM groups WHERE receipt_id = ?", (receipt_id,)
).fetchone()
store_name = receipt.get("store_name")
label = f"{store_name} ({group['name']})" if store_name else group["name"]
if bill_count > 1:
label = f"{store_name} ({members_label})" if store_name else members_label
else:
label = store_name or "receipt"
bill_date = receipt.get("date") or date_cls.today().isoformat()
bill_id = cospend_client.create_bill(
g.nc_user_id,
g.nc_app_password,
project_id=group["cospend_project_id"],
what=f"{label} {share_url}",
amount=total,
@@ -285,7 +334,10 @@ def submit_group(receipt_id: str, group_id: str):
return jsonify(share_url=share_url, bill_id=bill_id, amount=total)
def _receipt_image_path(receipt_id: str) -> str:
def _receipt_image_path(receipt_id: str, owner_nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT image_path FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
row = conn.execute(
"SELECT image_path FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
return row["image_path"]
+20 -1
View File
@@ -1,5 +1,25 @@
CREATE TABLE IF NOT EXISTS users (
nc_user_id TEXT PRIMARY KEY,
-- Fernet-encrypted NC app password obtained via Login Flow v2. Never
-- returned by any API response.
nc_app_password_encrypted BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
-- SHA-256 hex digest of the actual session cookie value - never the raw
-- token, so a DB read alone can't yield a usable session (same
-- principle as password hashing).
session_id_hash TEXT PRIMARY KEY,
nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
owner_nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
image_path TEXT NOT NULL,
image_width INTEGER,
image_height INTEGER,
@@ -14,7 +34,6 @@ CREATE TABLE IF NOT EXISTS receipts (
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
receipt_id TEXT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
cospend_project_id TEXT NOT NULL,
payer_member_id TEXT NOT NULL,
member_ids_json TEXT NOT NULL, -- json list of cospend member ids (owers)