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
+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}",