Fix audit findings: security, robustness, and dead code
- Remove dead get_analysis_service() function in main.py - Scope file deletion to UPLOAD_DIR to prevent path traversal - Parse datetime strings back to datetime objects in repos - Add 10-minute polling timeout in frontend analysis store - Accept .pdf extension (not just MIME type) on drag-and-drop - Guard localStorage access for private browsing compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b0fc45e5d
commit
001cf6807c
7 changed files with 60 additions and 18 deletions
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.analyses import router as analyses_router
|
||||
|
|
@ -89,11 +89,6 @@ app.include_router(documents_router)
|
|||
app.include_router(analyses_router)
|
||||
|
||||
|
||||
def get_analysis_service(request: Request) -> AnalysisService:
|
||||
"""FastAPI dependency — retrieve the AnalysisService from app.state."""
|
||||
return request.app.state.analysis_service
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO-format datetime string back into a datetime object."""
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _row_to_job(row) -> AnalysisJob:
|
||||
return AnalysisJob(
|
||||
id=row["id"],
|
||||
|
|
@ -15,9 +24,9 @@ def _row_to_job(row) -> AnalysisJob:
|
|||
content_html=row["content_html"],
|
||||
pages_json=row["pages_json"],
|
||||
error_message=row["error_message"],
|
||||
started_at=row["started_at"],
|
||||
completed_at=row["completed_at"],
|
||||
created_at=row["created_at"],
|
||||
started_at=_parse_dt(row["started_at"]),
|
||||
completed_at=_parse_dt(row["completed_at"]),
|
||||
created_at=_parse_dt(row["created_at"]) or datetime.now(),
|
||||
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from domain.models import Document
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _row_to_document(row) -> Document:
|
||||
created = row["created_at"]
|
||||
if isinstance(created, str):
|
||||
created = datetime.fromisoformat(created)
|
||||
return Document(
|
||||
id=row["id"],
|
||||
filename=row["filename"],
|
||||
|
|
@ -14,7 +19,7 @@ def _row_to_document(row) -> Document:
|
|||
file_size=row["file_size"],
|
||||
page_count=row["page_count"],
|
||||
storage_path=row["storage_path"],
|
||||
created_at=row["created_at"],
|
||||
created_at=created,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -70,10 +70,14 @@ async def delete(doc_id: str) -> bool:
|
|||
# Delete associated analyses first (cascade)
|
||||
await analysis_repo.delete_by_document(doc_id)
|
||||
|
||||
# Delete file from disk
|
||||
# Delete file from disk (only if inside UPLOAD_DIR)
|
||||
try:
|
||||
if os.path.exists(doc.storage_path):
|
||||
os.unlink(doc.storage_path)
|
||||
real_path = os.path.realpath(doc.storage_path)
|
||||
real_upload_dir = os.path.realpath(UPLOAD_DIR)
|
||||
if real_path.startswith(real_upload_dir + os.sep) and os.path.exists(real_path):
|
||||
os.unlink(real_path)
|
||||
elif os.path.exists(doc.storage_path):
|
||||
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
|
||||
except OSError:
|
||||
logger.warning("Could not delete file: %s", doc.storage_path)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
const running = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
const currentPages = computed<Page[]>(() => {
|
||||
if (!currentAnalysis.value?.pagesJson) return []
|
||||
|
|
@ -72,6 +74,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
running.value = false
|
||||
}
|
||||
}, 2000)
|
||||
pollingTimeout.value = setTimeout(() => {
|
||||
if (pollingInterval.value) {
|
||||
error.value = 'Analysis timed out'
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
}, MAX_POLLING_DURATION)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
|
|
@ -79,6 +88,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
clearInterval(pollingInterval.value)
|
||||
pollingInterval.value = null
|
||||
}
|
||||
if (pollingTimeout.value) {
|
||||
clearTimeout(pollingTimeout.value)
|
||||
pollingTimeout.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ async function onFileSelect(e: Event) {
|
|||
async function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
|
|||
import { ref, watch, watchEffect } from 'vue'
|
||||
import type { Locale, Theme } from '../../shared/types'
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// localStorage unavailable (private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const apiUrl = ref('http://localhost:8000')
|
||||
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
|
||||
const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||
|
||||
watch(theme, (v) => localStorage.setItem('docling-theme', v))
|
||||
watch(locale, (v) => localStorage.setItem('docling-locale', v))
|
||||
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||
watch(locale, (v) => safeSetItem('docling-locale', v))
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||
|
|
|
|||
Loading…
Reference in a new issue