File size: 2,549 Bytes
9c3ba60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
"""Utilities for safely handling user-uploaded files.

Used by ``backend/api/routes/upload.py``. Keeps file-type detection,
extension validation, and safe-name handling in one place so route code
can stay thin.
"""

from __future__ import annotations

import re
import unicodedata
import uuid
from pathlib import Path
from typing import Final

from config import settings

# Canonical extension set for the AI PPT generator pipeline.
SUPPORTED_EXTENSIONS: Final[frozenset[str]] = frozenset(
    {"csv", "xlsx", "xls", "json", "pdf", "docx", "pptx", "txt", "md"}
)

# Extensions we explicitly never accept regardless of MIME, to avoid hosting
# executable content in /storage/uploads which is served by /api/files in dev.
BLOCKED_EXTENSIONS: Final[frozenset[str]] = frozenset(
    {
        "exe", "bat", "cmd", "com", "sh", "ps1", "msi", "scr",
        "js", "ts", "html", "htm", "svg", "php", "py", "pyc",
        "dll", "so", "dylib", "jar",
    }
)


def get_extension(filename: str) -> str:
    """Return lowercase extension without the leading dot, or ``""``."""
    if not filename:
        return ""
    name = filename.strip().rsplit(".", 1)
    return name[1].lower() if len(name) == 2 else ""


def is_allowed_extension(filename: str) -> bool:
    ext = get_extension(filename)
    if not ext or ext in BLOCKED_EXTENSIONS:
        return False
    allowed = settings.allowed_upload_extensions or SUPPORTED_EXTENSIONS
    return ext in allowed


def safe_filename(filename: str) -> str:
    """Strip path components, normalize unicode, keep only [A-Za-z0-9._-]."""
    base = Path(filename or "").name
    norm = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode()
    cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", norm).strip("._") or "file"
    # Guard against silly long names.
    return cleaned[:200]


def build_storage_path(filename: str) -> tuple[str, Path]:
    """Return ``(file_id, absolute_path)`` for a new upload.

    The on-disk filename is ``{uuid}_{safe_original_name}`` to keep originals
    debuggable while preventing collisions and traversal.
    """
    settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    file_id = uuid.uuid4().hex
    final = settings.UPLOAD_DIR / f"{file_id}_{safe_filename(filename)}"
    return file_id, final


def detect_file_type(filename: str) -> str:
    """Map an extension to the canonical ``file_type`` stored on UploadedFile."""
    ext = get_extension(filename)
    if ext == "xls":
        return "xlsx"  # parsed via pandas with the same loader
    return ext