306 lines
11 KiB
Python
306 lines
11 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 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 crm.models import MediaCreate, MediaDirection
|
|
|
|
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("/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)
|
|
|
|
# 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")
|
|
|
|
content, mime_type = await nextcloud.download_file(path)
|
|
total = len(content)
|
|
|
|
range_header = request.headers.get("range")
|
|
if range_header and range_header.startswith("bytes="):
|
|
# Parse "bytes=start-end"
|
|
try:
|
|
range_spec = range_header[6:]
|
|
start_str, _, end_str = range_spec.partition("-")
|
|
start = int(start_str) if start_str else 0
|
|
end = int(end_str) if end_str else total - 1
|
|
end = min(end, total - 1)
|
|
chunk = content[start:end + 1]
|
|
headers = {
|
|
"Content-Range": f"bytes {start}-{end}/{total}",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(len(chunk)),
|
|
"Content-Type": mime_type,
|
|
}
|
|
return Response(content=chunk, status_code=206, headers=headers, media_type=mime_type)
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
return Response(
|
|
content=content,
|
|
media_type=mime_type,
|
|
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
# 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,
|
|
))
|
|
|
|
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)
|
|
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("/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
|
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
|
nc_paths = {item["path"] for item in all_nc_files}
|
|
|
|
# Find DB records whose NC path no longer exists
|
|
existing = await service.list_media(customer_id=customer_id)
|
|
untracked = 0
|
|
for m in existing:
|
|
if m.nextcloud_path and m.nextcloud_path not in nc_paths:
|
|
try:
|
|
await service.delete_media(m.id)
|
|
untracked += 1
|
|
except Exception:
|
|
pass
|
|
|
|
return {"untracked": untracked}
|