From 8813e4e8f392927d55da7de9a09413460e484d0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:07:55 +0000 Subject: [PATCH 1/4] Initial plan From 16e7b6478e0b2eb22c13ac757a50fd85ff86d2a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:15:27 +0000 Subject: [PATCH 2/4] feat(admin): add admin-only file manager, admin menu, de-emphasize status, improve dashboard Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/views/__init__.py | 2 + app/views/filemanager.py | 147 ++++++++++++++++ frontend/static/js/common.js | 12 ++ frontend/templates/base.html | 74 +++++++- frontend/templates/filemanager.html | 125 ++++++++++++++ frontend/templates/index.html | 251 +++++++++++++++------------- 6 files changed, 492 insertions(+), 119 deletions(-) create mode 100644 app/views/filemanager.py create mode 100644 frontend/templates/filemanager.html diff --git a/app/views/__init__.py b/app/views/__init__.py index bf263567..12057a41 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -5,6 +5,7 @@ Aggregated view routers for the application. from fastapi import APIRouter from app.views.dropbox import router as dropbox_router +from app.views.filemanager import router as filemanager_router # Import all the view routers from app.views.general import router as general_router @@ -25,3 +26,4 @@ router.include_router(dropbox_router) router.include_router(google_drive_router) router.include_router(license_router) # Include the license router router.include_router(settings_router) +router.include_router(filemanager_router) diff --git a/app/views/filemanager.py b/app/views/filemanager.py new file mode 100644 index 00000000..61c85828 --- /dev/null +++ b/app/views/filemanager.py @@ -0,0 +1,147 @@ +""" +Admin-only file manager view for browsing the workdir directory. +""" + +import logging +import mimetypes +import os +from datetime import datetime +from pathlib import Path + +from fastapi import Request +from fastapi.responses import FileResponse + +from app.config import settings +from app.views.base import APIRouter, require_login, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + +_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] + + +def _format_size(size_bytes: int) -> str: + """Format file size in human-readable form.""" + size = float(size_bytes) + for unit in _SIZE_UNITS: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} PB" + + +def _safe_path(workdir: str, rel_path: str) -> Path: + """ + Resolve a relative path inside workdir safely. + + Raises ValueError if the resolved path escapes workdir. + """ + base = Path(workdir).resolve() + target = (base / rel_path).resolve() + if not str(target).startswith(str(base)): + raise ValueError("Path traversal detected") + return target + + +@router.get("/admin/files") +@require_login +@require_admin_access +async def filemanager(request: Request): + """ + Admin-only file manager for browsing the workdir directory. + """ + workdir = settings.workdir + rel_path = request.query_params.get("path", "") + + # Sanitise the relative path – strip leading slashes / dots + rel_path = rel_path.lstrip("/").lstrip(".") + + try: + target = _safe_path(workdir, rel_path) + except ValueError: + logger.warning(f"Path traversal attempt blocked: path={rel_path!r}") + target = Path(workdir).resolve() + rel_path = "" + + if not target.exists(): + target = Path(workdir).resolve() + rel_path = "" + + entries = [] + if target.is_dir(): + for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + try: + stat = item.stat() + item_rel = str(item.relative_to(Path(workdir).resolve())) + mime_type, _ = mimetypes.guess_type(item.name) + entries.append( + { + "name": item.name, + "rel_path": item_rel, + "is_dir": item.is_dir(), + "size": _format_size(stat.st_size) if not item.is_dir() else "", + "size_bytes": stat.st_size if not item.is_dir() else 0, + "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"), + "mime_type": mime_type or "", + } + ) + except (PermissionError, OSError) as exc: + logger.warning(f"Could not stat {item}: {exc}") + + # Build breadcrumb trail + breadcrumbs = [] + if rel_path: + parts = Path(rel_path).parts + accumulated = "" + for part in parts: + accumulated = str(Path(accumulated) / part) if accumulated else part + breadcrumbs.append({"name": part, "path": accumulated}) + + # Parent path for the "go up" link + parent_path = str(Path(rel_path).parent) if rel_path and Path(rel_path).parent != Path(".") else "" + if parent_path == ".": + parent_path = "" + + return templates.TemplateResponse( + "filemanager.html", + { + "request": request, + "entries": entries, + "current_path": rel_path, + "parent_path": parent_path, + "breadcrumbs": breadcrumbs, + "workdir": workdir, + "app_version": settings.version, + }, + ) + + +@router.get("/admin/files/download") +@require_login +@require_admin_access +async def filemanager_download(request: Request): + """ + Admin-only endpoint to download a file from workdir. + """ + workdir = settings.workdir + rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".") + + try: + target = _safe_path(workdir, rel_path) + except ValueError: + from fastapi import HTTPException + + raise HTTPException(status_code=400, detail="Invalid path") + + if not target.exists() or not target.is_file(): + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="File not found") + + mime_type, _ = mimetypes.guess_type(target.name) + return FileResponse( + path=str(target), + filename=target.name, + media_type=mime_type or "application/octet-stream", + ) diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 2244ee8e..0d906055 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -16,6 +16,18 @@ // Get the display name (prefer name, fall back to preferred_username, then email) const displayName = data.name || data.preferred_username || data.email; + // Show admin menu items if the user is an admin + if (data.is_admin) { + const adminMenuContainer = document.getElementById("adminMenuContainer"); + if (adminMenuContainer) { + adminMenuContainer.classList.remove("hidden"); + } + const mobileAdminSection = document.getElementById("mobileAdminSection"); + if (mobileAdminSection) { + mobileAdminSection.classList.remove("hidden"); + } + } + // User is logged in - use DOM API to prevent XSS if (authSection) { authSection.textContent = ''; // Clear existing content diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 4ab0505c..7be7e0cd 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -41,13 +41,55 @@ -
+ Browsing: {{ workdir }}
+ {% if current_path %}/ {{ current_path }}{% endif %}
+
| + | Name | + + +Actions | +||
|---|---|---|---|---|
| + | + + .. + + | ++ | ||
| + {% if entry.is_dir %} + + {% elif entry.mime_type.startswith('image') %} + + {% elif entry.mime_type == 'application/pdf' %} + + {% elif 'text' in entry.mime_type %} + + {% elif 'json' in entry.mime_type %} + + {% else %} + + {% endif %} + | ++ {% if entry.is_dir %} + + {{ entry.name }} + + {% else %} + {{ entry.name }} + {% endif %} + | + + ++ {% if not entry.is_dir %} + + Download + + {% endif %} + | +||
| + + This directory is empty. + | +||||
- Your intelligent solution for processing, managing, and organizing documents effortlessly. -
- -{{ stats.processed_files|default('0') }}
-Documents Processed
+ +Intelligent document processing & management
{{ stats.active_integrations|default('0') }}
-Active Integrations
-- Upload and process documents with OCR, metadata extraction, and intelligent classification. -
- - Start processing → - +{{ stats.processed_files | default(0) }}
+Documents Processed
+{{ stats.active_integrations | default(0) }}
+Active Integrations
+{{ stats.storage_targets | default(0) }}
+Storage Targets
+- Connect to Dropbox, NextCloud, OneDrive and other storage solutions seamlessly. -
- - Configure storage → - + +- Leverage AI to extract metadata, classify documents, and organize your information. -
- - View processed files → - -- Auto-process documents received via email with our IMAP polling capabilities. -
- - Set up email → - -- Create workflows to automatically process and route documents based on content. -
- - Learn more → - -- Your documents are processed securely with privacy-focused design principles. -
- - Privacy policy → + + Learn more about DocuElevateUpload your first document and see DocuElevate in action.
-
- Browsing: {{ workdir }}
- {% if current_path %}/ {{ current_path }}{% endif %}
-
+ {{ workdir }}
+
| - | Name | - - -Actions | ++ | Name | + + +DB | +Actions | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| - | - + | + | + .. | -+ | ||||||
| + {% if fs_entries %} + {% for entry in fs_entries %} + | ||||||||||
| + | {% if entry.is_dir %} - - {% elif entry.mime_type.startswith('image') %} - - {% elif entry.mime_type == 'application/pdf' %} - - {% elif 'text' in entry.mime_type %} - - {% elif 'json' in entry.mime_type %} - - {% else %} - - {% endif %} - | -- {% if entry.is_dir %} - + {{ entry.name }} {% else %} - {{ entry.name }} + {{ entry.name }} {% endif %} | - + ++ {% if entry.db_status == 'in_db' %} + + In DB + + {% elif entry.db_status == 'orphan' %} + + Orphan + + {% endif %} | - -+ | {% if not entry.is_dir %} - Download + class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50"> + Download {% endif %} | @@ -111,8 +153,8 @@ {% endfor %} {% else %}|||||
| - + | + This directory is empty. | |||||||||
| ID | +Original Filename | + + +local_filename | +original_file_path | +processed_file_path | +Health | +
|---|---|---|---|---|---|
| {{ rec.id }} | ++ {% if rec.is_duplicate %} + dup + {% endif %} + {{ rec.original_filename }} + | + + + + {# Helper macro: render a path cell #} + {% for path_info in [rec.local, rec.original, rec.processed] %} ++ {% if path_info.path is none %} + — + {% elif path_info.exists %} + + + {{ path_info.rel }} + + {% else %} + + + {{ path_info.rel }} + + {% endif %} + | + {% endfor %} + ++ {% if rec.health == 'ok' %} + + OK + + {% else %} + + Missing + + {% endif %} + | +
| + | Path (relative to workdir) | + + +Actions | +
|---|---|---|
| + | {{ f.rel_path }} | + + ++ + Download + + | +
No orphan files found.
+ {% endif %} +| ID | +Original Filename | + +Missing paths | +
|---|---|---|
| {{ rec.id }} | ++ {{ rec.original_filename }} + | + +
+ {% for path_info in [rec.local, rec.original, rec.processed] %}
+ {% if path_info.exists is sameas false %}
+
+
+ {{ path_info.rel }}
+
+ {% endif %}
+ {% endfor %}
+ |
+
No ghost records found.
+ {% endif %} +