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:
+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}",
|
||||
|
||||
Reference in New Issue
Block a user