From ff9be1249a0825c018b3946312c1925a5e68a3d9 Mon Sep 17 00:00:00 2001 From: M Dombaugh Date: Mon, 2 Mar 2026 23:58:23 +0000 Subject: [PATCH] File Sync and repoint to WebDAV folder --- docker-compose.yml | 8 +- routers/files.py | 227 ++++++++++++++++-- templates/file_preview.html | 7 +- templates/file_upload.html | 15 ++ templates/files.html | 26 +- .../Hetzner VM/lifeos-webdav-setup-guide.docx | Bin 0 -> 12909 bytes 6 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx diff --git a/docker-compose.yml b/docker-compose.yml index 767f8eb..708f404 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,13 +9,13 @@ services: restart: unless-stopped environment: DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod - FILE_STORAGE_PATH: /opt/lifeos/files/prod + FILE_STORAGE_PATH: /opt/lifeos/webdav ENVIRONMENT: production command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1 ports: - "8002:8002" volumes: - - /opt/lifeos/prod/files:/opt/lifeos/files/prod + - /opt/lifeos/webdav:/opt/lifeos/webdav networks: - lifeos_network depends_on: @@ -29,13 +29,13 @@ services: restart: unless-stopped environment: DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev - FILE_STORAGE_PATH: /opt/lifeos/files/dev + FILE_STORAGE_PATH: /opt/lifeos/webdav ENVIRONMENT: development command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload ports: - "8003:8003" volumes: - - /opt/lifeos/dev/files:/opt/lifeos/files/dev + - /opt/lifeos/webdav:/opt/lifeos/webdav - .:/app # hot reload in dev networks: - lifeos_network diff --git a/routers/files.py b/routers/files.py index 1a0c76d..834f541 100644 --- a/routers/files.py +++ b/routers/files.py @@ -1,12 +1,11 @@ -"""Files: upload, download, list, preview, and polymorphic entity attachment.""" +"""Files: upload, download, list, preview, folder-aware storage, and WebDAV sync.""" import os -import uuid -import shutil +import mimetypes from pathlib import Path from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile from fastapi.templating import Jinja2Templates -from fastapi.responses import RedirectResponse, FileResponse +from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional @@ -18,7 +17,7 @@ from core.sidebar import get_sidebar_data router = APIRouter(prefix="/files", tags=["files"]) templates = Jinja2Templates(directory="templates") -FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev") +FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/webdav") # Ensure storage dir exists Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True) @@ -29,16 +28,120 @@ PREVIEWABLE = { "application/pdf", "text/plain", "text/html", "text/csv", } +# Files to skip during sync +SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"} + + +def _resolve_path(item): + """Resolve a DB record's relative storage_path to an absolute path.""" + return os.path.join(FILE_STORAGE_PATH, item["storage_path"]) + + +def get_folders(): + """Walk FILE_STORAGE_PATH and return sorted list of relative folder paths.""" + folders = [] + for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH): + # Skip hidden directories + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + rel = os.path.relpath(dirpath, FILE_STORAGE_PATH) + if rel != ".": + folders.append(rel) + return sorted(folders) + + +def resolve_collision(folder_abs, filename): + """If filename exists in folder_abs, return name (2).ext, name (3).ext, etc.""" + target = os.path.join(folder_abs, filename) + if not os.path.exists(target): + return filename + + name, ext = os.path.splitext(filename) + counter = 2 + while True: + candidate = f"{name} ({counter}){ext}" + if not os.path.exists(os.path.join(folder_abs, candidate)): + return candidate + counter += 1 + + +async def sync_files(db: AsyncSession): + """Sync filesystem state with the database. + + - Files on disk not in DB → create record + - Active DB records with missing files → soft-delete + Returns dict with added/removed counts. + """ + added = 0 + removed = 0 + + # Build set of all relative file paths on disk + disk_files = set() + for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + for fname in filenames: + if fname in SKIP_FILES or fname.startswith("."): + continue + abs_path = os.path.join(dirpath, fname) + rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH) + disk_files.add(rel_path) + + # Get ALL DB records (including soft-deleted) to avoid re-creating deleted files + result = await db.execute(text( + "SELECT id, storage_path, is_deleted FROM files" + )) + db_records = [dict(r._mapping) for r in result] + + # Build lookup sets + all_db_paths = {r["storage_path"] for r in db_records} + active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]} + + # New on disk, not in DB at all → create record + new_files = disk_files - all_db_paths + for rel_path in new_files: + abs_path = os.path.join(FILE_STORAGE_PATH, rel_path) + filename = os.path.basename(rel_path) + mime_type = mimetypes.guess_type(filename)[0] + try: + size_bytes = os.path.getsize(abs_path) + except OSError: + continue + + repo = BaseRepository("files", db) + await repo.create({ + "filename": filename, + "original_filename": filename, + "storage_path": rel_path, + "mime_type": mime_type, + "size_bytes": size_bytes, + }) + added += 1 + + # Active in DB but missing from disk → soft-delete + missing_files = active_db_paths - disk_files + for record in db_records: + if record["storage_path"] in missing_files and not record["is_deleted"]: + repo = BaseRepository("files", db) + await repo.soft_delete(record["id"]) + removed += 1 + + return {"added": added, "removed": removed} + @router.get("/") async def list_files( request: Request, + folder: Optional[str] = None, context_type: Optional[str] = None, context_id: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) + # Auto-sync on page load + sync_result = await sync_files(db) + + folders = get_folders() + if context_type and context_id: # Files attached to a specific entity result = await db.execute(text(""" @@ -48,19 +151,40 @@ async def list_files( WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid ORDER BY f.created_at DESC """), {"ct": context_type, "cid": context_id}) + elif folder is not None: + if folder == "": + # Root folder: files with no directory separator in storage_path + result = await db.execute(text(""" + SELECT * FROM files + WHERE is_deleted = false AND storage_path NOT LIKE '%/%' + ORDER BY created_at DESC + """)) + else: + # Specific folder: storage_path starts with folder/ + result = await db.execute(text(""" + SELECT * FROM files + WHERE is_deleted = false AND storage_path LIKE :prefix + ORDER BY created_at DESC + """), {"prefix": folder + "/%"}) else: # All files result = await db.execute(text(""" - SELECT f.* FROM files f - WHERE f.is_deleted = false - ORDER BY f.created_at DESC - LIMIT 100 + SELECT * FROM files + WHERE is_deleted = false + ORDER BY created_at DESC """)) items = [dict(r._mapping) for r in result] + # Add derived folder field for display + for item in items: + dirname = os.path.dirname(item["storage_path"]) + item["folder"] = dirname if dirname else "/" + return templates.TemplateResponse("files.html", { "request": request, "sidebar": sidebar, "items": items, + "folders": folders, "current_folder": folder, + "sync_result": sync_result, "context_type": context_type or "", "context_id": context_id or "", "page_title": "Files", "active_nav": "files", @@ -70,13 +194,16 @@ async def list_files( @router.get("/upload") async def upload_form( request: Request, + folder: Optional[str] = None, context_type: Optional[str] = None, context_id: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) + folders = get_folders() return templates.TemplateResponse("file_upload.html", { "request": request, "sidebar": sidebar, + "folders": folders, "prefill_folder": folder or "", "context_type": context_type or "", "context_id": context_id or "", "page_title": "Upload File", "active_nav": "files", @@ -89,19 +216,41 @@ async def upload_file( file: UploadFile = FastAPIFile(...), description: Optional[str] = Form(None), tags: Optional[str] = Form(None), + folder: Optional[str] = Form(None), + new_folder: Optional[str] = Form(None), context_type: Optional[str] = Form(None), context_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): - # Generate storage filename - file_uuid = str(uuid.uuid4()) + # Determine target folder + target_folder = "" + if new_folder and new_folder.strip(): + target_folder = new_folder.strip().strip("/") + elif folder and folder.strip(): + target_folder = folder.strip() + + # Build absolute folder path and ensure it exists + if target_folder: + folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder) + else: + folder_abs = FILE_STORAGE_PATH + os.makedirs(folder_abs, exist_ok=True) + + # Use original filename, handle collisions original = file.filename or "unknown" safe_name = original.replace("/", "_").replace("\\", "_") - storage_name = f"{file_uuid}_{safe_name}" - storage_path = os.path.join(FILE_STORAGE_PATH, storage_name) + final_name = resolve_collision(folder_abs, safe_name) + + # Build relative storage path + if target_folder: + storage_path = os.path.join(target_folder, final_name) + else: + storage_path = final_name + + abs_path = os.path.join(FILE_STORAGE_PATH, storage_path) # Save to disk - with open(storage_path, "wb") as f: + with open(abs_path, "wb") as f: content = await file.read() f.write(content) @@ -110,7 +259,7 @@ async def upload_file( # Insert file record repo = BaseRepository("files", db) data = { - "filename": storage_name, + "filename": final_name, "original_filename": original, "storage_path": storage_path, "mime_type": file.content_type, @@ -139,15 +288,26 @@ async def upload_file( return RedirectResponse(url="/files", status_code=303) +@router.post("/sync") +async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)): + """Manual sync trigger.""" + await sync_files(db) + return RedirectResponse(url="/files", status_code=303) + + @router.get("/{file_id}/download") async def download_file(file_id: str, db: AsyncSession = Depends(get_db)): repo = BaseRepository("files", db) item = await repo.get(file_id) - if not item or not os.path.exists(item["storage_path"]): + if not item: + return RedirectResponse(url="/files", status_code=303) + + abs_path = _resolve_path(item) + if not os.path.exists(abs_path): return RedirectResponse(url="/files", status_code=303) return FileResponse( - path=item["storage_path"], + path=abs_path, filename=item["original_filename"], media_type=item.get("mime_type") or "application/octet-stream", ) @@ -163,10 +323,12 @@ async def preview_file(file_id: str, request: Request, db: AsyncSession = Depend return RedirectResponse(url="/files", status_code=303) can_preview = item.get("mime_type", "") in PREVIEWABLE + folder = os.path.dirname(item["storage_path"]) return templates.TemplateResponse("file_preview.html", { "request": request, "sidebar": sidebar, "item": item, "can_preview": can_preview, + "folder": folder if folder else "/", "page_title": item["original_filename"], "active_nav": "files", }) @@ -176,13 +338,34 @@ async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)): """Serve file inline (for img src, iframe, etc).""" repo = BaseRepository("files", db) item = await repo.get(file_id) - if not item or not os.path.exists(item["storage_path"]): + if not item: return RedirectResponse(url="/files", status_code=303) - return FileResponse( - path=item["storage_path"], - media_type=item.get("mime_type") or "application/octet-stream", - ) + abs_path = _resolve_path(item) + if not os.path.exists(abs_path): + return RedirectResponse(url="/files", status_code=303) + + mime = item.get("mime_type") or "application/octet-stream" + + # Wrap text files in HTML with forced white background / dark text + if mime.startswith("text/"): + try: + with open(abs_path, "r", errors="replace") as f: + text_content = f.read() + except Exception: + return FileResponse(path=abs_path, media_type=mime) + + from html import escape + html = ( + '' + '' + f'{escape(text_content)}' + ) + return HTMLResponse(content=html) + + return FileResponse(path=abs_path, media_type=mime) @router.post("/{file_id}/delete") diff --git a/templates/file_preview.html b/templates/file_preview.html index 066fa8d..90044ab 100644 --- a/templates/file_preview.html +++ b/templates/file_preview.html @@ -3,6 +3,10 @@ @@ -17,6 +21,7 @@
+ Folder: {{ folder }} {% if item.mime_type %}Type: {{ item.mime_type }}{% endif %} {% if item.size_bytes %}Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %} {% if item.description %}{{ item.description }}{% endif %} @@ -34,7 +39,7 @@ {% elif item.mime_type == 'application/pdf' %} {% elif item.mime_type and item.mime_type.startswith('text/') %} - + {% endif %}
{% else %} diff --git a/templates/file_upload.html b/templates/file_upload.html index 64f3fac..7a0aee9 100644 --- a/templates/file_upload.html +++ b/templates/file_upload.html @@ -17,6 +17,21 @@ +
+ + +
+ +
+ + +
+
diff --git a/templates/files.html b/templates/files.html index 57351f7..29a1668 100644 --- a/templates/files.html +++ b/templates/files.html @@ -2,9 +2,30 @@ {% block content %} +{% if sync_result and (sync_result.added > 0 or sync_result.removed > 0) %} +
+ Synced: {{ sync_result.added }} file{{ 's' if sync_result.added != 1 }} added, {{ sync_result.removed }} removed +
+{% endif %} + +{% if folders %} +
+ All + / + {% for f in folders %} + {{ f }} + {% endfor %} +
+{% endif %} + {% if items %}
{% for item in items %} @@ -12,6 +33,7 @@ {{ item.original_filename }} + {{ item.folder }} {% if item.mime_type %} {{ item.mime_type.split('/')|last }} {% endif %} @@ -33,7 +55,7 @@ {% else %}
📁
-
No files uploaded yet
+
No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}
Upload First File
{% endif %} diff --git a/webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx b/webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx new file mode 100644 index 0000000000000000000000000000000000000000..9adbd17f5c3dcae9317d3c8dc51371d275436135 GIT binary patch literal 12909 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 N0{B(?RNw#n{{R5ty$JvS literal 0 HcmV?d00001