feat: enhance file uploader with size validation and error handling

This commit is contained in:
Your Name
2026-03-22 15:12:19 +02:00
parent d8a51d8494
commit 70d7f09110
5 changed files with 84 additions and 5 deletions

View File

@@ -1,9 +1,12 @@
"""Task status polling endpoint.""" """Task status polling endpoint."""
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from celery.result import AsyncResult from celery.result import AsyncResult
from app.extensions import celery from app.extensions import celery
from app.middleware.rate_limiter import limiter from app.middleware.rate_limiter import limiter
from app.services.account_service import has_task_access, record_usage_event
from app.services.policy_service import ( from app.services.policy_service import (
PolicyError, PolicyError,
assert_api_task_access, assert_api_task_access,
@@ -11,10 +14,46 @@ from app.services.policy_service import (
resolve_api_actor, resolve_api_actor,
resolve_web_actor, resolve_web_actor,
) )
from app.utils.auth import remember_task_access
tasks_bp = Blueprint("tasks", __name__) tasks_bp = Blueprint("tasks", __name__)
def _extract_download_task_id(download_url: str | None) -> str | None:
"""Return the local download identifier embedded in one download URL."""
if not download_url:
return None
path_parts = [part for part in urlparse(download_url).path.split("/") if part]
if len(path_parts) >= 4 and path_parts[0] == "api" and path_parts[1] == "download":
return path_parts[2]
return None
def _remember_download_alias(actor, download_task_id: str | None):
"""Grant access to one local download identifier returned after task success."""
if not download_task_id:
return
remember_task_access(download_task_id)
if actor.user_id is None:
return
if has_task_access(actor.user_id, actor.source, download_task_id):
return
record_usage_event(
user_id=actor.user_id,
api_key_id=actor.api_key_id,
source=actor.source,
tool="download",
task_id=download_task_id,
event_type="download_alias",
)
@tasks_bp.route("/<task_id>/status", methods=["GET"]) @tasks_bp.route("/<task_id>/status", methods=["GET"])
@limiter.limit("300/minute", override_defaults=True) @limiter.limit("300/minute", override_defaults=True)
def get_task_status(task_id: str): def get_task_status(task_id: str):
@@ -50,6 +89,7 @@ def get_task_status(task_id: str):
elif result.state == "SUCCESS": elif result.state == "SUCCESS":
task_result = result.result or {} task_result = result.result or {}
_remember_download_alias(actor, _extract_download_task_id(task_result.get("download_url")))
response["result"] = task_result response["result"] = task_result
elif result.state == "FAILURE": elif result.state == "FAILURE":

View File

@@ -669,7 +669,8 @@ def has_task_access(user_id: int, source: str, task_id: str) -> bool:
""" """
SELECT 1 SELECT 1
FROM usage_events FROM usage_events
WHERE user_id = ? AND source = ? AND task_id = ? AND event_type = 'accepted' WHERE user_id = ? AND source = ? AND task_id = ?
AND event_type IN ('accepted', 'download_alias')
LIMIT 1 LIMIT 1
""", """,
(user_id, source, task_id), (user_id, source, task_id),

Binary file not shown.

View File

@@ -1,6 +1,7 @@
"""Tests for task status polling route.""" """Tests for task status polling route."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from app.services.account_service import create_user, has_task_access
from app.utils.auth import TASK_ACCESS_SESSION_KEY from app.utils.auth import TASK_ACCESS_SESSION_KEY
@@ -62,6 +63,30 @@ class TestTaskStatus:
assert data['result']['status'] == 'completed' assert data['result']['status'] == 'completed'
assert 'download_url' in data['result'] assert 'download_url' in data['result']
with client.session_transaction() as session:
assert 'task-id' in session[TASK_ACCESS_SESSION_KEY]
def test_success_task_persists_download_alias_for_authenticated_user(self, client):
"""Should persist download aliases for logged-in users as authorized task ids."""
user = create_user('tasks-route@example.com', 'secretpass123')
mock_result = MagicMock()
mock_result.state = 'SUCCESS'
mock_result.result = {
'status': 'completed',
'download_url': '/api/download/local-download-id/output.pdf',
'filename': 'output.pdf',
}
with client.session_transaction() as session:
session['user_id'] = user['id']
session[TASK_ACCESS_SESSION_KEY] = ['success-id']
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
response = client.get('/api/tasks/success-id/status')
assert response.status_code == 200
assert has_task_access(user['id'], 'web', 'local-download-id') is True
def test_failure_task(self, client, monkeypatch): def test_failure_task(self, client, monkeypatch):
"""Should return FAILURE state with error message.""" """Should return FAILURE state with error message."""
mock_result = MagicMock() mock_result = MagicMock()

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useDropzone, type Accept } from 'react-dropzone'; import { useDropzone, type Accept, type FileRejection } from 'react-dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Upload, File, X } from 'lucide-react'; import { Upload, File, X } from 'lucide-react';
import { formatFileSize } from '@/utils/textTools'; import { formatFileSize } from '@/utils/textTools';
@@ -37,9 +37,11 @@ export default function FileUploader({
acceptLabel, acceptLabel,
}: FileUploaderProps) { }: FileUploaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sizeError, setSizeError] = useState<string | null>(null);
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
setSizeError(null);
if (acceptedFiles.length > 0) { if (acceptedFiles.length > 0) {
onFileSelect(acceptedFiles[0]); onFileSelect(acceptedFiles[0]);
} }
@@ -47,8 +49,19 @@ export default function FileUploader({
[onFileSelect] [onFileSelect]
); );
const onDropRejected = useCallback(
(rejectedFiles: FileRejection[]) => {
const code = rejectedFiles[0]?.errors[0]?.code;
if (code === 'file-too-large') {
setSizeError(t('errors.fileTooLarge', { size: maxSizeMB }));
}
},
[maxSizeMB, t]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
onDropRejected,
accept, accept,
maxFiles: 1, maxFiles: 1,
maxSize: maxSizeMB * 1024 * 1024, maxSize: maxSizeMB * 1024 * 1024,
@@ -122,9 +135,9 @@ export default function FileUploader({
)} )}
{/* Error */} {/* Error */}
{error && ( {(sizeError || error) && (
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800"> <div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p> <p className="text-sm text-red-700 dark:text-red-400"> {sizeError || error}</p>
</div> </div>
)} )}
</div> </div>