Files
bellsystems-cp/backend/crm/nextcloud_router.py

491 lines
18 KiB
Python

"""
Nextcloud WebDAV proxy endpoints.
Folder convention (all paths relative to nextcloud_base_path = BellSystems/Console):
customers/{folder_id}/media/
customers/{folder_id}/documents/
customers/{folder_id}/sent/
customers/{folder_id}/received/
folder_id = customer.folder_id if set, else customer.id (legacy fallback).
"""
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request
from fastapi.responses import StreamingResponse
from typing import Optional
from jose import JWTError
from auth.models import TokenPayload
from auth.dependencies import require_permission
from auth.utils import decode_access_token
from crm import nextcloud, service
from config import settings
from crm.models import MediaCreate, MediaDirection
from crm.thumbnails import generate_thumbnail
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
DIRECTION_MAP = {
"sent": MediaDirection.sent,
"received": MediaDirection.received,
"internal": MediaDirection.internal,
"media": MediaDirection.internal,
"documents": MediaDirection.internal,
}
@router.get("/web-url")
async def get_web_url(
path: str = Query(..., description="Path relative to nextcloud_base_path"),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""
Return the Nextcloud Files web-UI URL for a given file path.
Opens the parent folder with the file highlighted.
"""
if not settings.nextcloud_url:
raise HTTPException(status_code=503, detail="Nextcloud not configured")
base = settings.nextcloud_base_path.strip("/")
# path is relative to base, e.g. "customers/abc/media/photo.jpg"
parts = path.rsplit("/", 1)
folder_rel = parts[0] if len(parts) == 2 else ""
filename = parts[-1]
nc_dir = f"/{base}/{folder_rel}" if folder_rel else f"/{base}"
from urllib.parse import urlencode, quote
qs = urlencode({"dir": nc_dir, "scrollto": filename})
url = f"{settings.nextcloud_url.rstrip('/')}/index.php/apps/files/?{qs}"
return {"url": url}
@router.get("/browse")
async def browse(
path: str = Query(..., description="Path relative to nextcloud_base_path"),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""List immediate children of a Nextcloud folder."""
items = await nextcloud.list_folder(path)
return {"path": path, "items": items}
@router.get("/browse-all")
async def browse_all(
customer_id: str = Query(..., description="Customer ID"),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""
Recursively list ALL files for a customer across all subfolders and any depth.
Uses Depth:infinity (one WebDAV call) with automatic fallback to recursive Depth:1.
Each file item includes a 'subfolder' key derived from its path.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
all_files = await nextcloud.list_folder_recursive(base)
# Exclude _info.txt stubs — human-readable only, should never appear in the UI.
# .thumbs/ files are kept: the frontend needs them to build the thumbnail map
# (it already filters them out of the visible file list itself).
all_files = [
f for f in all_files
if not f["path"].endswith("/_info.txt")
]
# Tag each file with the top-level subfolder it lives under
for item in all_files:
parts = item["path"].split("/")
# path looks like: customers/{nc_path}/{subfolder}/[...]/filename
# parts[0]=customers, parts[1]={nc_path}, parts[2]={subfolder}
item["subfolder"] = parts[2] if len(parts) > 2 else "other"
return {"items": all_files}
@router.get("/file")
async def proxy_file(
request: Request,
path: str = Query(..., description="Path relative to nextcloud_base_path"),
token: Optional[str] = Query(None, description="JWT token for browser-native requests (img src, video src, a href) that cannot send an Authorization header"),
):
"""
Stream a file from Nextcloud through the backend (proxy).
Supports HTTP Range requests so videos can be seeked and start playing immediately.
Accepts auth via Authorization: Bearer header OR ?token= query param.
"""
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
try:
decode_access_token(token)
except (JWTError, KeyError):
raise HTTPException(status_code=403, detail="Invalid token")
# Forward the Range header to Nextcloud so we get a true partial response
# without buffering the whole file into memory.
nc_url = nextcloud._full_url(path)
nc_auth = nextcloud._auth()
forward_headers = {}
range_header = request.headers.get("range")
if range_header:
forward_headers["Range"] = range_header
import httpx as _httpx
# Use a dedicated streaming client — httpx.stream() keeps the connection open
# for the lifetime of the generator, so we can't reuse the shared persistent client.
# We enter the stream context here to get headers immediately (no body buffering),
# then hand the body iterator to StreamingResponse.
stream_client = _httpx.AsyncClient(timeout=None, follow_redirects=True)
nc_resp_ctx = stream_client.stream("GET", nc_url, auth=nc_auth, headers=forward_headers)
nc_resp = await nc_resp_ctx.__aenter__()
if nc_resp.status_code == 404:
await nc_resp_ctx.__aexit__(None, None, None)
await stream_client.aclose()
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
if nc_resp.status_code not in (200, 206):
await nc_resp_ctx.__aexit__(None, None, None)
await stream_client.aclose()
raise HTTPException(status_code=502, detail=f"Nextcloud returned {nc_resp.status_code}")
mime_type = nc_resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
resp_headers = {"Accept-Ranges": "bytes"}
for h in ("content-range", "content-length"):
if h in nc_resp.headers:
resp_headers[h.title()] = nc_resp.headers[h]
async def _stream():
try:
async for chunk in nc_resp.aiter_bytes(chunk_size=64 * 1024):
yield chunk
finally:
await nc_resp_ctx.__aexit__(None, None, None)
await stream_client.aclose()
return StreamingResponse(
_stream(),
status_code=nc_resp.status_code,
media_type=mime_type,
headers=resp_headers,
)
@router.put("/file-put")
async def put_file(
request: Request,
path: str = Query(..., description="Path relative to nextcloud_base_path"),
token: Optional[str] = Query(None),
):
"""
Overwrite a file in Nextcloud with a new body (used for TXT in-browser editing).
Auth via ?token= query param (same pattern as /file GET).
"""
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
try:
decode_access_token(token)
except (JWTError, KeyError):
raise HTTPException(status_code=403, detail="Invalid token")
body = await request.body()
content_type = request.headers.get("content-type", "text/plain")
await nextcloud.upload_file(path, body, content_type)
return {"updated": path}
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
customer_id: str = Form(...),
subfolder: str = Form("media"), # "media" | "documents" | "sent" | "received"
direction: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Upload a file to the customer's Nextcloud folder and record it in crm_media.
Uses the customer's folder_id as the NC path (falls back to UUID for legacy records).
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
target_folder = f"customers/{nc_path}/{subfolder}"
file_path = f"{target_folder}/{file.filename}"
# Ensure the target subfolder exists (idempotent, fast for existing folders)
await nextcloud.ensure_folder(target_folder)
# Read and upload
content = await file.read()
mime_type = file.content_type or "application/octet-stream"
await nextcloud.upload_file(file_path, content, mime_type)
# Generate and upload thumbnail (best-effort, non-blocking)
# Always stored as {stem}.jpg regardless of source extension so the thumb
# filename is unambiguous and the existence check can never false-positive.
thumb_path = None
try:
thumb_bytes = generate_thumbnail(content, mime_type, file.filename)
if thumb_bytes:
thumb_folder = f"{target_folder}/.thumbs"
stem = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
thumb_filename = f"{stem}.jpg"
thumb_nc_path = f"{thumb_folder}/{thumb_filename}"
await nextcloud.ensure_folder(thumb_folder)
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
thumb_path = thumb_nc_path
except Exception as e:
import logging
logging.getLogger(__name__).warning("Thumbnail generation failed for %s: %s", file.filename, e)
# Resolve direction
resolved_direction = None
if direction:
try:
resolved_direction = MediaDirection(direction)
except ValueError:
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
else:
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
# Save metadata record
tag_list = [t.strip() for t in tags.split(",")] if tags else []
media_record = await service.create_media(MediaCreate(
customer_id=customer_id,
filename=file.filename,
nextcloud_path=file_path,
mime_type=mime_type,
direction=resolved_direction,
tags=tag_list,
uploaded_by=_user.name,
thumbnail_path=thumb_path,
))
return media_record
@router.delete("/file")
async def delete_file(
path: str = Query(..., description="Path relative to nextcloud_base_path"),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""Delete a file from Nextcloud and remove the matching crm_media record if found."""
await nextcloud.delete_file(path)
# Best-effort: delete the DB record if one matches this path
media_list = await service.list_media()
for m in media_list:
if m.nextcloud_path == path:
try:
await service.delete_media(m.id)
except Exception:
pass
break
return {"deleted": path}
@router.post("/init-customer-folder")
async def init_customer_folder(
customer_id: str = Form(...),
customer_name: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Create the standard folder structure for a customer in Nextcloud
and write an _info.txt stub for human readability.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
for sub in ("media", "documents", "sent", "received"):
await nextcloud.ensure_folder(f"{base}/{sub}")
await nextcloud.write_info_file(base, customer_name, customer_id)
return {"initialized": base}
@router.post("/sync")
async def sync_nextcloud_files(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Scan the customer's Nextcloud folder and register any files not yet tracked in the DB.
Returns counts of newly synced and skipped (already tracked) files.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
# Collect all NC files recursively (handles nested folders at any depth)
all_nc_files = await nextcloud.list_folder_recursive(base)
# Skip .thumbs/ folder contents and the _info.txt stub — these are internal
all_nc_files = [
f for f in all_nc_files
if "/.thumbs/" not in f["path"] and not f["path"].endswith("/_info.txt")
]
for item in all_nc_files:
parts = item["path"].split("/")
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
# Get existing DB records for this customer
existing = await service.list_media(customer_id=customer_id)
tracked_paths = {m.nextcloud_path for m in existing}
synced = 0
skipped = 0
for f in all_nc_files:
if f["path"] in tracked_paths:
skipped += 1
continue
sub = f["_subfolder"]
direction = DIRECTION_MAP.get(sub, MediaDirection.internal)
await service.create_media(MediaCreate(
customer_id=customer_id,
filename=f["filename"],
nextcloud_path=f["path"],
mime_type=f.get("mime_type") or "application/octet-stream",
direction=direction,
tags=[],
uploaded_by="nextcloud-sync",
))
synced += 1
return {"synced": synced, "skipped": skipped}
@router.post("/generate-thumbs")
async def generate_thumbs(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Scan all customer files in Nextcloud and generate thumbnails for any file
that doesn't already have one in the corresponding .thumbs/ sub-folder.
Skips files inside .thumbs/ itself and file types that can't be thumbnailed.
Returns counts of generated, skipped (already exists), and failed files.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
all_nc_files = await nextcloud.list_folder_recursive(base)
# Build a set of existing thumb paths for O(1) lookup
existing_thumbs = {
f["path"] for f in all_nc_files if "/.thumbs/" in f["path"]
}
# Only process real files (not thumbs themselves)
candidates = [f for f in all_nc_files if "/.thumbs/" not in f["path"]]
generated = 0
skipped = 0
failed = 0
for f in candidates:
# Derive where the thumb would live
path = f["path"] # e.g. customers/{nc_path}/{subfolder}/photo.jpg
parts = path.rsplit("/", 1)
if len(parts) != 2:
skipped += 1
continue
parent_folder, filename = parts
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
thumb_filename = f"{stem}.jpg"
thumb_nc_path = f"{parent_folder}/.thumbs/{thumb_filename}"
if thumb_nc_path in existing_thumbs:
skipped += 1
continue
# Download the file, generate thumb, upload
try:
content, mime_type = await nextcloud.download_file(path)
thumb_bytes = generate_thumbnail(content, mime_type, filename)
if not thumb_bytes:
skipped += 1 # unsupported file type
continue
thumb_folder = f"{parent_folder}/.thumbs"
await nextcloud.ensure_folder(thumb_folder)
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
generated += 1
except Exception as e:
import logging
logging.getLogger(__name__).warning("Thumb gen failed for %s: %s", path, e)
failed += 1
return {"generated": generated, "skipped": skipped, "failed": failed}
@router.post("/clear-thumbs")
async def clear_thumbs(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Delete all .thumbs sub-folders for a customer across all subfolders.
This lets you regenerate thumbnails from scratch.
Returns count of .thumbs folders deleted.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
all_nc_files = await nextcloud.list_folder_recursive(base)
# Collect unique .thumbs folder paths
thumb_folders = set()
for f in all_nc_files:
if "/.thumbs/" in f["path"]:
folder = f["path"].split("/.thumbs/")[0] + "/.thumbs"
thumb_folders.add(folder)
deleted = 0
for folder in thumb_folders:
try:
await nextcloud.delete_file(folder)
deleted += 1
except Exception as e:
import logging
logging.getLogger(__name__).warning("Failed to delete .thumbs folder %s: %s", folder, e)
return {"deleted_folders": deleted}
@router.post("/untrack-deleted")
async def untrack_deleted_files(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Remove DB records for files that no longer exist in Nextcloud.
Returns count of untracked records.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
# Collect all NC file paths recursively (excluding thumbs and info stub)
all_nc_files = await nextcloud.list_folder_recursive(base)
nc_paths = {
item["path"] for item in all_nc_files
if "/.thumbs/" not in item["path"] and not item["path"].endswith("/_info.txt")
}
# Find DB records whose NC path no longer exists, OR that are internal files
# (_info.txt / .thumbs/) which should never have been tracked in the first place.
existing = await service.list_media(customer_id=customer_id)
untracked = 0
for m in existing:
is_internal = m.nextcloud_path and (
"/.thumbs/" in m.nextcloud_path or m.nextcloud_path.endswith("/_info.txt")
)
if m.nextcloud_path and (is_internal or m.nextcloud_path not in nc_paths):
try:
await service.delete_media(m.id)
untracked += 1
except Exception:
pass
return {"untracked": untracked}