Merge pull request #17 from christianlouis/auth-for-ui
added Authentik config and complete UI for that project
This commit is contained in:
@@ -44,4 +44,11 @@ IMAP2_SSL=true
|
||||
IMAP2_POLL_INTERVAL_MINUTES=10
|
||||
IMAP2_DELETE_AFTER_PROCESS=false
|
||||
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
# ** needed for Authentik **
|
||||
AUTH_ENABLED=true
|
||||
SESSION_SECRET=<atLeast32Characters>
|
||||
AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID>
|
||||
AUTHENTIK_CLIENT_SECRET=<yourAuthentikAppClientSecret>
|
||||
AUTHENTIK_CONFIG_URL=<ConfigUrlOfYourApp, e.g. https://authentik.example.com/application/o/document-parser/.well-known/openid-configuration>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Request, HTTPException, status
|
||||
from hashlib import md5
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/whoami")
|
||||
async def whoami(request: Request):
|
||||
"""
|
||||
Returns user info if logged in, else 401.
|
||||
Example response:
|
||||
{
|
||||
"email": "someone@example.com",
|
||||
"picture": "https://www.gravatar.com/avatar/..."
|
||||
}
|
||||
"""
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not logged in")
|
||||
|
||||
email = user.get("email")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="User has no email in session")
|
||||
|
||||
# Generate Gravatar URL from email
|
||||
# For more options, see: https://en.gravatar.com/site/implement/images/
|
||||
email_hash = md5(email.strip().lower().encode()).hexdigest()
|
||||
gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"picture": gravatar_url
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# app/auth.py
|
||||
# app/auth.py
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from starlette.config import Config
|
||||
from fastapi import APIRouter, Request, status
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
config = Config(".env")
|
||||
oauth = OAuth(config)
|
||||
|
||||
AUTH_ENABLED = config("AUTH_ENABLED", cast=bool, default=True)
|
||||
|
||||
if AUTH_ENABLED:
|
||||
oauth.register(
|
||||
name="authentik",
|
||||
client_id=config("AUTHENTIK_CLIENT_ID"),
|
||||
client_secret=config("AUTHENTIK_CLIENT_SECRET"),
|
||||
server_metadata_url=config("AUTHENTIK_CONFIG_URL"),
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def require_login(func):
|
||||
if not AUTH_ENABLED:
|
||||
return func # no-op
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
if not request.session.get("user"):
|
||||
request.session["redirect_after_login"] = str(request.url)
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
return await func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
if AUTH_ENABLED:
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
redirect_uri = request.url_for("auth")
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
@router.get("/auth")
|
||||
async def auth(request: Request):
|
||||
token = await oauth.authentik.authorize_access_token(request)
|
||||
userinfo = token.get("userinfo")
|
||||
request.session["user"] = dict(userinfo)
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.pop("user", None)
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
@router.get("/private")
|
||||
@require_login
|
||||
async def private_page(request: Request):
|
||||
"""A protected endpoint that requires login."""
|
||||
user = request.session.get("user") # e.g. {"email": "...", ...}
|
||||
return {"message": f"This is a protected page. Hello {user['email']}!"}
|
||||
+20
-5
@@ -1,7 +1,8 @@
|
||||
# app/frontend.py (new file or inline in main.py)
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.auth import require_login
|
||||
import os
|
||||
|
||||
router = APIRouter()
|
||||
@@ -14,11 +15,25 @@ frontend_folder = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
router.mount("/static", StaticFiles(directory=frontend_folder), name="static")
|
||||
|
||||
# 2) For the root route ("/"), return the index.html
|
||||
@router.get("/ui", response_class=FileResponse)
|
||||
def serve_ui():
|
||||
return os.path.join(frontend_folder, "index.html")
|
||||
|
||||
@router.get("/upload", response_class=FileResponse)
|
||||
@require_login
|
||||
async def serve_upload(request: Request):
|
||||
return os.path.join(frontend_folder, "upload.html")
|
||||
|
||||
# 3) Serve favicon.ico from the frontend folder
|
||||
@router.get("/favicon.ico", response_class=FileResponse)
|
||||
def favicon():
|
||||
return os.path.join(frontend_folder, "favicon.ico")
|
||||
return os.path.join(frontend_folder, "favicon.ico")
|
||||
|
||||
""" @router.exception_handler(404)
|
||||
async def custom_404_handler(request: Request, exc):
|
||||
return FileResponse("frontend/404.html", status_code=status.HTTP_404_NOT_FOUND) """
|
||||
|
||||
@router.get("/", response_class=FileResponse)
|
||||
async def serve_index(request: Request):
|
||||
return os.path.join(frontend_folder, "index.html")
|
||||
|
||||
@router.get("/about", response_class=FileResponse)
|
||||
async def serve_about(request: Request):
|
||||
return os.path.join(frontend_folder, "about.html")
|
||||
+72
-30
@@ -1,7 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File, status, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.config import Config
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from app.database import init_db
|
||||
from app.config import settings
|
||||
from app.tasks.upload_to_s3 import upload_to_s3
|
||||
@@ -9,17 +14,42 @@ from app.tasks.upload_to_dropbox import upload_to_dropbox
|
||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||
from app.tasks.send_to_all import send_to_all_destinations
|
||||
from pathlib import Path
|
||||
|
||||
from app.api import router as api_router
|
||||
from app.frontend import router as frontend_router
|
||||
from app.auth import router as auth_router
|
||||
|
||||
|
||||
# Load configuration from .env for the session key
|
||||
config = Config(".env")
|
||||
SESSION_SECRET = config(
|
||||
"SESSION_SECRET",
|
||||
default="YOUR_DEFAULT_SESSION_SECRET_MUST_BE_32_CHARS_OR_MORE"
|
||||
)
|
||||
|
||||
app = FastAPI(title="Document Processing API")
|
||||
|
||||
|
||||
# 1) Session Middleware (for request.session to work)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
||||
|
||||
# 2) Respect the X-Forwarded-* headers from Traefik
|
||||
# so your request.url_for(...) uses https://docparse.hosterra.net
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
# 3) (Optional but recommended) Restrict valid hosts:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[
|
||||
"docparse.hosterra.net",
|
||||
"localhost",
|
||||
"127.0.0.1"
|
||||
])
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db() # Create tables if they don't exist
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Document Processing API"}
|
||||
|
||||
|
||||
@app.post("/process/")
|
||||
def process(file_path: str):
|
||||
@@ -27,13 +57,13 @@ def process(file_path: str):
|
||||
API Endpoint to start document processing.
|
||||
This enqueues the first task (upload_to_s3), which handles the full pipeline.
|
||||
"""
|
||||
|
||||
# If file_path is not absolute, treat it as relative to settings.workdir.
|
||||
if not os.path.isabs(file_path):
|
||||
file_path = os.path.join(settings.workdir, file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"File {file_path} not found."
|
||||
)
|
||||
|
||||
task = upload_to_s3.delay(file_path)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
@@ -43,7 +73,9 @@ def send_to_dropbox(file_path: str):
|
||||
if not os.path.isabs(file_path):
|
||||
file_path = os.path.join(settings.workdir, 'processed', file_path)
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"File {file_path} not found."
|
||||
)
|
||||
task = upload_to_dropbox.delay(file_path)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
@@ -52,7 +84,9 @@ def send_to_paperless(file_path: str):
|
||||
if not os.path.isabs(file_path):
|
||||
file_path = os.path.join(settings.workdir, 'processed', file_path)
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"File {file_path} not found."
|
||||
)
|
||||
task = upload_to_paperless.delay(file_path)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
@@ -61,40 +95,38 @@ def send_to_nextcloud(file_path: str):
|
||||
if not os.path.isabs(file_path):
|
||||
file_path = os.path.join(settings.workdir, 'processed', file_path)
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"File {file_path} not found."
|
||||
)
|
||||
task = upload_to_nextcloud.delay(file_path)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@app.post("/send_to_all_destinations/")
|
||||
def send_to_all_destinations_endpoint(file_path: str):
|
||||
"""
|
||||
Call the aggregator task that sends this file to dropbox, nextcloud, and paperless.
|
||||
"""
|
||||
if not os.path.isabs(file_path):
|
||||
# If not absolute, assume it's in processed subdir
|
||||
file_path = os.path.join(settings.workdir, 'processed', file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File {file_path} not found."
|
||||
status_code=400, detail=f"File {file_path} not found."
|
||||
)
|
||||
|
||||
task = send_to_all_destinations.delay(file_path)
|
||||
return {"task_id": task.id, "status": "queued", "file_path": file_path}
|
||||
|
||||
|
||||
|
||||
@app.post("/processall")
|
||||
def process_all_pdfs_in_workdir():
|
||||
"""
|
||||
Finds all .pdf files in <workdir>/processed
|
||||
and enqueues them for upload_to_s3.
|
||||
Finds all .pdf files in <workdir> and enqueues them for upload_to_s3.
|
||||
"""
|
||||
target_dir = settings.workdir
|
||||
if not os.path.exists(target_dir):
|
||||
raise HTTPException(status_code=400, detail=f"Directory {target_dir} does not exist.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Directory {target_dir} does not exist."
|
||||
)
|
||||
|
||||
pdf_files = []
|
||||
for filename in os.listdir(target_dir):
|
||||
@@ -102,13 +134,11 @@ def process_all_pdfs_in_workdir():
|
||||
pdf_files.append(filename)
|
||||
|
||||
if not pdf_files:
|
||||
return {"message": "No PDF files found in processed directory."}
|
||||
return {"message": "No PDF files found in that directory."}
|
||||
|
||||
task_ids = []
|
||||
for pdf in pdf_files:
|
||||
file_path = os.path.join(target_dir, pdf)
|
||||
# Enqueue upload_to_s3
|
||||
from app.tasks.upload_to_s3 import upload_to_s3
|
||||
task = upload_to_s3.delay(file_path)
|
||||
task_ids.append(task.id)
|
||||
|
||||
@@ -118,21 +148,33 @@ def process_all_pdfs_in_workdir():
|
||||
"task_ids": task_ids
|
||||
}
|
||||
|
||||
app.include_router(frontend_router)
|
||||
|
||||
@app.post("/ui-upload")
|
||||
async def ui_upload(file: UploadFile = File(...)):
|
||||
# You can store this file in your 'workdir' (like how /process does) or a tmp dir
|
||||
"""Endpoint to accept a user-uploaded file and enqueue it to S3."""
|
||||
workdir = "/workdir"
|
||||
target_path = os.path.join(workdir, file.filename)
|
||||
|
||||
try:
|
||||
with open(target_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save file: {e}"
|
||||
)
|
||||
|
||||
# Now you can call your existing Celery flow:
|
||||
task = upload_to_s3.delay(target_path)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def custom_404_handler(request: Request, exc: HTTPException):
|
||||
return FileResponse(
|
||||
"/app/frontend/404.html",
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Include the frontend and auth routers
|
||||
app.include_router(frontend_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
||||
working_dir: /workdir
|
||||
|
||||
# We'll run uvicorn from the container's /app code
|
||||
command: ["sh", "-c", "cd /app && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
command: ["sh", "-c", "cd /app && uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers"]
|
||||
|
||||
# Environment variables
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>404 - Oops, DocuNova Lost the Page</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 to-white min-h-screen flex flex-col">
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
|
||||
<!-- Brand -->
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="text-xl font-bold text-blue-500 hover:text-blue-700">
|
||||
DocuNova
|
||||
</a>
|
||||
</div>
|
||||
<!-- Menu Items -->
|
||||
<div class="flex space-x-6 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||
<!-- Dynamic Auth Section -->
|
||||
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-grow flex flex-col items-center justify-center text-center px-4">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="text-7xl font-extrabold text-blue-600 mb-6">404</h1>
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-4">Oops, we couldn’t find that page!</h2>
|
||||
<p class="text-gray-600 mb-8">
|
||||
It seems DocuNova has misplaced the document you were looking for. Whether it got lost in the cloud or hidden between the files, don’t worry – we’ve got your back.
|
||||
</p>
|
||||
<a href="/" class="inline-block bg-blue-500 hover:bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
|
||||
← Return Home
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
|
||||
© 2025 DocuNova. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript for dynamic auth section -->
|
||||
<script>
|
||||
(async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch("/api/whoami");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// data.picture is expected to be a Gravatar URL
|
||||
const authSection = document.getElementById("authSection");
|
||||
authSection.innerHTML = '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = data.picture;
|
||||
img.alt = 'User Avatar';
|
||||
img.className = 'inline-block h-8 w-8 rounded-full mr-2';
|
||||
|
||||
const textNode = document.createTextNode('Logged in as ');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = data.email;
|
||||
|
||||
const logoutLink = document.createElement('a');
|
||||
logoutLink.href = '/logout';
|
||||
logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800';
|
||||
logoutLink.textContent = 'Logout';
|
||||
|
||||
authSection.appendChild(img);
|
||||
authSection.appendChild(textNode);
|
||||
authSection.appendChild(strong);
|
||||
authSection.appendChild(logoutLink);
|
||||
} else {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>About DocuNova</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
|
||||
<!-- Brand -->
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="text-xl font-bold text-blue-500 hover:text-blue-700">
|
||||
DocuNova
|
||||
</a>
|
||||
</div>
|
||||
<!-- Menu items -->
|
||||
<div class="flex space-x-4 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||
<!-- Dynamic auth section -->
|
||||
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-grow container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold mb-4">About DocuNova</h1>
|
||||
<p class="text-gray-700 mb-6 leading-relaxed">
|
||||
Welcome to <strong>DocuNova</strong> – your modern, intelligent solution for document processing! We’ve built DocuNova to completely transform the way you handle your documents – from upload to extraction, from processing to storage. With cutting-edge technologies and a user-first design, DocuNova makes managing your documents as easy as a click.
|
||||
</p>
|
||||
|
||||
<!-- Our Story Section -->
|
||||
<section class="bg-white shadow rounded p-6 mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-2">Our Story</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
DocuNova was created with one goal in mind: to simplify and streamline document management for everyone, whether you’re a small startup or a large enterprise. Tired of clunky, outdated systems, we set out to design a platform that is intuitive, flexible, and packed with powerful features.
|
||||
</p>
|
||||
<p class="text-gray-600">
|
||||
We harness the power of OpenAI for metadata extraction and text refinement, integrate seamlessly with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions. And while we’ve implemented AWS S3 for now, we’re always evolving!
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Key Features Section -->
|
||||
<section class="bg-white shadow rounded p-6 mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-2">Key Features</h2>
|
||||
<ul class="list-disc list-inside text-gray-600">
|
||||
<li>Simple and secure file uploads with drag & drop support</li>
|
||||
<li>Automated metadata extraction, indexing, and version control</li>
|
||||
<li>Integration with popular cloud services and storage platforms</li>
|
||||
<li>OCR and intelligent document processing powered by AI</li>
|
||||
<li>IMAP integration for automated document fetching</li>
|
||||
<li>Highly configurable via environment variables for custom workflows</li>
|
||||
<li>Docker-ready for easy deployment and scalability</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Meet the Creator Section -->
|
||||
<section class="bg-white shadow rounded p-6 mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-2">Meet the Creator</h2>
|
||||
<p class="text-gray-600">
|
||||
DocuNova is passionately developed by <a href="https://www.christianlouis.de" target="_blank" class="text-blue-600 hover:underline">Christian Krakau-Louis</a>, a visionary committed to solving real-world challenges with innovative technology. His dedication and expertise ensure that every feature in DocuNova is crafted with you in mind.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- GitHub Link Section -->
|
||||
<section class="bg-white shadow rounded p-6">
|
||||
<h2 class="text-2xl font-semibold mb-2">Get Involved</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Want to dive into the code, contribute ideas, or simply check out the magic behind DocuNova? Visit our GitHub repository to see the project in action!
|
||||
</p>
|
||||
<a href="https://github.com/christianlouis/document-processor" target="_blank" class="flex items-center space-x-2 text-blue-600 hover:text-blue-800">
|
||||
<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" alt="GitHub Logo" class="h-6 w-6" />
|
||||
<span>View DocuNova on GitHub</span>
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
|
||||
© 2025 DocuNova. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript for dynamic auth section -->
|
||||
<script>
|
||||
(async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch("/api/whoami");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// data.picture is expected to be a Gravatar URL
|
||||
const authSection = document.getElementById("authSection");
|
||||
authSection.innerHTML = '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = data.picture;
|
||||
img.alt = 'User Avatar';
|
||||
img.className = 'inline-block h-8 w-8 rounded-full mr-2';
|
||||
|
||||
const textNode = document.createTextNode('Logged in as ');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = data.email;
|
||||
|
||||
const logoutLink = document.createElement('a');
|
||||
logoutLink.href = '/logout';
|
||||
logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800';
|
||||
logoutLink.textContent = 'Logout';
|
||||
|
||||
authSection.appendChild(img);
|
||||
authSection.appendChild(textNode);
|
||||
authSection.appendChild(strong);
|
||||
authSection.appendChild(logoutLink);
|
||||
} else {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+108
-89
@@ -1,89 +1,108 @@
|
||||
<!-- File: frontend/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Document Processor - Upload</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<script>
|
||||
// Optional: Additional JS for drag-and-drop, or you can keep it inline.
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 h-screen flex flex-col items-center justify-center">
|
||||
<h1 class="text-3xl font-bold mb-8">Upload a File</h1>
|
||||
|
||||
<div
|
||||
id="dropZone"
|
||||
class="border-4 border-dashed border-gray-300 rounded-lg p-8 bg-white text-center w-1/2"
|
||||
ondrop="handleDrop(event)"
|
||||
ondragover="handleDragOver(event)"
|
||||
>
|
||||
<p class="text-gray-500">
|
||||
Drag & drop a file here, or click to select a file.
|
||||
</p>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange="handleFileSelect(event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="mt-4 text-gray-700"></div>
|
||||
|
||||
<script>
|
||||
const dropZone = document.getElementById("dropZone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const statusMessage = document.getElementById("statusMessage");
|
||||
|
||||
dropZone.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
dropZone.classList.add("bg-gray-100");
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("bg-gray-100");
|
||||
if (e.dataTransfer.files.length) {
|
||||
uploadFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
if (e.target.files.length) {
|
||||
uploadFile(e.target.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
statusMessage.textContent = `Uploading ${file.name}...`;
|
||||
|
||||
try {
|
||||
// We'll POST the file to /upload
|
||||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/ui-upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
statusMessage.textContent = `File ${file.name} uploaded. Task ID: ${result.task_id}`;
|
||||
} catch (err) {
|
||||
statusMessage.textContent = `Error: ${err}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>DocuNova - Intelligent Document Processing</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
|
||||
<!-- Brand -->
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="text-xl font-bold text-blue-500 hover:text-blue-700">
|
||||
DocuNova
|
||||
</a>
|
||||
</div>
|
||||
<!-- Menu items -->
|
||||
<div class="flex space-x-4 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||
<!-- Dynamic auth section -->
|
||||
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-grow container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold mb-4">Welcome to DocuNova</h1>
|
||||
<p class="text-gray-700 mb-8">
|
||||
Your intelligent solution for processing, managing, and organizing documents effortlessly.
|
||||
</p>
|
||||
<!-- Sample Sections -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="bg-white shadow rounded p-6">
|
||||
<h2 class="text-2xl font-semibold mb-2">Upload Documents</h2>
|
||||
<p class="text-gray-600">
|
||||
Quickly upload and process your files with our user-friendly interface.
|
||||
</p>
|
||||
<a href="/upload" class="mt-4 inline-block text-blue-600 hover:text-blue-800">
|
||||
Get Started →
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-white shadow rounded p-6">
|
||||
<h2 class="text-2xl font-semibold mb-2">Manage Your Files</h2>
|
||||
<p class="text-gray-600">
|
||||
View, organize, and collaborate on your processed files in one central hub.
|
||||
</p>
|
||||
<a href="/files" class="mt-4 inline-block text-blue-600 hover:text-blue-800">
|
||||
View Files →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
|
||||
© 2025 DocuNova. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript for dynamic auth section -->
|
||||
<script>
|
||||
(async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch("/api/whoami");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// data.picture is expected to be a Gravatar URL
|
||||
const authSection = document.getElementById("authSection");
|
||||
authSection.innerHTML = '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = data.picture;
|
||||
img.alt = 'User Avatar';
|
||||
img.className = 'inline-block h-8 w-8 rounded-full mr-2';
|
||||
|
||||
const textNode = document.createTextNode('Logged in as ');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = data.email;
|
||||
|
||||
const logoutLink = document.createElement('a');
|
||||
logoutLink.href = '/logout';
|
||||
logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800';
|
||||
logoutLink.textContent = 'Logout';
|
||||
|
||||
authSection.appendChild(img);
|
||||
authSection.appendChild(textNode);
|
||||
authSection.appendChild(strong);
|
||||
authSection.appendChild(logoutLink);
|
||||
} else {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>DocuNova - Upload</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
|
||||
<!-- Brand -->
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="text-xl font-bold text-blue-500 hover:text-blue-700">
|
||||
DocuNova
|
||||
</a>
|
||||
</div>
|
||||
<!-- Menu Items -->
|
||||
<div class="flex space-x-4 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||
<!-- Dynamic Auth Section -->
|
||||
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-grow flex flex-col items-center justify-center p-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Upload a File</h1>
|
||||
|
||||
<div
|
||||
id="dropZone"
|
||||
class="border-4 border-dashed border-gray-300 rounded-lg p-8 bg-white text-center w-full max-w-lg"
|
||||
ondrop="handleDrop(event)"
|
||||
ondragover="handleDragOver(event)"
|
||||
>
|
||||
<p class="text-gray-500 mb-4">
|
||||
Drag & drop a file here, or click to select a file.
|
||||
</p>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange="handleFileSelect(event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="mt-4 text-gray-700"></div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white shadow mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
|
||||
© 2025 DocuNova. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
// Drag-and-drop / file input logic
|
||||
const dropZone = document.getElementById("dropZone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const statusMessage = document.getElementById("statusMessage");
|
||||
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
dropZone.classList.add("bg-gray-100");
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("bg-gray-100");
|
||||
if (e.dataTransfer.files.length) {
|
||||
uploadFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
if (e.target.files.length) {
|
||||
uploadFile(e.target.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
// Check file type
|
||||
const acceptedTypes = ['application/pdf'];
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
statusMessage.textContent = `Error: Only PDF files are accepted.`;
|
||||
return;
|
||||
}
|
||||
statusMessage.textContent = `Uploading ${file.name}...`;
|
||||
try {
|
||||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/ui-upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
statusMessage.textContent = `File ${file.name} uploaded. Task ID: ${result.task_id}`;
|
||||
} catch (err) {
|
||||
statusMessage.textContent = `Error: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
<!-- JavaScript for dynamic auth section -->
|
||||
|
||||
(async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch("/api/whoami");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// data.picture is expected to be a Gravatar URL
|
||||
const authSection = document.getElementById("authSection");
|
||||
authSection.innerHTML = '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = data.picture;
|
||||
img.alt = 'User Avatar';
|
||||
img.className = 'inline-block h-8 w-8 rounded-full mr-2';
|
||||
|
||||
const textNode = document.createTextNode('Logged in as ');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = data.email;
|
||||
|
||||
const logoutLink = document.createElement('a');
|
||||
logoutLink.href = '/logout';
|
||||
logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800';
|
||||
logoutLink.textContent = 'Logout';
|
||||
|
||||
authSection.appendChild(img);
|
||||
authSection.appendChild(textNode);
|
||||
authSection.appendChild(strong);
|
||||
authSection.appendChild(logoutLink);
|
||||
} else {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("authSection").innerHTML =
|
||||
`<a href="/login" class="text-blue-600">Login</a>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-1
@@ -12,4 +12,7 @@ openai
|
||||
pymupdf
|
||||
requests
|
||||
dropbox
|
||||
azure-ai-documentintelligence
|
||||
azure-ai-documentintelligence
|
||||
authlib
|
||||
python-dotenv
|
||||
starlette
|
||||
Reference in New Issue
Block a user