234 lines
8.8 KiB
Python
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."}
|