Files
Orchestrator/privat/NoteGraph/ingestion/web/upload.py
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

234 lines
8.8 KiB
Python

"""FastAPI web upload endpoint for mobile file ingestion.
Auth: Token-based via URL query param (?token=secret) or header (X-Upload-Token).
Processing: File is saved immediately, enrichment runs in background.
"""
import logging
import secrets
import threading
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse
from ingestion.config import IngestionConfig
from ingestion.web.browse import router as browse_router
logger = logging.getLogger(__name__)
app = FastAPI(title="NoteGraph", version="0.1.0")
app.include_router(browse_router)
@app.get("/")
async def root(request: Request):
"""Redirect root to browse page."""
from fastapi.responses import RedirectResponse
token = request.query_params.get("token", "")
return RedirectResponse(url=f"/browse?token={token}" if token else "/browse")
def _get_config() -> IngestionConfig:
return IngestionConfig()
def _verify_token(request: Request, token: str = Query(default=None)):
"""Verify upload token from query param or X-Upload-Token header."""
config = _get_config()
# Token is the password part of SB_USER (after the colon)
sb_user = config.sb_user.strip()
parts = sb_user.split(":", 1)
expected_token = parts[1].strip() if len(parts) == 2 else sb_user
# Check query param
if token and secrets.compare_digest(token.encode(), expected_token.encode()):
return True
# Check header
header_token = request.headers.get("X-Upload-Token", "")
if header_token and secrets.compare_digest(header_token.encode(), expected_token.encode()):
return True
raise HTTPException(status_code=401, detail="Invalid or missing token. Use ?token=YOUR_PASSWORD")
def _process_in_background(file_path: Path, config: IngestionConfig):
"""Run the enrichment pipeline in a background thread."""
try:
from ingestion.pipeline import process_file
result = process_file(file_path=file_path, config=config)
if result.success:
logger.info("Background processing complete: %s%s", file_path.name, result.output_file)
else:
logger.warning("Background processing failed for %s: %s", file_path.name, result.error)
except Exception as e:
logger.error("Background processing error for %s: %s", file_path.name, e)
UPLOAD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NoteGraph Upload</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.container {
background: #16213e;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
h1 { text-align: center; margin-bottom: 1.5rem; font-size: 1.5rem; color: #4fc3f7; }
.drop-zone {
border: 2px dashed #4fc3f7;
border-radius: 8px;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 1rem;
}
.drop-zone:hover, .drop-zone.dragover {
background: rgba(79, 195, 247, 0.1);
border-color: #81d4fa;
}
.drop-zone p { margin-bottom: 0.5rem; font-size: 1.1rem; }
.drop-zone small { color: #999; }
input[type="file"] { display: none; }
.btn {
display: block; width: 100%; padding: 0.9rem;
background: #4fc3f7; color: #1a1a2e; border: none;
border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.3s;
}
.btn:hover { background: #81d4fa; }
.btn:disabled { background: #555; cursor: not-allowed; }
.status { margin-top: 1rem; padding: 0.8rem; border-radius: 6px; text-align: center; display: none; }
.status.success { display: block; background: #1b5e20; color: #a5d6a7; }
.status.error { display: block; background: #b71c1c; color: #ef9a9a; }
.status.loading { display: block; background: #0d47a1; color: #90caf9; }
.filename { margin-top: 0.5rem; font-size: 0.9rem; color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>📄 NoteGraph Upload</h1>
<form id="uploadForm" enctype="multipart/form-data">
<div class="drop-zone" id="dropZone">
<p>Drop file here or tap to select</p>
<small>PDF, JPG, PNG, DOCX, TXT, MD</small>
</div>
<input type="file" id="fileInput" name="file" accept=".pdf,.jpg,.jpeg,.png,.docx,.txt,.md">
<div class="filename" id="fileName"></div>
<button type="submit" class="btn" id="submitBtn" disabled>Upload & Process</button>
</form>
<div class="status" id="status"></div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const form = document.getElementById('uploadForm');
const submitBtn = document.getElementById('submitBtn');
const statusEl = document.getElementById('status');
const fileNameEl = document.getElementById('fileName');
// Get token from URL
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token') || '';
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; updateFileName(); }
});
fileInput.addEventListener('change', updateFileName);
function updateFileName() {
if (fileInput.files.length) { fileNameEl.textContent = fileInput.files[0].name; submitBtn.disabled = false; }
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!fileInput.files.length) return;
submitBtn.disabled = true;
statusEl.className = 'status loading';
statusEl.textContent = 'Uploading...';
statusEl.style.display = 'block';
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const resp = await fetch('/upload?token=' + encodeURIComponent(token), { method: 'POST', body: formData });
const data = await resp.json();
if (resp.ok) {
statusEl.className = 'status success';
statusEl.textContent = data.message || 'Upload successful!';
} else {
statusEl.className = 'status error';
statusEl.textContent = data.detail || 'Upload failed';
}
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Network error: ' + err.message;
}
submitBtn.disabled = false;
});
</script>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
@app.get("/upload", response_class=HTMLResponse)
async def upload_form(request: Request, token: str = Query(default=None)):
"""Serve the upload form. Token required."""
_verify_token(request, token)
return UPLOAD_HTML
@app.post("/upload")
async def upload_file(
request: Request,
file: UploadFile = File(...),
token: str = Query(default=None),
):
"""Accept file upload, save immediately, process in background."""
_verify_token(request, token)
config = _get_config()
inbox = Path(config.inbox_dir)
inbox.mkdir(parents=True, exist_ok=True)
# Save uploaded file
target = inbox / file.filename
content = await file.read()
target.write_bytes(content)
logger.info("Uploaded file saved: %s (%d bytes)", target, len(content))
# Process in background (don't block the response)
thread = threading.Thread(
target=_process_in_background,
args=(target, config),
daemon=True,
)
thread.start()
return {"message": f"File received: {file.filename}. Processing in background — check SilverBullet in ~30s."}