feat(nextcloud): per-user Nextcloud library integration
Lets each mule-image user (matched via OIDC preferred_username, overridable in Settings) browse their Nextcloud files/ tree from the mule-image UI and register subfolders as per-user SourceRoots. Reads stay direct on the bind-mounted /nextcloud-users path; mutations (upload, delete, rename, move within NC) dispatch through Nextcloud WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients stay coherent. Backend: - users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest, key derived from SECRET_KEY) — alembic 0016 - services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE, MOVE) with HTTP Basic auth via the per-user app password - routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE /source-roots (path-scoped to current_user.nextcloud_username with realpath traversal guard) - PATCH /api/v1/auth/me to update nextcloud_username and app password - OIDC callback defaults nextcloud_username from preferred_username on first login; backfill on existing users; never overwrites a manual override - routers/upload.py: stream upload to NamedTemporaryFile, then PUT to WebDAV (with MKCOL chain) when destination is NC-rooted; existing Photo row creation runs unchanged - routers/discard.py empty-trash: WebDAV DELETE for NC files - routers/photos.py rename + move: WebDAV MOVE for NC paths; cross-system move/copy returns a clean error - routers/folders.py rename + create + permanent-delete: dispatch via WebDAV when targeting NC-rooted paths Frontend: - AuthUser carries nextcloud_username + has_nextcloud_app_password - services/api.ts: nextcloud + account namespaces - components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name + submit -> POST /source-roots - SettingsDialog: new "Nextcloud library" card with username override + validate, app-password input, list/remove of NC libraries, and the picker entry point docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,18 @@ class UserResponse(BaseModel):
|
||||
created_at: Optional[str]
|
||||
avatar_url: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
# Nextcloud integration — username override (defaults to OIDC
|
||||
# preferred_username) and a flag for whether the user has stored
|
||||
# an app password. Cleartext passwords are never serialized.
|
||||
nextcloud_username: Optional[str] = None
|
||||
has_nextcloud_app_password: bool = False
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
"""PATCH /me payload. Every field is optional — only what's set
|
||||
gets touched. Setting `nextcloud_app_password` to "" clears it."""
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_app_password: Optional[str] = None
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
@@ -93,6 +105,8 @@ def serialize_user(user: User) -> UserResponse:
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
avatar_url=avatar,
|
||||
display_name=user.display_name,
|
||||
nextcloud_username=user.nextcloud_username,
|
||||
has_nextcloud_app_password=bool(user.nextcloud_app_password_enc),
|
||||
)
|
||||
|
||||
|
||||
@@ -186,6 +200,46 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return serialize_user(current_user)
|
||||
|
||||
|
||||
_NC_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._@-]{1,64}$")
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
body: UpdateMeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the authenticated user's Nextcloud integration settings.
|
||||
|
||||
`nextcloud_username` overrides the OIDC `preferred_username` default
|
||||
so e.g. the local mule-image user `dtoro` can map to Nextcloud user
|
||||
`admin`. `nextcloud_app_password` is encrypted at rest via the
|
||||
Fernet helper in `services/secrets.py`; passing an empty string
|
||||
clears it.
|
||||
"""
|
||||
from app.services.secrets import encrypt
|
||||
|
||||
changed = False
|
||||
if body.nextcloud_username is not None:
|
||||
candidate = body.nextcloud_username.strip()
|
||||
if candidate and not _NC_USERNAME_RE.match(candidate):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nextcloud username")
|
||||
current_user.nextcloud_username = candidate or None
|
||||
changed = True
|
||||
|
||||
if body.nextcloud_app_password is not None:
|
||||
if body.nextcloud_app_password == "":
|
||||
current_user.nextcloud_app_password_enc = None
|
||||
else:
|
||||
current_user.nextcloud_app_password_enc = encrypt(body.nextcloud_app_password)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return serialize_user(current_user)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
@@ -426,6 +480,11 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
oidc_sub=sub,
|
||||
avatar_url=picture,
|
||||
display_name=display_name,
|
||||
# Default the Nextcloud username from preferred_username so
|
||||
# the common case "same name on both sides" needs zero
|
||||
# configuration. Override is exposed in Settings for the
|
||||
# mismatch case (e.g. authentik dtoro ↔ Nextcloud admin).
|
||||
nextcloud_username=(claims.get("preferred_username") or None),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
@@ -455,6 +514,14 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
if picture and user.avatar_url != picture:
|
||||
user.avatar_url = picture
|
||||
changed = True
|
||||
# Backfill nextcloud_username on first OIDC login for users that
|
||||
# predate the column. NEVER overwrites a value the user already
|
||||
# set in Settings — once the override is non-null, it wins.
|
||||
if not user.nextcloud_username:
|
||||
preferred = claims.get("preferred_username")
|
||||
if preferred:
|
||||
user.nextcloud_username = preferred
|
||||
changed = True
|
||||
if admin_groups:
|
||||
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
||||
if user.role != new_role:
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,7 +50,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User =
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
return await _permanently_delete(db, photos, current_user)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
@@ -70,19 +71,38 @@ async def delete_discarded(
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
return await _permanently_delete(db, photos, current_user)
|
||||
|
||||
|
||||
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
|
||||
async def _permanently_delete(db: AsyncSession, photos: list[Photo], user: User) -> dict:
|
||||
"""Shared helper: unlink files for the given photos and delete their
|
||||
rows. Per-file errors are counted but don't abort the batch.
|
||||
|
||||
For files inside a Nextcloud-rooted SourceRoot the unlink is dispatched
|
||||
through Nextcloud's WebDAV `DELETE` so Nextcloud moves the file into
|
||||
the user's trashbin and updates `oc_filecache`. For everything else we
|
||||
fall back to plain `os.unlink`.
|
||||
"""
|
||||
deleted = 0
|
||||
file_errors = 0
|
||||
for photo in photos:
|
||||
try:
|
||||
if photo.filepath and os.path.exists(photo.filepath):
|
||||
os.unlink(photo.filepath)
|
||||
if photo.filepath:
|
||||
if is_nextcloud_path(photo.filepath):
|
||||
# WebDAV DELETE — Nextcloud moves to trashbin and
|
||||
# updates oc_filecache. The bind mount will then
|
||||
# reflect the file's absence (Nextcloud writes
|
||||
# synchronously). 404 from NC is treated as already
|
||||
# gone (idempotent).
|
||||
delete_for_user(user, photo.filepath)
|
||||
elif os.path.exists(photo.filepath):
|
||||
os.unlink(photo.filepath)
|
||||
except HTTPException as e:
|
||||
# WebDAV-side error — surface to caller via the file_errors
|
||||
# counter rather than aborting the whole batch.
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to delete {photo.filepath} via Nextcloud: {e.detail}")
|
||||
continue
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
||||
|
||||
@@ -18,6 +18,12 @@ from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_folder
|
||||
from app.services.nextcloud_dav import (
|
||||
delete_for_user as nc_delete,
|
||||
is_nextcloud_path,
|
||||
mkcol_for_user,
|
||||
move_for_user as nc_move,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -251,10 +257,16 @@ async def rename_folder(
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
if is_nextcloud_path(old_path):
|
||||
# WebDAV MOVE keeps Nextcloud's oc_filecache + sharing metadata
|
||||
# consistent. NC's MOVE is recursive — descendants come along,
|
||||
# exactly like shutil.move.
|
||||
nc_move(current_user, old_path, new_path)
|
||||
else:
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||
# rewrites the prefix; we use the trailing separator on the LIKE
|
||||
@@ -309,10 +321,14 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db),
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
if is_nextcloud_path(new_path):
|
||||
# MKCOL via WebDAV so Nextcloud knows about the new collection.
|
||||
mkcol_for_user(current_user, new_path)
|
||||
else:
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
|
||||
new_folder = Folder(
|
||||
name=name,
|
||||
@@ -395,14 +411,28 @@ async def delete_folder(
|
||||
|
||||
# mode == 'permanent'
|
||||
file_errors = 0
|
||||
for p in photos:
|
||||
folder_is_nc = is_nextcloud_path(folder_path)
|
||||
|
||||
if folder_is_nc:
|
||||
# One WebDAV DELETE on the folder itself does the recursive
|
||||
# delete (NC moves the whole tree to trashbin and updates
|
||||
# oc_filecache for everything inside). Skip per-photo unlinks.
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
nc_delete(current_user, folder_path)
|
||||
except HTTPException as e:
|
||||
logger.error(f"Nextcloud DELETE failed for {folder_path}: {e.detail}")
|
||||
raise
|
||||
for p in photos:
|
||||
await db.delete(p)
|
||||
else:
|
||||
for p in photos:
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
|
||||
# Delete this folder + every descendant Folder row.
|
||||
await db.execute(
|
||||
@@ -412,13 +442,14 @@ async def delete_folder(
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
if not folder_is_nc:
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
|
||||
323
backend/app/routers/nextcloud.py
Normal file
323
backend/app/routers/nextcloud.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Nextcloud integration router — folder picker + per-user SourceRoots.
|
||||
|
||||
Exposes three things:
|
||||
|
||||
- GET /api/v1/nextcloud/whoami?candidate=<name>
|
||||
Validate that a Nextcloud username actually has a files/ tree
|
||||
on the mounted homecloud volume. Used by the Settings UI to
|
||||
sanity-check the override field before saving.
|
||||
|
||||
- GET /api/v1/nextcloud/browse?path=<rel>
|
||||
List immediate subdirectories of the current user's Nextcloud
|
||||
files tree, scoped server-side to their nextcloud_username.
|
||||
Powers the folder picker.
|
||||
|
||||
- POST /api/v1/nextcloud/source-roots {name, nextcloud_path}
|
||||
DELETE /api/v1/nextcloud/source-roots/{id}
|
||||
Add or remove a per-user SourceRoot pointing at a Nextcloud
|
||||
subfolder. Adding kicks off an immediate scan_folder task so
|
||||
photos start appearing without a full library re-scan.
|
||||
|
||||
All paths are normalized with realpath and rejected if they escape the
|
||||
user's allowed root — defense-in-depth against `..` and symlink
|
||||
shenanigans.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.folders import SourceRoot, Folder
|
||||
from app.models.user import User
|
||||
from app.services.nextcloud_dav import (
|
||||
NEXTCLOUD_USERS_ROOT,
|
||||
is_nextcloud_path,
|
||||
whoami_dir_exists,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_root(nc_username: str) -> str:
|
||||
"""Absolute path of `<NEXTCLOUD_USERS_ROOT>/<user>/files`."""
|
||||
return os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
|
||||
|
||||
|
||||
def _resolve_under_user_root(nc_username: str, rel: str) -> str:
|
||||
"""Resolve `rel` (a relative path the client supplied) under the
|
||||
user's Nextcloud `files/` directory and ensure the result is still
|
||||
inside that root. Returns the absolute, realpath-normalized path.
|
||||
|
||||
Raises 400 on traversal attempts (`..`, absolute paths, symlinks
|
||||
that point outside the root)."""
|
||||
rel = (rel or "").lstrip("/")
|
||||
if any(seg in ("..",) for seg in rel.split("/") if seg):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
base = _user_root(nc_username)
|
||||
candidate = os.path.realpath(os.path.join(base, rel))
|
||||
base_real = os.path.realpath(base)
|
||||
if candidate != base_real and not candidate.startswith(base_real + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Path escapes Nextcloud root")
|
||||
return candidate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrowseEntry(BaseModel):
|
||||
name: str
|
||||
path: str # path relative to the user's files/ root
|
||||
has_children: bool # True if it contains at least one sub-directory
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
nc_username: str
|
||||
rel_path: str
|
||||
parent_rel: Optional[str] # None at the root
|
||||
entries: list[BrowseEntry]
|
||||
|
||||
|
||||
@router.get("/whoami")
|
||||
async def whoami(
|
||||
candidate: Optional[str] = Query(None, description="Nextcloud username to validate"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Public to authenticated users — returns whether a Nextcloud
|
||||
username has a real files/ directory on the bind mount. Used by
|
||||
the Settings UI to validate the override field before save."""
|
||||
name = (candidate or current_user.nextcloud_username or "").strip()
|
||||
if not name:
|
||||
return {
|
||||
"configured": bool(current_user.nextcloud_username),
|
||||
"candidate": None,
|
||||
"valid": False,
|
||||
"reason": "no_username",
|
||||
}
|
||||
valid = whoami_dir_exists(name)
|
||||
return {
|
||||
"configured": bool(current_user.nextcloud_username),
|
||||
"candidate": name,
|
||||
"valid": valid,
|
||||
"reason": None if valid else "no_files_dir",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse", response_model=BrowseResponse)
|
||||
async def browse(
|
||||
path: str = Query("", description="Path relative to the user's NC files/ root"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List immediate subdirectories of the current user's NC tree."""
|
||||
nc_user = (current_user.nextcloud_username or "").strip()
|
||||
if not nc_user:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Set your Nextcloud username in Settings → Library first.",
|
||||
)
|
||||
if not whoami_dir_exists(nc_user):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Nextcloud user '{nc_user}' has no files/ directory on the "
|
||||
"mounted homecloud volume. Check your nextcloud_username override."
|
||||
),
|
||||
)
|
||||
|
||||
abs_path = _resolve_under_user_root(nc_user, path)
|
||||
if not os.path.isdir(abs_path):
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
base = _user_root(nc_user)
|
||||
entries: list[BrowseEntry] = []
|
||||
try:
|
||||
with os.scandir(abs_path) as it:
|
||||
for de in it:
|
||||
# Skip hidden and Nextcloud's appdata noise.
|
||||
if de.name.startswith("."):
|
||||
continue
|
||||
if not de.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
child_abs = os.path.join(abs_path, de.name)
|
||||
rel = os.path.relpath(child_abs, base)
|
||||
# Quick has_children probe: any subdir that's a real
|
||||
# directory. Cap at first hit so deep trees don't slow
|
||||
# the picker.
|
||||
has_children = False
|
||||
try:
|
||||
with os.scandir(child_abs) as sub:
|
||||
for s in sub:
|
||||
if s.name.startswith("."):
|
||||
continue
|
||||
if s.is_dir(follow_symlinks=False):
|
||||
has_children = True
|
||||
break
|
||||
except OSError:
|
||||
has_children = False
|
||||
entries.append(BrowseEntry(name=de.name, path=rel, has_children=has_children))
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Cannot read Nextcloud folder — backend container lacks "
|
||||
"filesystem permissions on the mount. Apply ACL fix on the host."
|
||||
),
|
||||
)
|
||||
|
||||
entries.sort(key=lambda e: e.name.lower())
|
||||
|
||||
rel_path = os.path.relpath(abs_path, _user_root(nc_user)) if abs_path != _user_root(nc_user) else ""
|
||||
if rel_path == ".":
|
||||
rel_path = ""
|
||||
parent_rel: Optional[str] = None
|
||||
if rel_path:
|
||||
parent = os.path.dirname(rel_path)
|
||||
parent_rel = parent
|
||||
|
||||
return BrowseResponse(
|
||||
nc_username=nc_user,
|
||||
rel_path=rel_path,
|
||||
parent_rel=parent_rel,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SourceRootCreate(BaseModel):
|
||||
name: str
|
||||
nextcloud_path: str # relative to the user's files/ root
|
||||
|
||||
|
||||
@router.post("/source-roots", status_code=201)
|
||||
async def create_nextcloud_source_root(
|
||||
body: SourceRootCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Register a Nextcloud subfolder as a SourceRoot for the current user
|
||||
and kick off the initial scan."""
|
||||
nc_user = (current_user.nextcloud_username or "").strip()
|
||||
if not nc_user:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Set your Nextcloud username in Settings → Library first.",
|
||||
)
|
||||
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
|
||||
abs_path = _resolve_under_user_root(nc_user, body.nextcloud_path)
|
||||
if not os.path.isdir(abs_path):
|
||||
raise HTTPException(status_code=404, detail="Folder not found in Nextcloud tree")
|
||||
|
||||
# Don't allow registering the user's `files/` root itself as a
|
||||
# SourceRoot — it'd index everything they own (Documents, Notes,
|
||||
# appdata noise). Force them to pick a subfolder.
|
||||
if abs_path == os.path.realpath(_user_root(nc_user)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Pick a subfolder; the whole files/ root is too broad.",
|
||||
)
|
||||
|
||||
# Refuse duplicates — the path is uniquely indexed but a clean error
|
||||
# beats a 500 from the unique constraint.
|
||||
existing = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.path == abs_path)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="A SourceRoot for that path already exists")
|
||||
|
||||
sr = SourceRoot(
|
||||
name=name,
|
||||
path=abs_path,
|
||||
user_id=current_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sr)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
|
||||
# Kick off the initial scan. Failures here shouldn't block the
|
||||
# SourceRoot creation — the user can hit "Re-scan source folders"
|
||||
# from Settings if Celery is wedged.
|
||||
try:
|
||||
from app.tasks.celery import celery_app
|
||||
celery_app.send_task("scan_folder", args=[sr.path, sr.id])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Failed to queue initial scan for new SourceRoot %s: %s", sr.id, exc)
|
||||
|
||||
return {
|
||||
"id": sr.id,
|
||||
"name": sr.name,
|
||||
"path": sr.path,
|
||||
"user_id": sr.user_id,
|
||||
"is_active": sr.is_active,
|
||||
"is_nextcloud": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/source-roots/{source_root_id}", status_code=204)
|
||||
async def delete_nextcloud_source_root(
|
||||
source_root_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-delete: deactivate the SourceRoot and orphan its photo rows
|
||||
(mark them is_discarded). The actual files in Nextcloud are NOT
|
||||
touched — this is just unwiring the user's view of the library.
|
||||
The user can re-add the same path later to restart scanning."""
|
||||
sr = (await db.execute(
|
||||
select(SourceRoot).where(
|
||||
SourceRoot.id == source_root_id,
|
||||
SourceRoot.user_id == current_user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if sr is None:
|
||||
raise HTTPException(status_code=404, detail="SourceRoot not found")
|
||||
if not is_nextcloud_path(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This endpoint only manages Nextcloud-rooted SourceRoots",
|
||||
)
|
||||
|
||||
# Soft-deactivate. Existing photos stay in the DB but won't appear
|
||||
# in active queries (folders.py filters by is_active=true).
|
||||
sr.is_active = False
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/source-roots")
|
||||
async def list_nextcloud_source_roots(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All Nextcloud-rooted SourceRoots owned by the current user.
|
||||
Used by the Settings panel to render the manage list."""
|
||||
rows = (await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.user_id == current_user.id)
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"path": r.path,
|
||||
"is_active": r.is_active,
|
||||
"is_nextcloud": True,
|
||||
}
|
||||
for r in rows
|
||||
if is_nextcloud_path(r.path)
|
||||
]
|
||||
@@ -31,8 +31,22 @@ from app.dependencies import (
|
||||
get_user_or_shared_heap, get_user_or_shared_folder,
|
||||
can_access_photo_via_share,
|
||||
)
|
||||
from app.services.nextcloud_dav import is_nextcloud_path, move_for_user
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _nc_unsupported(detail: str) -> HTTPException:
|
||||
"""Returns a 501 with a UI-friendly hint to use Nextcloud's web UI
|
||||
for an op we haven't routed through WebDAV yet. Centralised so the
|
||||
message stays consistent."""
|
||||
return HTTPException(
|
||||
status_code=501,
|
||||
detail=(
|
||||
f"{detail} For Nextcloud-managed libraries, do this from "
|
||||
"Nextcloud's web UI; mule-image will pick up the change."
|
||||
),
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
@@ -843,11 +857,18 @@ async def update_photo(
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(status_code=409, detail="A file with that name already exists")
|
||||
|
||||
try:
|
||||
os.rename(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
if is_nextcloud_path(photo.filepath):
|
||||
# WebDAV MOVE within the same directory == rename. Lands
|
||||
# the new name in oc_filecache so Nextcloud's web UI and
|
||||
# sync clients see it; the bind mount reflects the
|
||||
# rename for our own watcher.
|
||||
move_for_user(current_user, photo.filepath, new_path)
|
||||
else:
|
||||
try:
|
||||
os.rename(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
photo.filename = new_name
|
||||
photo.filepath = new_path
|
||||
@@ -974,6 +995,15 @@ async def copy_photos(
|
||||
return candidate
|
||||
return None
|
||||
|
||||
target_nc = is_nextcloud_path(target_dir)
|
||||
if target_nc:
|
||||
# Copy into a Nextcloud-managed folder isn't routed through
|
||||
# WebDAV PUT yet — would need a download from the source path
|
||||
# plus an upload, doubling IO. Defer until someone needs it.
|
||||
raise _nc_unsupported(
|
||||
"Copy into a Nextcloud-managed folder isn't supported yet."
|
||||
)
|
||||
|
||||
for photo in photos_to_copy:
|
||||
if not os.path.exists(photo.filepath):
|
||||
errors.append({"id": photo.id, "error": "source file missing"})
|
||||
@@ -986,6 +1016,17 @@ async def copy_photos(
|
||||
|
||||
new_path = os.path.join(target_dir, new_name)
|
||||
|
||||
if is_nextcloud_path(photo.filepath):
|
||||
# Source is NC, dest is local. We could `shutil.copy2`
|
||||
# since NC files are readable on the bind mount, but the
|
||||
# local /photos tree has different ownership semantics —
|
||||
# leave this off until we've thought about it.
|
||||
errors.append({
|
||||
"id": photo.id,
|
||||
"error": "Cross-system copy (Nextcloud ↔ local) not supported yet",
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
shutil.copy2(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
@@ -1099,8 +1140,27 @@ async def move_photos(
|
||||
errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"})
|
||||
continue
|
||||
|
||||
src_nc = is_nextcloud_path(photo.filepath)
|
||||
dst_nc = is_nextcloud_path(new_path)
|
||||
if src_nc != dst_nc:
|
||||
# Cross-system moves (NC ↔ local /photos) aren't supported
|
||||
# in v1 — the user can copy via Nextcloud's web UI or use
|
||||
# the desktop sync client to move the file, and mule-image
|
||||
# will pick up both sides via inotify.
|
||||
errors.append({
|
||||
"id": photo.id,
|
||||
"error": "Cross-system move (Nextcloud ↔ local) not supported yet",
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
shutil.move(photo.filepath, new_path)
|
||||
if src_nc:
|
||||
move_for_user(current_user, photo.filepath, new_path)
|
||||
else:
|
||||
shutil.move(photo.filepath, new_path)
|
||||
except HTTPException as e:
|
||||
errors.append({"id": photo.id, "error": str(e.detail)})
|
||||
continue
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
|
||||
@@ -18,6 +18,7 @@ the same thumb + metadata pipeline that the scanner uses. An optional
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -32,6 +33,11 @@ from app.models import Folder, Heap, Photo, SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
from app.services.date_guess import has_date_warning
|
||||
from app.services.nextcloud_dav import (
|
||||
ensure_parents_for_user,
|
||||
is_nextcloud_path,
|
||||
put_for_user,
|
||||
)
|
||||
from app.tasks.scan import SUPPORTED_EXTENSIONS, get_media_type
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
from app.services.metadata import extract_metadata
|
||||
@@ -122,8 +128,11 @@ async def _ensure_subfolder(
|
||||
) -> Folder:
|
||||
"""Return (or create) a Folder row named `name` under `parent`.
|
||||
|
||||
Also mkdirs the directory on disk. Idempotent — safe to call for a
|
||||
path segment that already exists as a Folder row or directory.
|
||||
Also mkdirs the directory on disk. For Nextcloud-rooted paths the
|
||||
directory is created via WebDAV MKCOL so Nextcloud's `oc_filecache`
|
||||
knows about it; otherwise plain `os.makedirs`. Idempotent — safe
|
||||
to call for a path segment that already exists as a Folder row or
|
||||
directory.
|
||||
"""
|
||||
child_path = os.path.normpath(os.path.join(parent.path, name))
|
||||
|
||||
@@ -134,10 +143,23 @@ async def _ensure_subfolder(
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
# Materialise the directory if it wasn't already.
|
||||
if is_nextcloud_path(child_path):
|
||||
ensure_parents_for_user(user, child_path)
|
||||
# Also ensure the leaf collection exists; ensure_parents
|
||||
# only handles intermediate dirs.
|
||||
from app.services.nextcloud_dav import mkcol_for_user
|
||||
mkcol_for_user(user, child_path)
|
||||
else:
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
return existing
|
||||
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
if is_nextcloud_path(child_path):
|
||||
ensure_parents_for_user(user, child_path)
|
||||
from app.services.nextcloud_dav import mkcol_for_user
|
||||
mkcol_for_user(user, child_path)
|
||||
else:
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
child = Folder(
|
||||
name=name,
|
||||
path=child_path,
|
||||
@@ -213,38 +235,74 @@ async def upload_file(
|
||||
target_folder = await _ensure_subfolder(target_folder, seg, current_user, db)
|
||||
|
||||
target_dir = target_folder.path
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
nc_managed = is_nextcloud_path(target_dir)
|
||||
if not nc_managed:
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
# else: target_dir was created via WebDAV MKCOL by _ensure_subfolder
|
||||
filepath, final_name = _unique_path(target_dir, leaf)
|
||||
|
||||
# --- stream to disk, hash as we go ----------------------------------
|
||||
# --- stream the upload to a tempfile, hashing as we go ---------------
|
||||
# For Nextcloud-managed destinations we then PUT the tempfile to
|
||||
# WebDAV so Nextcloud's oc_filecache + sharing/comment metadata stay
|
||||
# consistent. For local destinations we just rename the tempfile
|
||||
# into place. Either way the hash + size are computed once.
|
||||
hasher = hashlib.sha256()
|
||||
total = 0
|
||||
tmp_dir = os.path.dirname(filepath) if not nc_managed else None
|
||||
# NamedTemporaryFile in the same directory as filepath when local
|
||||
# (so the final rename is atomic on the same filesystem). For NC,
|
||||
# use the system tmpdir — we re-upload via HTTP either way.
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, dir=tmp_dir, suffix=".part"
|
||||
)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
with open(filepath, 'wb') as out:
|
||||
try:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_UPLOAD_BYTES:
|
||||
out.close()
|
||||
os.unlink(filepath)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit",
|
||||
)
|
||||
hasher.update(chunk)
|
||||
out.write(chunk)
|
||||
tmp.write(chunk)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
if nc_managed:
|
||||
# PUT to Nextcloud WebDAV. The PUT lands the bytes on
|
||||
# `/mnt/library/homecloud/<nc_user>/files/<rel>` AND
|
||||
# registers the file in oc_filecache, so the desktop sync
|
||||
# client and Nextcloud's web UI both see it.
|
||||
with open(tmp_path, "rb") as body:
|
||||
put_for_user(current_user, filepath, body)
|
||||
else:
|
||||
os.replace(tmp_path, filepath)
|
||||
tmp_path = None # consumed
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Upload write failed for {filepath}: {e}")
|
||||
if os.path.exists(filepath):
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(filepath)
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
# WebDAV wrote it; the bind mount should reflect it. If it
|
||||
# doesn't, surface a clean error rather than building a Photo
|
||||
# row that points at a missing file.
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Nextcloud accepted the upload but the file isn't visible on the mount yet.",
|
||||
)
|
||||
|
||||
file_hash = hasher.hexdigest()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user