491 lines
18 KiB
Python
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}
|