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:
52
backend/alembic/versions/0016_nextcloud_integration.py
Normal file
52
backend/alembic/versions/0016_nextcloud_integration.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Nextcloud integration: per-user username override + encrypted app password
|
||||||
|
|
||||||
|
Revision ID: 0016_nextcloud_integration
|
||||||
|
Revises: 0015_oidc_and_avatar
|
||||||
|
Create Date: 2026-04-26
|
||||||
|
|
||||||
|
Lets each mule-image user wire their account to a Nextcloud account so
|
||||||
|
photos can be browsed, indexed, and mutated under their own Nextcloud
|
||||||
|
file tree (`/mnt/library/homecloud/<nc_user>/files/...` mounted into the
|
||||||
|
backend + workers as `/nextcloud-users`). The OIDC `preferred_username`
|
||||||
|
claim is the default mapping; the override field handles cases where the
|
||||||
|
authentik username and the Nextcloud username don't match.
|
||||||
|
|
||||||
|
1. users.nextcloud_username — default sourced from preferred_username
|
||||||
|
on OIDC login (only when null), editable via PATCH /api/v1/auth/me.
|
||||||
|
2. users.nextcloud_app_password_enc — Fernet-encrypted Nextcloud app
|
||||||
|
password used for HTTP Basic auth on WebDAV calls. Set from the
|
||||||
|
Settings UI; the cleartext is never persisted.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0016_nextcloud_integration"
|
||||||
|
down_revision: Union[str, None] = "0015_oidc_and_avatar"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
for col_def in (
|
||||||
|
"nextcloud_username VARCHAR",
|
||||||
|
"nextcloud_app_password_enc VARCHAR",
|
||||||
|
):
|
||||||
|
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
|
||||||
|
|
||||||
|
# Index the username for the per-user path-scoping check on /browse
|
||||||
|
# and /source-roots — keeps lookups fast even on tiny user tables.
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_users_nextcloud_username "
|
||||||
|
"ON users (nextcloud_username)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_nextcloud_username"))
|
||||||
|
for col in ("nextcloud_app_password_enc", "nextcloud_username"):
|
||||||
|
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
|
||||||
@@ -12,7 +12,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features
|
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features, nextcloud
|
||||||
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
|
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
|
||||||
from app.services.cleanup import cleanup_data_integrity
|
from app.services.cleanup import cleanup_data_integrity
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@ app.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"
|
|||||||
app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"])
|
||||||
app.include_router(download.router, prefix="/api/v1/download", tags=["download"])
|
app.include_router(download.router, prefix="/api/v1/download", tags=["download"])
|
||||||
app.include_router(features.router, prefix="/api/v1/features", tags=["features"])
|
app.include_router(features.router, prefix="/api/v1/features", tags=["features"])
|
||||||
|
app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"])
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ class User(Base):
|
|||||||
# computes the final avatar URL for the frontend.
|
# computes the final avatar URL for the frontend.
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Nextcloud integration. `nextcloud_username` defaults to the
|
||||||
|
# `preferred_username` OIDC claim on first login but can be overridden
|
||||||
|
# in Settings (the local mule-image username doesn't always match the
|
||||||
|
# Nextcloud user — e.g. authentik `dtoro` ↔ Nextcloud `admin`).
|
||||||
|
# `nextcloud_app_password_enc` is the user's Nextcloud app password
|
||||||
|
# (created from Nextcloud → Settings → Security), Fernet-encrypted at
|
||||||
|
# rest with a key derived from settings.secret_key. Used as HTTP Basic
|
||||||
|
# auth on outgoing WebDAV calls when the user mutates a file under
|
||||||
|
# their Nextcloud-rooted SourceRoot.
|
||||||
|
nextcloud_username = Column(String, nullable=True, index=True)
|
||||||
|
nextcloud_app_password_enc = Column(String, nullable=True)
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ class UserResponse(BaseModel):
|
|||||||
created_at: Optional[str]
|
created_at: Optional[str]
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
display_name: 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):
|
class SetupRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -93,6 +105,8 @@ def serialize_user(user: User) -> UserResponse:
|
|||||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||||
avatar_url=avatar,
|
avatar_url=avatar,
|
||||||
display_name=user.display_name,
|
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)
|
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")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
body: ChangePasswordRequest,
|
body: ChangePasswordRequest,
|
||||||
@@ -426,6 +480,11 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
|||||||
oidc_sub=sub,
|
oidc_sub=sub,
|
||||||
avatar_url=picture,
|
avatar_url=picture,
|
||||||
display_name=display_name,
|
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)
|
db.add(user)
|
||||||
await db.flush()
|
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:
|
if picture and user.avatar_url != picture:
|
||||||
user.avatar_url = picture
|
user.avatar_url = picture
|
||||||
changed = True
|
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:
|
if admin_groups:
|
||||||
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
||||||
if user.role != new_role:
|
if user.role != new_role:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.database import get_db
|
|||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
|
from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
photos = result.scalars().all()
|
photos = result.scalars().all()
|
||||||
return await _permanently_delete(db, photos)
|
return await _permanently_delete(db, photos, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("")
|
@router.delete("")
|
||||||
@@ -70,19 +71,38 @@ async def delete_discarded(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
photos = result.scalars().all()
|
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
|
"""Shared helper: unlink files for the given photos and delete their
|
||||||
rows. Per-file errors are counted but don't abort the batch.
|
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
|
deleted = 0
|
||||||
file_errors = 0
|
file_errors = 0
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
try:
|
try:
|
||||||
if photo.filepath and os.path.exists(photo.filepath):
|
if photo.filepath:
|
||||||
os.unlink(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:
|
except OSError as e:
|
||||||
file_errors += 1
|
file_errors += 1
|
||||||
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
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 import Folder, SourceRoot, Photo
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.dependencies import get_current_user, get_user_folder
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -251,10 +257,16 @@ async def rename_folder(
|
|||||||
detail=f"A folder named '{name}' already exists here",
|
detail=f"A folder named '{name}' already exists here",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if is_nextcloud_path(old_path):
|
||||||
shutil.move(old_path, new_path)
|
# WebDAV MOVE keeps Nextcloud's oc_filecache + sharing metadata
|
||||||
except OSError as e:
|
# consistent. NC's MOVE is recursive — descendants come along,
|
||||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
# 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
|
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||||
# rewrites the prefix; we use the trailing separator on the LIKE
|
# 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",
|
detail=f"A folder named '{name}' already exists here",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if is_nextcloud_path(new_path):
|
||||||
os.makedirs(new_path, exist_ok=False)
|
# MKCOL via WebDAV so Nextcloud knows about the new collection.
|
||||||
except OSError as e:
|
mkcol_for_user(current_user, new_path)
|
||||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
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(
|
new_folder = Folder(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -395,14 +411,28 @@ async def delete_folder(
|
|||||||
|
|
||||||
# mode == 'permanent'
|
# mode == 'permanent'
|
||||||
file_errors = 0
|
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:
|
try:
|
||||||
if p.filepath and os.path.exists(p.filepath):
|
nc_delete(current_user, folder_path)
|
||||||
os.unlink(p.filepath)
|
except HTTPException as e:
|
||||||
except OSError as e:
|
logger.error(f"Nextcloud DELETE failed for {folder_path}: {e.detail}")
|
||||||
file_errors += 1
|
raise
|
||||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
for p in photos:
|
||||||
await db.delete(p)
|
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.
|
# Delete this folder + every descendant Folder row.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -412,13 +442,14 @@ async def delete_folder(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if not folder_is_nc:
|
||||||
if os.path.isdir(folder_path):
|
try:
|
||||||
shutil.rmtree(folder_path)
|
if os.path.isdir(folder_path):
|
||||||
except OSError as e:
|
shutil.rmtree(folder_path)
|
||||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
except OSError as e:
|
||||||
# Don't raise — DB rows are already gone, leaving an orphan
|
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||||
# directory is the lesser evil.
|
# Don't raise — DB rows are already gone, leaving an orphan
|
||||||
|
# directory is the lesser evil.
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
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,
|
get_user_or_shared_heap, get_user_or_shared_folder,
|
||||||
can_access_photo_via_share,
|
can_access_photo_via_share,
|
||||||
)
|
)
|
||||||
|
from app.services.nextcloud_dav import is_nextcloud_path, move_for_user
|
||||||
from app.config import settings
|
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 = APIRouter()
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -843,11 +857,18 @@ async def update_photo(
|
|||||||
if os.path.exists(new_path):
|
if os.path.exists(new_path):
|
||||||
raise HTTPException(status_code=409, detail="A file with that name already exists")
|
raise HTTPException(status_code=409, detail="A file with that name already exists")
|
||||||
|
|
||||||
try:
|
if is_nextcloud_path(photo.filepath):
|
||||||
os.rename(photo.filepath, new_path)
|
# WebDAV MOVE within the same directory == rename. Lands
|
||||||
except OSError as e:
|
# the new name in oc_filecache so Nextcloud's web UI and
|
||||||
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
|
# sync clients see it; the bind mount reflects the
|
||||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
# 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.filename = new_name
|
||||||
photo.filepath = new_path
|
photo.filepath = new_path
|
||||||
@@ -974,6 +995,15 @@ async def copy_photos(
|
|||||||
return candidate
|
return candidate
|
||||||
return None
|
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:
|
for photo in photos_to_copy:
|
||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
errors.append({"id": photo.id, "error": "source file missing"})
|
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)
|
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:
|
try:
|
||||||
shutil.copy2(photo.filepath, new_path)
|
shutil.copy2(photo.filepath, new_path)
|
||||||
except OSError as e:
|
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}"})
|
errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"})
|
||||||
continue
|
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:
|
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:
|
except OSError as e:
|
||||||
errors.append({"id": photo.id, "error": str(e)})
|
errors.append({"id": photo.id, "error": str(e)})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ the same thumb + metadata pipeline that the scanner uses. An optional
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
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.heaps import heap_photos
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.date_guess import has_date_warning
|
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.scan import SUPPORTED_EXTENSIONS, get_media_type
|
||||||
from app.tasks.thumbs import generate_thumbnails
|
from app.tasks.thumbs import generate_thumbnails
|
||||||
from app.services.metadata import extract_metadata
|
from app.services.metadata import extract_metadata
|
||||||
@@ -122,8 +128,11 @@ async def _ensure_subfolder(
|
|||||||
) -> Folder:
|
) -> Folder:
|
||||||
"""Return (or create) a Folder row named `name` under `parent`.
|
"""Return (or create) a Folder row named `name` under `parent`.
|
||||||
|
|
||||||
Also mkdirs the directory on disk. Idempotent — safe to call for a
|
Also mkdirs the directory on disk. For Nextcloud-rooted paths the
|
||||||
path segment that already exists as a Folder row or directory.
|
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))
|
child_path = os.path.normpath(os.path.join(parent.path, name))
|
||||||
|
|
||||||
@@ -134,10 +143,23 @@ async def _ensure_subfolder(
|
|||||||
)
|
)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not 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
|
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(
|
child = Folder(
|
||||||
name=name,
|
name=name,
|
||||||
path=child_path,
|
path=child_path,
|
||||||
@@ -213,38 +235,74 @@ async def upload_file(
|
|||||||
target_folder = await _ensure_subfolder(target_folder, seg, current_user, db)
|
target_folder = await _ensure_subfolder(target_folder, seg, current_user, db)
|
||||||
|
|
||||||
target_dir = target_folder.path
|
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)
|
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()
|
hasher = hashlib.sha256()
|
||||||
total = 0
|
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:
|
try:
|
||||||
with open(filepath, 'wb') as out:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await file.read(1024 * 1024)
|
chunk = await file.read(1024 * 1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
total += len(chunk)
|
total += len(chunk)
|
||||||
if total > MAX_UPLOAD_BYTES:
|
if total > MAX_UPLOAD_BYTES:
|
||||||
out.close()
|
|
||||||
os.unlink(filepath)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit",
|
detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit",
|
||||||
)
|
)
|
||||||
hasher.update(chunk)
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Upload write failed for {filepath}: {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:
|
try:
|
||||||
os.unlink(filepath)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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()
|
file_hash = hasher.hexdigest()
|
||||||
|
|
||||||
|
|||||||
302
backend/app/services/nextcloud_dav.py
Normal file
302
backend/app/services/nextcloud_dav.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""Nextcloud WebDAV client — only the verbs we actually need.
|
||||||
|
|
||||||
|
Outgoing mutations (upload, delete, rename/move) on files that live
|
||||||
|
under a user's Nextcloud-rooted SourceRoot route through this client
|
||||||
|
instead of touching the filesystem directly. That way Nextcloud's
|
||||||
|
oc_filecache, trashbin, sharing/comments metadata, and desktop sync
|
||||||
|
clients all stay coherent — the price of bypassing it is a stale
|
||||||
|
Nextcloud and resurrected files when sync clients re-upload.
|
||||||
|
|
||||||
|
Reads (scanning, hashing, EXIF, ML pipelines) keep using the bind
|
||||||
|
mount at NEXTCLOUD_USERS_ROOT. WebDAV is far too slow for every byte
|
||||||
|
of every photo, and the read side has no consistency cost — Nextcloud
|
||||||
|
is the writer, the bind mount is the reader, that's it.
|
||||||
|
|
||||||
|
Auth: HTTP Basic with the user's Nextcloud app password (set via the
|
||||||
|
Settings UI, stored Fernet-encrypted at rest). OIDC bearer reuse is a
|
||||||
|
later optimization; app passwords work today and are well-supported.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import BinaryIO, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.secrets import decrypt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Top-level mount inside the backend container. The Nextcloud user tree
|
||||||
|
# `/mnt/library/homecloud/<nc_user>/files/...` shows up here as
|
||||||
|
# `/nextcloud-users/<nc_user>/files/...`.
|
||||||
|
NEXTCLOUD_USERS_ROOT = os.environ.get("NEXTCLOUD_USERS_ROOT", "/nextcloud-users")
|
||||||
|
|
||||||
|
|
||||||
|
def is_nextcloud_path(path: str) -> bool:
|
||||||
|
"""True iff `path` resolves under the configured NC users mount."""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
norm = os.path.normpath(path)
|
||||||
|
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||||
|
return norm == root or norm.startswith(root + os.sep)
|
||||||
|
|
||||||
|
|
||||||
|
def split_nextcloud_path(path: str) -> Tuple[str, str]:
|
||||||
|
"""Return (nc_username, rel_path) for a file/dir under the NC mount.
|
||||||
|
|
||||||
|
rel_path is the path relative to `<NEXTCLOUD_USERS_ROOT>/<user>/files/`,
|
||||||
|
suitable for appending to the WebDAV base URL. Raises if `path`
|
||||||
|
isn't a Nextcloud-rooted path or doesn't sit under a `files/`
|
||||||
|
directory.
|
||||||
|
"""
|
||||||
|
norm = os.path.normpath(path)
|
||||||
|
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||||
|
if not (norm == root or norm.startswith(root + os.sep)):
|
||||||
|
raise ValueError(f"Not a Nextcloud-rooted path: {path!r}")
|
||||||
|
rest = norm[len(root):].lstrip(os.sep) # "<user>/files/foo/bar.jpg"
|
||||||
|
parts = rest.split(os.sep, 2)
|
||||||
|
if len(parts) < 3 or parts[1] != "files":
|
||||||
|
# Either we got just /<user>, /<user>/files (no rel), or a
|
||||||
|
# different second segment — only the user's `files/` tree is
|
||||||
|
# safe to mutate via WebDAV.
|
||||||
|
if len(parts) == 2 and parts[1] == "files":
|
||||||
|
return parts[0], ""
|
||||||
|
raise ValueError(
|
||||||
|
f"Path doesn't live under <user>/files/: {path!r}"
|
||||||
|
)
|
||||||
|
return parts[0], parts[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class NextcloudCredentialsMissing(HTTPException):
|
||||||
|
"""The user hasn't set their Nextcloud app password yet, but the
|
||||||
|
request needs it to mutate a Nextcloud-managed file. 412 because
|
||||||
|
the precondition (credentials) is missing rather than the request
|
||||||
|
itself being malformed."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||||
|
detail=(
|
||||||
|
"Set your Nextcloud app password in Settings → Library "
|
||||||
|
"before mutating files in your Nextcloud library."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credentials_for(user: User) -> tuple[str, str]:
|
||||||
|
"""Resolve the (nc_username, app_password) pair for a user.
|
||||||
|
Raises NextcloudCredentialsMissing when either is missing."""
|
||||||
|
nc_user = (user.nextcloud_username or "").strip()
|
||||||
|
app_pw = decrypt(user.nextcloud_app_password_enc)
|
||||||
|
if not nc_user or not app_pw:
|
||||||
|
raise NextcloudCredentialsMissing()
|
||||||
|
return nc_user, app_pw
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
"""The Nextcloud WebDAV base URL (without trailing slash, without
|
||||||
|
user-suffixed path). Resolved per-call so a config reload picks up
|
||||||
|
a new value without restarting workers."""
|
||||||
|
base = (
|
||||||
|
os.environ.get("NEXTCLOUD_BASE_URL")
|
||||||
|
or getattr(settings, "nextcloud_base_url", None)
|
||||||
|
or ""
|
||||||
|
).rstrip("/")
|
||||||
|
if not base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="NEXTCLOUD_BASE_URL is not configured on the backend",
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_url(nc_username: str, rel_path: str) -> str:
|
||||||
|
"""Compose the absolute WebDAV URL for a relative path under the
|
||||||
|
user's `files/` collection."""
|
||||||
|
base = _base_url()
|
||||||
|
rel = (rel_path or "").lstrip("/")
|
||||||
|
# Each segment must be URL-encoded. httpx encodes path segments at
|
||||||
|
# request time, so we hand it the raw join — but we explicitly drop
|
||||||
|
# `..` traversals here as defense in depth.
|
||||||
|
if any(seg in ("", "..") for seg in rel.split("/") if seg):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid relative path")
|
||||||
|
parts = [base, "remote.php/dav/files", nc_username]
|
||||||
|
if rel:
|
||||||
|
parts.append(rel)
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# httpx Client TTL: short, since a single request is the unit of work.
|
||||||
|
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(auth: tuple[str, str]) -> httpx.Client:
|
||||||
|
return httpx.Client(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _async_client(auth: tuple[str, str]) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_dav(resp: httpx.Response, action: str) -> None:
|
||||||
|
"""Translate Nextcloud WebDAV errors into FastAPI HTTPExceptions
|
||||||
|
the frontend can show. We surface Nextcloud's body verbatim when
|
||||||
|
it's small enough, since it tends to carry the actually-useful
|
||||||
|
detail (quota, permission denied, etc.)."""
|
||||||
|
if resp.is_success:
|
||||||
|
return
|
||||||
|
body = resp.text or ""
|
||||||
|
if len(body) > 400:
|
||||||
|
body = body[:400] + "…"
|
||||||
|
logger.warning("Nextcloud %s failed: %s %s — %s", action, resp.status_code, resp.reason_phrase, body[:200])
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Nextcloud rejected the {action}: {resp.reason_phrase}. "
|
||||||
|
f"Check your app password under Settings → Library.",
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Not found in Nextcloud during {action}")
|
||||||
|
if resp.status_code == 507:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||||
|
detail="Nextcloud quota exceeded",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Nextcloud error during {action}: {resp.status_code} {resp.reason_phrase}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verbs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def delete_for_user(user: User, abs_path: str) -> None:
|
||||||
|
"""WebDAV DELETE — moves the file/dir into the user's NC trashbin.
|
||||||
|
`abs_path` is the absolute filesystem path under the bind mount."""
|
||||||
|
nc_user, app_pw = _credentials_for(user)
|
||||||
|
expected_user, rel = split_nextcloud_path(abs_path)
|
||||||
|
if expected_user != nc_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Path belongs to a different Nextcloud user",
|
||||||
|
)
|
||||||
|
url = _dav_url(nc_user, rel)
|
||||||
|
with _client((nc_user, app_pw)) as c:
|
||||||
|
resp = c.request("DELETE", url)
|
||||||
|
# 204 = deleted. 404 = already gone (treat as success, idempotent).
|
||||||
|
if resp.status_code == 404:
|
||||||
|
logger.info("Nextcloud DELETE %s already gone, treating as success", rel)
|
||||||
|
return
|
||||||
|
_raise_for_dav(resp, "delete")
|
||||||
|
|
||||||
|
|
||||||
|
def move_for_user(user: User, src_abs: str, dst_abs: str) -> None:
|
||||||
|
"""WebDAV MOVE — rename or move within the same Nextcloud user."""
|
||||||
|
nc_user, app_pw = _credentials_for(user)
|
||||||
|
src_user, src_rel = split_nextcloud_path(src_abs)
|
||||||
|
dst_user, dst_rel = split_nextcloud_path(dst_abs)
|
||||||
|
if src_user != nc_user or dst_user != nc_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="MOVE across Nextcloud users is not supported",
|
||||||
|
)
|
||||||
|
src_url = _dav_url(nc_user, src_rel)
|
||||||
|
dst_url = _dav_url(nc_user, dst_rel)
|
||||||
|
with _client((nc_user, app_pw)) as c:
|
||||||
|
resp = c.request(
|
||||||
|
"MOVE",
|
||||||
|
src_url,
|
||||||
|
headers={"Destination": dst_url, "Overwrite": "F"},
|
||||||
|
)
|
||||||
|
_raise_for_dav(resp, "move")
|
||||||
|
|
||||||
|
|
||||||
|
def mkcol_for_user(user: User, abs_path: str) -> None:
|
||||||
|
"""WebDAV MKCOL — create a directory. Idempotent: a 405 (Method Not
|
||||||
|
Allowed) means the collection already exists, treat as success."""
|
||||||
|
nc_user, app_pw = _credentials_for(user)
|
||||||
|
expected_user, rel = split_nextcloud_path(abs_path)
|
||||||
|
if expected_user != nc_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Path belongs to a different Nextcloud user",
|
||||||
|
)
|
||||||
|
url = _dav_url(nc_user, rel)
|
||||||
|
with _client((nc_user, app_pw)) as c:
|
||||||
|
resp = c.request("MKCOL", url)
|
||||||
|
if resp.status_code == 405:
|
||||||
|
return
|
||||||
|
_raise_for_dav(resp, "mkcol")
|
||||||
|
|
||||||
|
|
||||||
|
def put_for_user(
|
||||||
|
user: User,
|
||||||
|
abs_path: str,
|
||||||
|
fileobj: BinaryIO,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""WebDAV PUT — upload `fileobj` to `abs_path`. Caller is
|
||||||
|
responsible for ensuring intermediate collections exist via
|
||||||
|
`mkcol_for_user`. Streams the body, no in-memory copy."""
|
||||||
|
nc_user, app_pw = _credentials_for(user)
|
||||||
|
expected_user, rel = split_nextcloud_path(abs_path)
|
||||||
|
if expected_user != nc_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Path belongs to a different Nextcloud user",
|
||||||
|
)
|
||||||
|
url = _dav_url(nc_user, rel)
|
||||||
|
headers = {}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
with _client((nc_user, app_pw)) as c:
|
||||||
|
resp = c.request("PUT", url, content=fileobj, headers=headers)
|
||||||
|
_raise_for_dav(resp, "upload")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_parents_for_user(user: User, abs_path: str) -> None:
|
||||||
|
"""Walk the parent chain of `abs_path` under the user's NC root and
|
||||||
|
`mkcol` any missing collection. Stops at the user's `files/`
|
||||||
|
directory — never tries to create that, which is owned by Nextcloud
|
||||||
|
itself."""
|
||||||
|
nc_user, _ = _credentials_for(user)
|
||||||
|
expected_user, rel = split_nextcloud_path(abs_path)
|
||||||
|
if expected_user != nc_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Path belongs to a different Nextcloud user",
|
||||||
|
)
|
||||||
|
if not rel:
|
||||||
|
return
|
||||||
|
parts = rel.split("/")
|
||||||
|
if len(parts) <= 1:
|
||||||
|
return # no intermediate dirs to make
|
||||||
|
accum: list[str] = []
|
||||||
|
for seg in parts[:-1]:
|
||||||
|
accum.append(seg)
|
||||||
|
sub_rel = "/".join(accum)
|
||||||
|
sub_abs = os.path.join(NEXTCLOUD_USERS_ROOT, nc_user, "files", sub_rel)
|
||||||
|
mkcol_for_user(user, sub_abs)
|
||||||
|
|
||||||
|
|
||||||
|
def whoami_dir_exists(nc_username: str) -> bool:
|
||||||
|
"""True iff the bind-mounted `<NEXTCLOUD_USERS_ROOT>/<user>/files`
|
||||||
|
directory exists. Used by the UI to validate the override field
|
||||||
|
without round-tripping to Nextcloud — the bind mount is enough to
|
||||||
|
confirm Nextcloud actually has that user."""
|
||||||
|
if not nc_username or "/" in nc_username or nc_username in (".", ".."):
|
||||||
|
return False
|
||||||
|
target = os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
|
||||||
|
return os.path.isdir(target)
|
||||||
45
backend/app/services/secrets.py
Normal file
45
backend/app/services/secrets.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Symmetric encryption for credentials we have to store.
|
||||||
|
|
||||||
|
Used today for the per-user Nextcloud app password — we need the
|
||||||
|
plaintext to put it in an outgoing HTTP Basic header, so a one-way
|
||||||
|
hash won't do. Key is derived from `settings.secret_key` via SHA-256
|
||||||
|
so existing deployments don't need a separate KMS dance, and a stable
|
||||||
|
SECRET_KEY rotates these credentials automatically.
|
||||||
|
|
||||||
|
Fernet is symmetric AES-128-CBC + HMAC-SHA256 with a versioned
|
||||||
|
ciphertext envelope; good enough for column-level secrecy in a
|
||||||
|
single-host homelab. Rotate by setting a new SECRET_KEY and asking
|
||||||
|
users to re-enter their app password.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
# Fernet requires a 32-byte url-safe base64 key. SHA-256 of the
|
||||||
|
# configured secret gives us exactly 32 bytes; b64-urlsafe-encode
|
||||||
|
# to fit the API contract.
|
||||||
|
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(digest))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
"""Return a base64 token that can be stored in a VARCHAR column."""
|
||||||
|
return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(token: Optional[str]) -> Optional[str]:
|
||||||
|
"""Inverse of encrypt. Returns None for None / empty input. Raises
|
||||||
|
on tampered or wrong-key tokens — callers should treat that as
|
||||||
|
"credential unset" rather than crashing the request."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
return None
|
||||||
@@ -32,6 +32,14 @@ services:
|
|||||||
# filesystem; flip to :ro for a strict read-only library and the
|
# filesystem; flip to :ro for a strict read-only library and the
|
||||||
# write endpoints will return EROFS.
|
# write endpoints will return EROFS.
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
|
# Optional Nextcloud integration: mount the homecloud data dir so
|
||||||
|
# users can register subfolders of their Nextcloud `files/` tree
|
||||||
|
# as per-user SourceRoots. Reads use this path directly; mutations
|
||||||
|
# (upload, delete, rename, move) dispatch via WebDAV against
|
||||||
|
# NEXTCLOUD_BASE_URL so Nextcloud's oc_filecache stays in sync.
|
||||||
|
# Leave NEXTCLOUD_USERS_HOST_PATH unset (or pointing at a no-op
|
||||||
|
# path) to disable.
|
||||||
|
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
|
- db_data:/data/db # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
|
||||||
@@ -71,6 +79,16 @@ services:
|
|||||||
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
|
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
|
||||||
- OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false}
|
- OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false}
|
||||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
|
# Nextcloud integration. NEXTCLOUD_USERS_ROOT is the in-container
|
||||||
|
# path that NEXTCLOUD_USERS_HOST_PATH binds to. NEXTCLOUD_BASE_URL
|
||||||
|
# is the public-facing Nextcloud URL used for outgoing WebDAV
|
||||||
|
# calls (must be reachable from the backend container; e.g.
|
||||||
|
# https://cloud.example.com or http://nextcloud:80 if you put it
|
||||||
|
# on the same docker network). Leave NEXTCLOUD_BASE_URL unset to
|
||||||
|
# keep the integration off — the router endpoints stay registered
|
||||||
|
# but mutating endpoints fail with a clear error.
|
||||||
|
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||||
|
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -113,6 +131,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
|
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
@@ -123,6 +142,8 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379
|
- CELERY_BROKER_URL=redis://redis:6379
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=/photos
|
- PHOTO_DIRS=/photos
|
||||||
|
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||||
|
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
# NullPool — see app/database.py for rationale.
|
# NullPool — see app/database.py for rationale.
|
||||||
@@ -156,6 +177,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
|
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||||
@@ -163,6 +185,8 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379
|
- CELERY_BROKER_URL=redis://redis:6379
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=/photos
|
- PHOTO_DIRS=/photos
|
||||||
|
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||||
|
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- MULITA_CELERY_WORKER=1
|
- MULITA_CELERY_WORKER=1
|
||||||
@@ -185,6 +209,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
|
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
@@ -195,6 +220,8 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379
|
- CELERY_BROKER_URL=redis://redis:6379
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=/photos
|
- PHOTO_DIRS=/photos
|
||||||
|
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||||
|
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- MULITA_CELERY_WORKER=1
|
- MULITA_CELERY_WORKER=1
|
||||||
|
|||||||
212
frontend/src/components/dialogs/NextcloudFolderPicker.tsx
Normal file
212
frontend/src/components/dialogs/NextcloudFolderPicker.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* NextcloudFolderPicker — modal that lets the user navigate their
|
||||||
|
* Nextcloud `files/` tree and register a subfolder as a SourceRoot in
|
||||||
|
* mule-image. Lazy-loads each level via GET /nextcloud/browse?path=...,
|
||||||
|
* so opening the picker doesn't slurp the whole tree.
|
||||||
|
*
|
||||||
|
* Path scoping is enforced server-side; we still avoid showing a `..`
|
||||||
|
* affordance above the user's root so the UI never surfaces the idea
|
||||||
|
* that there's something to escape to.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ChevronRight, Folder, FolderOpen, Loader2, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { nextcloud, type NextcloudBrowseEntry } from '../../services/api'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: () => void // called after a successful POST so the parent refetches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NextcloudFolderPicker({ open, onClose, onCreated }: Props) {
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const [entries, setEntries] = useState<NextcloudBrowseEntry[]>([])
|
||||||
|
const [parentRel, setParentRel] = useState<string | null>(null)
|
||||||
|
const [ncUser, setNcUser] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
|
||||||
|
const loadPath = useCallback(async (target: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await nextcloud.browse(target)
|
||||||
|
setEntries(r.entries)
|
||||||
|
setParentRel(r.parent_rel)
|
||||||
|
setNcUser(r.nc_username)
|
||||||
|
setPath(r.rel_path)
|
||||||
|
// Default the SourceRoot name from the leaf folder name.
|
||||||
|
const leaf = r.rel_path.split('/').filter(Boolean).slice(-1)[0]
|
||||||
|
if (leaf) setName(leaf)
|
||||||
|
else setName(`${r.nc_username} library`)
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
void loadPath('')
|
||||||
|
}
|
||||||
|
}, [open, loadPath])
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
if (!path) {
|
||||||
|
toast.error('Pick a subfolder first — the root is too broad.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error('Library name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await nextcloud.createSourceRoot(name.trim(), path)
|
||||||
|
toast.success(`Added "${name.trim()}" to your libraries`)
|
||||||
|
onCreated()
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
toast.error(`Could not add library: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [path, name, onCreated, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
// Breadcrumb segments for the current path. "/" rendered as the user's
|
||||||
|
// root → click jumps back to that level.
|
||||||
|
const segments = path ? path.split('/').filter(Boolean) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="flex h-[36rem] w-[40rem] flex-col rounded-lg border border-border bg-surface shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Add library from Nextcloud
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1 border-b border-border bg-surface-2 px-4 py-2 text-xs text-text-muted">
|
||||||
|
<button
|
||||||
|
onClick={() => loadPath('')}
|
||||||
|
className="font-mono text-text hover:underline"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{ncUser || 'home'}
|
||||||
|
</button>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const target = segments.slice(0, i + 1).join('/')
|
||||||
|
return (
|
||||||
|
<span key={target} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
<button
|
||||||
|
onClick={() => loadPath(target)}
|
||||||
|
className="font-mono text-text hover:underline"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{seg}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-text-muted">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="rounded border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && entries.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-text-muted">
|
||||||
|
No subfolders here. Use Nextcloud to create a folder, then come
|
||||||
|
back to register it.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && entries.length > 0 && (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{/* "Up one level" — only shown when not already at root */}
|
||||||
|
{parentRel !== null && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => loadPath(parentRel)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 rotate-180 text-text-muted" />
|
||||||
|
<span className="text-text-muted">..</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.path}>
|
||||||
|
<button
|
||||||
|
onClick={() => loadPath(e.path)}
|
||||||
|
disabled={!e.has_children && false}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4 text-primary/70" />
|
||||||
|
<span className="truncate">{e.name}</span>
|
||||||
|
{e.has_children && (
|
||||||
|
<ChevronRight className="ml-auto h-3 w-3 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-border bg-surface-2 px-4 py-3">
|
||||||
|
<div className="mb-2 text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
|
Selected
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 truncate font-mono text-xs" title={path || '(root)'}>
|
||||||
|
{path ? `${ncUser}/files/${path}` : '(pick a subfolder)'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Library name"
|
||||||
|
className="flex-1 rounded border border-border bg-surface px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={!path || !name.trim() || submitting}
|
||||||
|
className="rounded bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Adding…' : 'Add this folder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Cloud,
|
||||||
Copy,
|
Copy,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -17,21 +18,26 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Brain,
|
Brain,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
library,
|
library,
|
||||||
admin as adminApi,
|
admin as adminApi,
|
||||||
|
account as accountApi,
|
||||||
|
nextcloud as nextcloudApi,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type PipelineStage,
|
type PipelineStage,
|
||||||
type ScanStatus,
|
type ScanStatus,
|
||||||
type WorkerStatus,
|
type WorkerStatus,
|
||||||
type FeatureFlagSnapshot,
|
type FeatureFlagSnapshot,
|
||||||
|
type NextcloudSourceRoot,
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { UserManagement } from '../admin/UserManagement'
|
import { UserManagement } from '../admin/UserManagement'
|
||||||
|
import { NextcloudFolderPicker } from './NextcloudFolderPicker'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -323,6 +329,8 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<NextcloudIntegrationCard />
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Activity className="h-4 w-4" />}
|
icon={<Activity className="h-4 w-4" />}
|
||||||
title="Pipeline progress"
|
title="Pipeline progress"
|
||||||
@@ -1277,3 +1285,282 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Nextcloud integration card ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Lives inside the Library tab. Lets the user (a) review/override their
|
||||||
|
// Nextcloud username, (b) store the Nextcloud app password used for
|
||||||
|
// outgoing WebDAV mutations, and (c) browse + register subfolders of
|
||||||
|
// their Nextcloud `files/` tree as per-user SourceRoots.
|
||||||
|
//
|
||||||
|
// Username defaults to the OIDC `preferred_username` claim on first
|
||||||
|
// login; the override is here for cases where the local mule-image user
|
||||||
|
// doesn't match the Nextcloud user (e.g. authentik `dtoro` vs Nextcloud
|
||||||
|
// `admin`). The app password is set from Nextcloud → Settings →
|
||||||
|
// Security → App passwords; we encrypt at rest and never echo it back.
|
||||||
|
const NEXTCLOUD_ROOTS_KEY = ['settings', 'nextcloud', 'source-roots'] as const
|
||||||
|
|
||||||
|
function NextcloudIntegrationCard() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [usernameInput, setUsernameInput] = useState('')
|
||||||
|
const [passwordInput, setPasswordInput] = useState('')
|
||||||
|
const [whoamiState, setWhoamiState] = useState<
|
||||||
|
{ state: 'idle' } | { state: 'checking' } | { state: 'ok' } | { state: 'bad'; reason: string }
|
||||||
|
>({ state: 'idle' })
|
||||||
|
|
||||||
|
// Initialize the editable fields from the live user.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setUsernameInput(user.nextcloud_username ?? '')
|
||||||
|
}
|
||||||
|
}, [user?.id, user?.nextcloud_username])
|
||||||
|
|
||||||
|
const rootsQuery = useQuery({
|
||||||
|
queryKey: NEXTCLOUD_ROOTS_KEY,
|
||||||
|
queryFn: () => nextcloudApi.listSourceRoots(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const validate = useCallback(async () => {
|
||||||
|
const candidate = usernameInput.trim()
|
||||||
|
if (!candidate) {
|
||||||
|
setWhoamiState({ state: 'bad', reason: 'no_username' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWhoamiState({ state: 'checking' })
|
||||||
|
try {
|
||||||
|
const r = await nextcloudApi.whoami(candidate)
|
||||||
|
setWhoamiState(r.valid ? { state: 'ok' } : { state: 'bad', reason: r.reason ?? 'invalid' })
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
setWhoamiState({ state: 'bad', reason: message })
|
||||||
|
}
|
||||||
|
}, [usernameInput])
|
||||||
|
|
||||||
|
const saveSettings = useMutation({
|
||||||
|
mutationFn: async (body: { nextcloud_username?: string; nextcloud_app_password?: string }) =>
|
||||||
|
accountApi.updateMe(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Nextcloud settings updated')
|
||||||
|
// Force AuthContext to refetch so the new flags propagate; the
|
||||||
|
// /auth/me endpoint backs both this and the AuthContext.
|
||||||
|
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||||
|
// Reload page to pick up the new auth user object — cheaper than
|
||||||
|
// wiring a refresh function through context for a one-off save.
|
||||||
|
window.setTimeout(() => window.location.reload(), 600)
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
toast.error(`Save failed: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeRoot = useMutation({
|
||||||
|
mutationFn: async (id: string) => nextcloudApi.deleteSourceRoot(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Nextcloud library removed')
|
||||||
|
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
toast.error(`Could not remove: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const roots = rootsQuery.data ?? []
|
||||||
|
const hasPw = user?.has_nextcloud_app_password ?? false
|
||||||
|
const usernameDirty = usernameInput.trim() !== (user?.nextcloud_username ?? '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
icon={<Cloud className="h-4 w-4" />}
|
||||||
|
title="Nextcloud library"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Browse subfolders of your Nextcloud <code>files/</code> tree and
|
||||||
|
register them as photo libraries. Reads stay on the filesystem
|
||||||
|
(fast); uploads, deletes, renames, and moves dispatch through
|
||||||
|
Nextcloud's WebDAV so its database, trashbin, comments, and sync
|
||||||
|
clients stay in sync.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Username override */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
|
Nextcloud username
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={usernameInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsernameInput(e.target.value)
|
||||||
|
setWhoamiState({ state: 'idle' })
|
||||||
|
}}
|
||||||
|
placeholder="e.g. admin"
|
||||||
|
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void validate()}
|
||||||
|
disabled={whoamiState.state === 'checking'}
|
||||||
|
>
|
||||||
|
{whoamiState.state === 'checking' ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : whoamiState.state === 'ok' ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
'Validate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!usernameDirty || saveSettings.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
saveSettings.mutate({ nextcloud_username: usernameInput.trim() })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{whoamiState.state === 'bad' && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[11px] text-red-400">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{whoamiState.reason === 'no_files_dir'
|
||||||
|
? 'No files/ directory for that user on the mounted Nextcloud volume.'
|
||||||
|
: whoamiState.reason === 'no_username'
|
||||||
|
? 'Enter a username first.'
|
||||||
|
: whoamiState.reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{whoamiState.state === 'ok' && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[11px] text-green-400">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Found a Nextcloud files/ directory.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App password */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
|
Nextcloud app password{' '}
|
||||||
|
{hasPw && <span className="text-green-400">(set)</span>}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordInput}
|
||||||
|
onChange={(e) => setPasswordInput(e.target.value)}
|
||||||
|
placeholder={hasPw ? '•••••••• (leave blank to keep)' : 'Generate in Nextcloud → Settings → Security'}
|
||||||
|
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={saveSettings.isPending || (passwordInput.length === 0 && !hasPw)}
|
||||||
|
onClick={() => {
|
||||||
|
if (passwordInput.length > 0) {
|
||||||
|
saveSettings.mutate({ nextcloud_app_password: passwordInput })
|
||||||
|
setPasswordInput('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{hasPw && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={saveSettings.isPending}
|
||||||
|
onClick={() => saveSettings.mutate({ nextcloud_app_password: '' })}
|
||||||
|
title="Clear stored password"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-text-muted">
|
||||||
|
Used as HTTP Basic auth on outgoing WebDAV calls. Stored
|
||||||
|
encrypted at rest with a key derived from the backend{' '}
|
||||||
|
<code>SECRET_KEY</code>; the cleartext is never logged.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SourceRoot list + Add button */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
|
Nextcloud-rooted libraries
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!user?.nextcloud_username}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<Cloud className="mr-1 h-3 w-3" />
|
||||||
|
Add from Nextcloud
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!user?.nextcloud_username && (
|
||||||
|
<p className="mt-1 text-[11px] text-text-muted">
|
||||||
|
Set + save your Nextcloud username above before adding libraries.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rootsQuery.isLoading && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!rootsQuery.isLoading && roots.length === 0 && (
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
No Nextcloud libraries yet. Click "Add from Nextcloud" once
|
||||||
|
you've validated your username and stored an app password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{roots.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{roots.map((r: NextcloudSourceRoot) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center gap-2 rounded border border-border bg-surface px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<Cloud className="h-3 w-3 text-primary/70" />
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<div className="font-medium text-text">{r.name}</div>
|
||||||
|
<div className="truncate font-mono text-text-muted" title={r.path}>
|
||||||
|
{r.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!r.is_active && (
|
||||||
|
<span className="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] text-amber-400">
|
||||||
|
inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={removeRoot.isPending}
|
||||||
|
onClick={() => removeRoot.mutate(r.id)}
|
||||||
|
title="Remove this library"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NextcloudFolderPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface AuthUser {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
display_name: string | null
|
display_name: string | null
|
||||||
|
// Nextcloud integration. `nextcloud_username` is the override (defaults
|
||||||
|
// to OIDC preferred_username on first login). The boolean flag is
|
||||||
|
// server-side only — the cleartext password never reaches the client.
|
||||||
|
nextcloud_username: string | null
|
||||||
|
has_nextcloud_app_password: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
|
|||||||
@@ -1108,4 +1108,89 @@ export const features = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nextcloud integration ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Browse the user's Nextcloud `files/` tree, register subfolders as
|
||||||
|
// per-user SourceRoots, and validate the username override. Path
|
||||||
|
// scoping is enforced server-side; all paths in/out are relative to
|
||||||
|
// the user's NC `files/` root.
|
||||||
|
|
||||||
|
export interface NextcloudBrowseEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
has_children: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextcloudBrowseResponse {
|
||||||
|
nc_username: string
|
||||||
|
rel_path: string
|
||||||
|
parent_rel: string | null
|
||||||
|
entries: NextcloudBrowseEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextcloudWhoamiResponse {
|
||||||
|
configured: boolean
|
||||||
|
candidate: string | null
|
||||||
|
valid: boolean
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextcloudSourceRoot {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_active: boolean
|
||||||
|
is_nextcloud: true
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextcloud = {
|
||||||
|
whoami: async (candidate?: string): Promise<NextcloudWhoamiResponse> => {
|
||||||
|
const response = await api.get('/nextcloud/whoami', {
|
||||||
|
params: candidate ? { candidate } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
browse: async (path: string = ''): Promise<NextcloudBrowseResponse> => {
|
||||||
|
const response = await api.get('/nextcloud/browse', {
|
||||||
|
params: { path },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
listSourceRoots: async (): Promise<NextcloudSourceRoot[]> => {
|
||||||
|
const response = await api.get('/nextcloud/source-roots')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createSourceRoot: async (
|
||||||
|
name: string,
|
||||||
|
nextcloudPath: string,
|
||||||
|
): Promise<NextcloudSourceRoot> => {
|
||||||
|
const response = await api.post('/nextcloud/source-roots', {
|
||||||
|
name,
|
||||||
|
nextcloud_path: nextcloudPath,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSourceRoot: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/nextcloud/source-roots/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account self-service (Nextcloud creds + username override) ─────────
|
||||||
|
export const account = {
|
||||||
|
/** Update the current user's Nextcloud integration settings. Pass an
|
||||||
|
* empty string for `nextcloud_app_password` to clear it. */
|
||||||
|
updateMe: async (body: {
|
||||||
|
nextcloud_username?: string
|
||||||
|
nextcloud_app_password?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.patch('/auth/me', body)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
Reference in New Issue
Block a user