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>
118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
"""
|
|
Discard API router
|
|
"""
|
|
import os
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models import Photo
|
|
from app.models.user import User
|
|
from app.dependencies import get_current_user
|
|
from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("")
|
|
async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
"""List discarded photos"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
|
)
|
|
photos = result.scalars().all()
|
|
return photos
|
|
|
|
@router.post("/restore")
|
|
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
"""Restore photos from the discard pile"""
|
|
result = await db.execute(
|
|
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id))
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
for photo in photos:
|
|
photo.is_discarded = False
|
|
photo.discarded_at = None
|
|
|
|
await db.commit()
|
|
return {"status": "success", "restored": len(photos)}
|
|
|
|
@router.delete("/empty")
|
|
async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
"""Permanently delete all discarded photos and unlink their files from
|
|
disk. Failures on individual files are logged but don't abort the batch.
|
|
"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos, current_user)
|
|
|
|
|
|
@router.delete("")
|
|
async def delete_discarded(
|
|
photo_ids: list[str] = Body(..., embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Permanently delete a specific subset of discarded photos. The photos
|
|
must already be in the discard pile — non-discarded ids are skipped so
|
|
this can never bypass the soft-delete safety net.
|
|
"""
|
|
if not photo_ids:
|
|
return {"status": "success", "deleted": 0, "file_errors": 0}
|
|
result = await db.execute(
|
|
select(Photo).where(
|
|
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)
|
|
)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos, current_user)
|
|
|
|
|
|
async def _permanently_delete(db: AsyncSession, photos: list[Photo], user: User) -> dict:
|
|
"""Shared helper: unlink files for the given photos and delete their
|
|
rows. Per-file errors are counted but don't abort the batch.
|
|
|
|
For files inside a Nextcloud-rooted SourceRoot the unlink is dispatched
|
|
through Nextcloud's WebDAV `DELETE` so Nextcloud moves the file into
|
|
the user's trashbin and updates `oc_filecache`. For everything else we
|
|
fall back to plain `os.unlink`.
|
|
"""
|
|
deleted = 0
|
|
file_errors = 0
|
|
for photo in photos:
|
|
try:
|
|
if photo.filepath:
|
|
if is_nextcloud_path(photo.filepath):
|
|
# WebDAV DELETE — Nextcloud moves to trashbin and
|
|
# updates oc_filecache. The bind mount will then
|
|
# reflect the file's absence (Nextcloud writes
|
|
# synchronously). 404 from NC is treated as already
|
|
# gone (idempotent).
|
|
delete_for_user(user, photo.filepath)
|
|
elif os.path.exists(photo.filepath):
|
|
os.unlink(photo.filepath)
|
|
except HTTPException as e:
|
|
# WebDAV-side error — surface to caller via the file_errors
|
|
# counter rather than aborting the whole batch.
|
|
file_errors += 1
|
|
logger.error(f"Failed to delete {photo.filepath} via Nextcloud: {e.detail}")
|
|
continue
|
|
except OSError as e:
|
|
file_errors += 1
|
|
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
|
await db.delete(photo)
|
|
deleted += 1
|
|
|
|
await db.commit()
|
|
return {
|
|
"status": "success",
|
|
"deleted": deleted,
|
|
"file_errors": file_errors,
|
|
}
|