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:
+18
-5
@@ -1,12 +1,13 @@
|
||||
# Copy to .env and fill in. Never commit .env.
|
||||
|
||||
# --- Nextcloud ---
|
||||
# Server address only - each user logs in themselves via Nextcloud Login
|
||||
# Flow v2 (see /auth/login/start); there's no shared NC_USERNAME/APP_PASSWORD
|
||||
# any more.
|
||||
NC_BASE_URL=https://cloud.dominik-roth.eu
|
||||
NC_USERNAME=dodox
|
||||
# Settings -> Security -> "Devices & Sessions" -> create app password
|
||||
NC_APP_PASSWORD=
|
||||
# Folder (relative to the user's files root) receipt images get uploaded to.
|
||||
# Created automatically on first upload if missing (each path segment).
|
||||
# Folder (relative to each user's own files root) receipt images get
|
||||
# uploaded to. Created automatically on first upload if missing (each path
|
||||
# segment).
|
||||
NC_UPLOAD_FOLDER=Documents/Cospend/Assets
|
||||
|
||||
# --- Cospend ---
|
||||
@@ -29,3 +30,15 @@ LLM_MODEL=
|
||||
# --- Flask ---
|
||||
FLASK_SECRET_KEY=dev-change-me
|
||||
DATABASE_PATH=instance/wgbill.sqlite3
|
||||
|
||||
# --- Auth ---
|
||||
# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Encrypts stored Nextcloud app passwords at rest. Required - the app
|
||||
# refuses to store/read a token without it. Keep this secret; losing it
|
||||
# means every logged-in user has to log in again.
|
||||
TOKEN_ENCRYPTION_KEY=
|
||||
# Idle session lifetime in days (sliding - refreshed on each use).
|
||||
SESSION_TTL_DAYS=30
|
||||
# Cookies need Secure (HTTPS-only) for any real deployment. Only set to
|
||||
# false for plain-http localhost dev.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -3,3 +3,4 @@ Flask-Cors==4.0.1
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
Pillow==10.4.0
|
||||
cryptography==43.0.1
|
||||
|
||||
Reference in New Issue
Block a user