Files
wgBill/backend/app/nc_client.py
T
dodox 1b38396a2c 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.
2026-08-30 16:02:29 +02:00

82 lines
2.8 KiB
Python

"""Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need.
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
import requests
from .config import Config
def _webdav_root(username: str) -> str:
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{username}"
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
only creates one level at a time, so walk the path segment by segment.
Idempotent.
"""
if not Config.NC_UPLOAD_FOLDER:
return
segments = Config.NC_UPLOAD_FOLDER.split("/")
partial = ""
for segment in segments:
partial = f"{partial}/{segment}" if partial else segment
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(
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(username, app_password)
rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename
url = f"{_webdav_root(username)}/{rel_path}"
resp = requests.put(
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(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=(username, app_password),
headers={"OCS-APIRequest": "true"},
data={
"path": f"/{rel_path}",
"shareType": 3, # public link
"permissions": 1, # read-only
},
params={"format": "json"},
timeout=15,
)
resp.raise_for_status()
payload = resp.json()
share_url = payload["ocs"]["data"]["url"]
return share_url