diff --git a/backend/api/admin.py b/backend/api/admin.py index 7818db7..fc4032d 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -29,7 +29,7 @@ from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, EmailStr, Field, field_validator from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -141,7 +141,7 @@ class SystemTopicCreate(BaseModel): class UserDeleteConfirm(BaseModel): """Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01).""" - admin_password: str + admin_password: str = Field(..., min_length=1) # ── SEC-08: Safe CloudConnection response model ─────────────────────────────── @@ -162,6 +162,8 @@ class CloudConnectionOut(BaseModel): display_name: str status: str connected_at: datetime + server_url: Optional[str] = None + connection_username: Optional[str] = None model_config = {"from_attributes": True} @field_validator("id", mode="before") diff --git a/backend/api/documents.py b/backend/api/documents.py index 491e980..b759065 100644 --- a/backend/api/documents.py +++ b/backend/api/documents.py @@ -27,12 +27,12 @@ from typing import Optional from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File, status from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select, text, func from sqlalchemy.ext.asyncio import AsyncSession from config import settings -from db.models import CloudConnection, Document, Quota, Share, User +from db.models import CloudConnection, Document, Folder, Quota, Share, User from deps.auth import get_regular_user from deps.db import get_db from services import classifier, storage @@ -78,9 +78,16 @@ class DocumentPatch(BaseModel): T-05-09-01: explicit field declaration prevents mass assignment. T-05-09-02: only filename and folder_id are accepted — no other fields can be set. """ - filename: Optional[str] = None + filename: Optional[str] = Field(None, min_length=1, max_length=255) folder_id: Optional[uuid.UUID] = None + @field_validator("filename") + @classmethod + def filename_no_path_separators(cls, v: Optional[str]) -> Optional[str]: + if v is not None and ("/" in v or "\\" in v): + raise ValueError("filename must not contain path separators") + return v + # ── POST /api/documents/upload-url ─────────────────────────────────────────── @@ -129,6 +136,7 @@ async def request_upload_url( async def upload_document( file: UploadFile = File(...), target_backend: str = Form("minio"), + cloud_folder_path: str = Form(None), request: Request = None, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), @@ -238,6 +246,8 @@ async def upload_document( file_bytes, extension, content_type, + cloud_folder=cloud_folder_path or None, + original_filename=filename if cloud_folder_path else None, ) except CloudConnectionError as exc: raise HTTPException( @@ -245,6 +255,11 @@ async def upload_document( detail="Cloud connection requires re-authentication. Please reconnect in Settings.", ) from exc + # Bust folder listing cache so the next GET /folders reflects the new file + if cloud_folder_path: + from services.cloud_cache import invalidate_provider_cache # lazy import + invalidate_provider_cache(str(current_user.id), target_backend) + doc = Document( id=doc_id, user_id=current_user.id, @@ -570,6 +585,10 @@ async def patch_document( if "folder_id" in body.model_fields_set: # folder_id=null → move to root (no folder); folder_id= → move to folder + if body.folder_id is not None: + target = await session.get(Folder, body.folder_id) + if target is None or target.user_id != current_user.id: + raise HTTPException(404, "Folder not found") doc.folder_id = body.folder_id await session.commit() diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 0d9b5b8..80598e1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -96,6 +96,14 @@ export function confirmUpload(documentId) { return request(`/api/documents/${documentId}/confirm`, { method: 'POST' }) } +export function uploadToCloud(file, provider, folderPath) { + const form = new FormData() + form.append('file', file) + form.append('target_backend', provider) + if (folderPath) form.append('cloud_folder_path', folderPath) + return request('/api/documents/upload', { method: 'POST', body: form }) +} + // ── Topics ─────────────────────────────────────────────────────────────────── export function listTopics() { @@ -413,6 +421,9 @@ export async function fetchDocumentContent(docId, options = {}) { } } + if (!res.ok) { + throw new Error(`Failed to fetch document content: ${res.status}`) + } return res } diff --git a/frontend/src/views/DocumentView.vue b/frontend/src/views/DocumentView.vue index 8e1fe80..5eb594f 100644 --- a/frontend/src/views/DocumentView.vue +++ b/frontend/src/views/DocumentView.vue @@ -173,9 +173,15 @@ async function openPdf() { } const blob = await res.blob() const objectUrl = URL.createObjectURL(blob) - window.open(objectUrl, '_blank') - // Revoke after a delay to allow the new tab to load the content - setTimeout(() => URL.revokeObjectURL(objectUrl), 60000) + const tab = window.open(objectUrl, '_blank') + // Revoke once the new tab has loaded, or after 120s as a fallback. + // Also revoke if the user navigates away from this page before the tab loads. + const revoke = () => URL.revokeObjectURL(objectUrl) + const timer = setTimeout(revoke, 120000) + window.addEventListener('pagehide', revoke, { once: true }) + if (tab) { + tab.addEventListener('load', () => { clearTimeout(timer); revoke() }, { once: true }) + } } catch (err) { console.error('Failed to open document:', err) }