alu-chatbot / document_processor.py
Donne120
Auth, rate limiting, upload hardening, and error hygiene on all routes
b76768d
Raw
History Blame Contribute Delete
9.59 kB
import os
import uuid
import json
import shutil
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import UploadFile, HTTPException
import time
# Document handling libraries
import pypdf
import docx2txt
# Create data directories if they don't exist
DATA_DIR = Path("./data")
DOCUMENTS_DIR = DATA_DIR / "documents"
DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True)
METADATA_FILE = DATA_DIR / "document_metadata.json"
# Hard cap on upload size — an unbounded file would fill the container's
# ephemeral disk / RAM and take the service down. 10 MB covers real docs.
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
# Derive the saved-file extension from the validated content type, never from
# the client-supplied filename (which is attacker-controlled).
EXTENSION_FOR_CONTENT_TYPE = {
"application/pdf": ".pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"text/plain": ".txt",
"text/markdown": ".md",
}
class DocumentProcessor:
"""
Handles document processing including:
- Uploading and storing documents
- Extracting text from different document formats
- Maintaining document metadata
"""
def __init__(self):
self.supported_formats = {
"application/pdf": self._extract_pdf_text,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": self._extract_docx_text,
"text/plain": self._extract_text_file,
"text/markdown": self._extract_text_file,
}
self._initialize_metadata()
def _initialize_metadata(self):
"""Initialize metadata storage"""
if not METADATA_FILE.exists():
with open(METADATA_FILE, "w") as f:
json.dump({}, f)
async def process_document(self, file: UploadFile, title: Optional[str] = None, source: str = "user-upload") -> str:
"""
Process an uploaded document:
1. Extract text based on file type
2. Save document metadata
3. Return document ID for further processing
"""
# Generate a unique ID for the document
doc_id = str(uuid.uuid4())
# Determine document format and extract text
content_type = file.content_type
if content_type not in self.supported_formats:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format: {content_type}. Supported formats: {', '.join(self.supported_formats.keys())}"
)
# Save the original file. Extension comes from the validated content
# type, NOT the client filename. Stream in chunks and abort if the
# upload exceeds MAX_UPLOAD_BYTES so a huge file can't exhaust disk/RAM.
extension = EXTENSION_FOR_CONTENT_TYPE.get(content_type, "")
file_path = DOCUMENTS_DIR / f"{doc_id}{extension}"
bytes_written = 0
try:
with open(file_path, "wb") as f:
while True:
chunk = await file.read(1024 * 1024) # 1 MB at a time
if not chunk:
break
bytes_written += len(chunk)
if bytes_written > MAX_UPLOAD_BYTES:
f.close()
file_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail="File too large — uploads are limited to 10 MB.",
)
f.write(chunk)
except HTTPException:
raise
except Exception:
file_path.unlink(missing_ok=True)
raise
# Reset file position so the extractor below can re-read from the start
await file.seek(0)
# Extract text
text = await self.supported_formats[content_type](file)
# If title is not provided, use the filename without extension
if not title:
title = Path(file.filename).stem
# Save text to a file
text_file_path = DOCUMENTS_DIR / f"{doc_id}.txt"
with open(text_file_path, "w", encoding="utf-8") as f:
f.write(text)
# Update metadata
metadata = {
"id": doc_id,
"title": title,
"filename": file.filename,
"content_type": content_type,
"source": source,
"length": len(text),
"upload_time": time.time(),
"original_file": str(file_path),
"text_file": str(text_file_path)
}
self._save_metadata(doc_id, metadata)
return doc_id
async def _extract_pdf_text(self, file: UploadFile) -> str:
"""Extract text from PDF files"""
temp_path = DOCUMENTS_DIR / f"temp_{uuid.uuid4()}.pdf"
try:
# Save to temporary file
with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
# Process PDF using pypdf
text = ""
with open(temp_path, "rb") as f:
pdf = pypdf.PdfReader(f)
for page in pdf.pages:
text += page.extract_text() + "\n\n"
return text
finally:
# Clean up temp file
if temp_path.exists():
os.remove(temp_path)
async def _extract_docx_text(self, file: UploadFile) -> str:
"""Extract text from DOCX files"""
temp_path = DOCUMENTS_DIR / f"temp_{uuid.uuid4()}.docx"
try:
# Save to temporary file
with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
# Process DOCX using docx2txt
text = docx2txt.process(temp_path)
return text
finally:
# Clean up temp file
if temp_path.exists():
os.remove(temp_path)
async def _extract_text_file(self, file: UploadFile) -> str:
"""Extract text from plain text files"""
content = await file.read()
try:
return content.decode("utf-8")
except UnicodeDecodeError:
# Try different encoding if UTF-8 fails
return content.decode("latin-1")
def _save_metadata(self, doc_id: str, metadata: Dict[str, Any]):
"""Save document metadata to the metadata file"""
try:
# Load existing metadata
with open(METADATA_FILE, "r") as f:
all_metadata = json.load(f)
# Add new metadata
all_metadata[doc_id] = metadata
# Save updated metadata
with open(METADATA_FILE, "w") as f:
json.dump(all_metadata, f, indent=2)
except Exception as e:
print(f"Error saving metadata: {e}")
raise HTTPException(status_code=500, detail=f"Error saving document metadata: {str(e)}")
def list_documents(self) -> List[Dict[str, Any]]:
"""Get a list of all document metadata"""
try:
with open(METADATA_FILE, "r") as f:
all_metadata = json.load(f)
# Return a list of metadata objects
return list(all_metadata.values())
except Exception as e:
print(f"Error listing documents: {e}")
return []
def delete_document(self, doc_id: str) -> bool:
"""Delete a document and its metadata"""
try:
# Load existing metadata
with open(METADATA_FILE, "r") as f:
all_metadata = json.load(f)
# Check if document exists
if doc_id not in all_metadata:
return False
# Get metadata for the document
metadata = all_metadata[doc_id]
# Delete the original file if it exists
original_file = Path(metadata.get("original_file", ""))
if original_file.exists():
os.remove(original_file)
# Delete the text file if it exists
text_file = Path(metadata.get("text_file", ""))
if text_file.exists():
os.remove(text_file)
# Remove from metadata
del all_metadata[doc_id]
# Save updated metadata
with open(METADATA_FILE, "w") as f:
json.dump(all_metadata, f, indent=2)
return True
except Exception as e:
print(f"Error deleting document: {e}")
return False
def get_document_text(self, doc_id: str) -> Optional[str]:
"""Get the extracted text for a document"""
try:
# Load metadata
with open(METADATA_FILE, "r") as f:
all_metadata = json.load(f)
# Check if document exists
if doc_id not in all_metadata:
return None
# Get text file path
text_file = Path(all_metadata[doc_id].get("text_file", ""))
if not text_file.exists():
return None
# Read text file
with open(text_file, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
print(f"Error getting document text: {e}")
return None