feat: enhance file uploader with size validation and error handling
This commit is contained in:
@@ -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":
|
||||||
|
|||||||
@@ -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.
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user