forge-mvc-files 1.0.0b16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ Forge — Licence propriétaire / source disponible
2
+
3
+ Copyright (c) Roger Lequette.
4
+ Tous droits réservés.
5
+
6
+ Le code source de Forge est mis à disposition uniquement pour lecture, étude,
7
+ évaluation et usage éducatif personnel.
8
+
9
+ Sauf autorisation écrite préalable de Roger Lequette, il est interdit de :
10
+
11
+ - utiliser Forge dans un cadre professionnel, commercial ou institutionnel ;
12
+ - utiliser Forge pour une prestation client ;
13
+ - intégrer Forge dans un produit, service, SaaS, application vendue ou solution
14
+ déployée pour un tiers ;
15
+ - redistribuer Forge, modifié ou non ;
16
+ - publier une version modifiée de Forge ;
17
+ - vendre, louer, sous-licencier ou exploiter commercialement Forge ;
18
+ - supprimer ou modifier les mentions de copyright et de licence.
19
+
20
+ Les usages autorisés sans autorisation écrite préalable sont limités à :
21
+
22
+ - lire le code ;
23
+ - étudier son fonctionnement ;
24
+ - tester Forge à titre personnel ;
25
+ - l'utiliser dans un cadre éducatif personnel ou pédagogique non commercial ;
26
+ - évaluer le framework avant une éventuelle demande d'autorisation.
27
+
28
+ Toute utilisation non explicitement autorisée par cette licence nécessite une
29
+ autorisation écrite préalable de Roger Lequette.
30
+
31
+ Cette licence pourra évoluer ultérieurement. Toute version publiée de Forge
32
+ reste soumise à la licence présente dans son dépôt au moment de sa récupération.
33
+
34
+ LE LOGICIEL EST FOURNI « TEL QUEL », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
35
+ IMPLICITE. EN AUCUN CAS LE DÉTENTEUR DU COPYRIGHT NE POURRA ÊTRE TENU
36
+ RESPONSABLE DE TOUT DOMMAGE DÉCOULANT DE L'UTILISATION DE CE LOGICIEL.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: forge-mvc-files
3
+ Version: 1.0.0b16
4
+ Summary: Forge Files — module opt-in propriétaire de l'upload générique : écriture sécurisée, storage anti-traversal, service de fichiers, rate-limit. Extrait du core (ADR-019).
5
+ Author: Roger Lequette
6
+ License-Expression: LicenseRef-Forge-Proprietary
7
+ Project-URL: Homepage, https://github.com/caucrogeGit/Forge
8
+ Project-URL: Repository, https://github.com/caucrogeGit/Forge
9
+ Project-URL: Documentation, https://forgemvc.com/docs/forge/
10
+ Keywords: python,mvc,forge,files,upload,storage
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: forge-mvc<2,>=1.0.0b16
20
+ Dynamic: license-file
21
+
22
+ # forge-mvc-files
23
+
24
+ Module **opt-in** propriétaire de l'**upload générique** dans Forge MVC :
25
+ écriture disque sécurisée, storage anti-traversal, service de fichiers
26
+ (streaming HTTP Range), suppression et rate-limit d'upload.
27
+
28
+ ## Statut : extraction complète (ADR-019)
29
+
30
+ `forge-mvc-files` détient désormais **tout le pipeline d'upload générique**,
31
+ extrait du core : `save_upload`, `SavedUpload`, `serve_media_file` (HTTP Range),
32
+ storage anti-traversal, suppression et rate-limit. `core/uploads/` a été
33
+ supprimé du core (`CORE-DROP-UPLOADS-001`). La **validation pure** de fichier
34
+ (extension/MIME/taille + `UploadError`) reste dans le core (`core.forms`),
35
+ réutilisée par ce paquet.
36
+
37
+ Ce paquet cible la publication PyPI de la release beta.13.
38
+
39
+ ## Pourquoi ce module (ADR-019)
40
+
41
+ Après l'extraction du traitement d'image (ADR-018), l'upload **générique** reste
42
+ le dernier gros bloc applicatif logé dans le noyau. Un framework web peut
43
+ exister sans upload de fichiers : c'est une brique applicative, pas un
44
+ fondement. `forge-mvc-files` devient l'**unique** propriétaire de l'upload
45
+ générique (principes 8 « noyau minimal » et 11 « une seule façon officielle »).
46
+
47
+ ### Ce qui a été déplacé du core
48
+
49
+ - `manager` — `save_upload`, `SavedUpload`, `serve_media_file`, `delete_upload`,
50
+ `delete_media_file`, `get_upload_path`, `upload_root`, `_read_upload` ;
51
+ - `storage` — écriture/anti-traversal (`normalize_media_path`,
52
+ `media_path_to_storage_path`, `is_safe_media_path`, `save_bytes`, `delete_file`) ;
53
+ - `rate_limit` d'upload (`is_upload_rate_limited`, `record_upload_attempt`).
54
+
55
+ ### Ce qui reste dans le core (définitif)
56
+
57
+ - Les **validators purs** (`validate_extension`, `validate_mime_type`,
58
+ `validate_size`) et la hiérarchie d'exceptions `UploadError` : `core/forms`
59
+ (`FileField`) en dépend, et le core ne peut pas dépendre d'un opt-in (ADR-004).
60
+ Ce sont des contrôles purs sans I/O. `forge-mvc-files` les réutilise.
61
+
62
+ ## Plan d'exécution (ADR-019)
63
+
64
+ | Ticket | Description | État |
65
+ |---|---|---|
66
+ | `FILES-PKG-SCAFFOLD-001` | Squelette du paquet + enregistrement opt-in | livré |
67
+ | `FILES-VALIDATORS-KEEP-001` | Relocaliser validators + exceptions dans le core | livré |
68
+ | `FILES-MOVE-PIPELINE-001` | Déplacer manager + storage + rate_limit | livré |
69
+ | `FILES-IMAGES-REPOINT-001` | forge-mvc-images dépend de forge-mvc-files | livré |
70
+ | `FILES-CLI-RENAME-001` | Générateurs + forge_cli/uploads + starter | livré |
71
+ | `FILES-DOCS-PERIMETER-001` | Docs + ADR-004 + CLAUDE.md §3 | livré |
72
+ | `CORE-DROP-UPLOADS-001` | Suppression de `core/uploads/` | livré |
73
+
74
+ ## Installation (mode éditable, depuis les sources)
75
+
76
+ ```bash
77
+ git clone https://github.com/caucrogeGit/Forge.git
78
+ cd Forge
79
+ pip install -e packages/forge-mvc-files/
80
+ ```
81
+
82
+ ## Référence
83
+
84
+ - `docs/adr/019-upload-extraction.md` — décision et périmètre figés.
85
+ - Charte principes 8 (noyau minimal), 11 (une seule façon officielle).
@@ -0,0 +1,64 @@
1
+ # forge-mvc-files
2
+
3
+ Module **opt-in** propriétaire de l'**upload générique** dans Forge MVC :
4
+ écriture disque sécurisée, storage anti-traversal, service de fichiers
5
+ (streaming HTTP Range), suppression et rate-limit d'upload.
6
+
7
+ ## Statut : extraction complète (ADR-019)
8
+
9
+ `forge-mvc-files` détient désormais **tout le pipeline d'upload générique**,
10
+ extrait du core : `save_upload`, `SavedUpload`, `serve_media_file` (HTTP Range),
11
+ storage anti-traversal, suppression et rate-limit. `core/uploads/` a été
12
+ supprimé du core (`CORE-DROP-UPLOADS-001`). La **validation pure** de fichier
13
+ (extension/MIME/taille + `UploadError`) reste dans le core (`core.forms`),
14
+ réutilisée par ce paquet.
15
+
16
+ Ce paquet cible la publication PyPI de la release beta.13.
17
+
18
+ ## Pourquoi ce module (ADR-019)
19
+
20
+ Après l'extraction du traitement d'image (ADR-018), l'upload **générique** reste
21
+ le dernier gros bloc applicatif logé dans le noyau. Un framework web peut
22
+ exister sans upload de fichiers : c'est une brique applicative, pas un
23
+ fondement. `forge-mvc-files` devient l'**unique** propriétaire de l'upload
24
+ générique (principes 8 « noyau minimal » et 11 « une seule façon officielle »).
25
+
26
+ ### Ce qui a été déplacé du core
27
+
28
+ - `manager` — `save_upload`, `SavedUpload`, `serve_media_file`, `delete_upload`,
29
+ `delete_media_file`, `get_upload_path`, `upload_root`, `_read_upload` ;
30
+ - `storage` — écriture/anti-traversal (`normalize_media_path`,
31
+ `media_path_to_storage_path`, `is_safe_media_path`, `save_bytes`, `delete_file`) ;
32
+ - `rate_limit` d'upload (`is_upload_rate_limited`, `record_upload_attempt`).
33
+
34
+ ### Ce qui reste dans le core (définitif)
35
+
36
+ - Les **validators purs** (`validate_extension`, `validate_mime_type`,
37
+ `validate_size`) et la hiérarchie d'exceptions `UploadError` : `core/forms`
38
+ (`FileField`) en dépend, et le core ne peut pas dépendre d'un opt-in (ADR-004).
39
+ Ce sont des contrôles purs sans I/O. `forge-mvc-files` les réutilise.
40
+
41
+ ## Plan d'exécution (ADR-019)
42
+
43
+ | Ticket | Description | État |
44
+ |---|---|---|
45
+ | `FILES-PKG-SCAFFOLD-001` | Squelette du paquet + enregistrement opt-in | livré |
46
+ | `FILES-VALIDATORS-KEEP-001` | Relocaliser validators + exceptions dans le core | livré |
47
+ | `FILES-MOVE-PIPELINE-001` | Déplacer manager + storage + rate_limit | livré |
48
+ | `FILES-IMAGES-REPOINT-001` | forge-mvc-images dépend de forge-mvc-files | livré |
49
+ | `FILES-CLI-RENAME-001` | Générateurs + forge_cli/uploads + starter | livré |
50
+ | `FILES-DOCS-PERIMETER-001` | Docs + ADR-004 + CLAUDE.md §3 | livré |
51
+ | `CORE-DROP-UPLOADS-001` | Suppression de `core/uploads/` | livré |
52
+
53
+ ## Installation (mode éditable, depuis les sources)
54
+
55
+ ```bash
56
+ git clone https://github.com/caucrogeGit/Forge.git
57
+ cd Forge
58
+ pip install -e packages/forge-mvc-files/
59
+ ```
60
+
61
+ ## Référence
62
+
63
+ - `docs/adr/019-upload-extraction.md` — décision et périmètre figés.
64
+ - Charte principes 8 (noyau minimal), 11 (une seule façon officielle).
@@ -0,0 +1,75 @@
1
+ """Forge MVC Files — module opt-in propriétaire de l'upload générique.
2
+
3
+ Ce paquet détient le **pipeline d'upload générique** extrait du core (ADR-019) :
4
+ écriture disque sécurisée (anti-traversal), service de fichiers (`serve_media_file`,
5
+ streaming HTTP Range), suppression, rate-limit d'upload, et l'API
6
+ ``save_upload`` / ``SavedUpload``.
7
+
8
+ Depuis ``FILES-MOVE-PIPELINE-001``, ``manager`` + ``storage`` + ``rate_limit``
9
+ vivent ici. La **validation pure** (validators + exceptions ``UploadError``)
10
+ reste dans le core (``core.forms.upload_validation`` / ``upload_exceptions``),
11
+ réutilisée ici : le core ne peut pas dépendre d'un opt-in (ADR-004). Le core
12
+ expose des **shims transitoires** (``core/uploads``) supprimés au ticket
13
+ ``CORE-DROP-UPLOADS-001``. Voir ``docs/adr/019-upload-extraction.md``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "1.0.0b16"
19
+
20
+ # Validation/exceptions : réexport depuis le core (où elles restent).
21
+ from core.forms.upload_exceptions import (
22
+ UploadError,
23
+ UploadInvalidExtensionError,
24
+ UploadInvalidMimeTypeError,
25
+ UploadStorageError,
26
+ UploadTooLargeError,
27
+ )
28
+ from forge_mvc_files.manager import (
29
+ SavedUpload,
30
+ delete_media_file,
31
+ delete_upload,
32
+ get_upload_path,
33
+ save_upload,
34
+ serve_media_file,
35
+ upload_root,
36
+ )
37
+ from forge_mvc_files.rate_limit import (
38
+ is_upload_rate_limited,
39
+ record_upload_attempt,
40
+ )
41
+ from forge_mvc_files.storage import (
42
+ delete_file,
43
+ is_safe_media_path,
44
+ media_path_to_storage_path,
45
+ normalize_media_path,
46
+ save_bytes,
47
+ secure_filename,
48
+ )
49
+
50
+ __all__ = [
51
+ # Exceptions (réexportées du core)
52
+ "UploadError",
53
+ "UploadInvalidExtensionError",
54
+ "UploadInvalidMimeTypeError",
55
+ "UploadStorageError",
56
+ "UploadTooLargeError",
57
+ # Upload / service de fichiers (manager)
58
+ "SavedUpload",
59
+ "save_upload",
60
+ "serve_media_file",
61
+ "delete_upload",
62
+ "delete_media_file",
63
+ "get_upload_path",
64
+ "upload_root",
65
+ # Storage (anti-traversal)
66
+ "save_bytes",
67
+ "delete_file",
68
+ "normalize_media_path",
69
+ "media_path_to_storage_path",
70
+ "is_safe_media_path",
71
+ "secure_filename",
72
+ # Rate-limit d'upload
73
+ "is_upload_rate_limited",
74
+ "record_upload_attempt",
75
+ ]
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from core.forge import get as _cfg
9
+ from core.http.response import Response
10
+
11
+ # FILES-MOVE-PIPELINE-001 (ADR-019) : le pipeline d'upload vit désormais dans
12
+ # forge-mvc-files. La validation pure (validators + exceptions) reste dans le
13
+ # core (core/forms), réutilisée ici (le core ne peut pas dépendre de l'opt-in).
14
+ from core.forms.upload_exceptions import UploadStorageError
15
+ from core.forms.upload_validation import validate_upload_metadata
16
+ from forge_mvc_files import storage
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SavedUpload:
21
+ filename: str
22
+ original_name: str
23
+ path: str
24
+ category: str
25
+ size: int
26
+ mime_type: str | None = None
27
+ variants: dict[str, str] = field(default_factory=dict)
28
+
29
+
30
+ def _read_upload(file) -> tuple[str, str | None, bytes]:
31
+ filename = getattr(file, "filename", None) or getattr(file, "name", None)
32
+ mime_type = (
33
+ getattr(file, "content_type", None)
34
+ or getattr(file, "mimetype", None)
35
+ or getattr(file, "mime_type", None)
36
+ )
37
+
38
+ if hasattr(file, "content"):
39
+ data = file.content
40
+ elif hasattr(file, "read"):
41
+ data = file.read()
42
+ elif hasattr(file, "stream") and hasattr(file.stream, "read"):
43
+ data = file.stream.read()
44
+ else:
45
+ data = file
46
+
47
+ if isinstance(data, str):
48
+ data = data.encode("utf-8")
49
+ if data is None:
50
+ data = b""
51
+ if not isinstance(data, (bytes, bytearray)):
52
+ raise TypeError("Le fichier uploadé doit fournir des bytes ou une méthode read().")
53
+
54
+ return filename, mime_type, bytes(data)
55
+
56
+
57
+ # ADR-032 : la config de stockage et de validation d'upload appartient à
58
+ # l'opt-in files, lue directement depuis l'environnement. Seul `upload_max_size`
59
+ # reste détenu par le noyau (borne le corps multipart dans core/http/request.py).
60
+ _DEFAULT_EXTENSIONS = "jpg,jpeg,png,webp,pdf"
61
+ _DEFAULT_MIME_TYPES = "image/jpeg,image/png,image/webp,application/pdf"
62
+
63
+
64
+ def _env_list(key: str, default: str) -> list[str]:
65
+ return [item.strip() for item in os.getenv(key, default).split(",") if item.strip()]
66
+
67
+
68
+ def upload_root() -> Path:
69
+ return Path(os.getenv("UPLOAD_ROOT", "storage/uploads"))
70
+
71
+
72
+ def _require_image_processing(name: str):
73
+ """Résout un helper de traitement d'image depuis l'opt-in forge-mvc-images.
74
+
75
+ IMAGES-MOVE-PROCESSING-001 (ADR-018) : le traitement d'image (Pillow) a été
76
+ extrait du core vers ``forge-mvc-images``. Depuis
77
+ CORE-SAVEUPLOAD-GENERIC-CLEANUP, ``save_upload`` est purement générique ; il
78
+ ne reste qu'un seul appelant de ce delegate dans le core :
79
+ ``delete_media_file(variants=True)``, qui a besoin des chemins de variantes
80
+ (``image_variant_relative_paths``) pour supprimer les fichiers dérivés. Si
81
+ l'opt-in est absent, l'erreur est explicite plutôt qu'un ``ImportError`` brut
82
+ (charte §7 — sécuriser/échouer clairement).
83
+ """
84
+ try:
85
+ import forge_mvc_images
86
+ except ImportError as exc: # pragma: no cover - dépend de l'environnement
87
+ raise UploadStorageError(
88
+ "Le traitement d'image requiert l'opt-in forge-mvc-images "
89
+ "(pip install forge-mvc-images)."
90
+ ) from exc
91
+ return getattr(forge_mvc_images, name)
92
+
93
+
94
+ def save_upload(file, category: str = "documents") -> SavedUpload:
95
+ """Upload brut **générique** : valide, écrit, retourne un SavedUpload.
96
+
97
+ CORE-SAVEUPLOAD-GENERIC-CLEANUP (ADR-018) : ``save_upload`` ne connaît plus
98
+ rien des images (ni vérification de contenu, ni variantes). Le chemin
99
+ image-aware (vérification + variantes) appartient à l'opt-in
100
+ ``forge-mvc-images`` (``save_image_upload``), qui s'appuie lui-même sur cette
101
+ primitive générique. ``variants`` renvoyé est toujours vide ici.
102
+ """
103
+ if file is None:
104
+ raise UploadStorageError("Aucun fichier reçu.")
105
+
106
+ filename, mime_type, data = _read_upload(file)
107
+ validate_upload_metadata(
108
+ filename=filename,
109
+ size=len(data),
110
+ mime_type=mime_type,
111
+ allowed_extensions=_env_list("UPLOAD_ALLOWED_EXTENSIONS", _DEFAULT_EXTENSIONS),
112
+ allowed_mime_types=_env_list("UPLOAD_ALLOWED_MIME_TYPES", _DEFAULT_MIME_TYPES),
113
+ max_size=int(_cfg("upload_max_size")),
114
+ )
115
+ root = upload_root()
116
+ saved_path = storage.save_bytes(
117
+ data,
118
+ original_name=filename,
119
+ category=category,
120
+ root=root,
121
+ )
122
+ relative_path = saved_path.relative_to(root.resolve()).as_posix()
123
+ normalized_path = storage.normalize_media_path(relative_path)
124
+
125
+ return SavedUpload(
126
+ filename=saved_path.name,
127
+ original_name=filename,
128
+ path=normalized_path,
129
+ category=category,
130
+ size=len(data),
131
+ mime_type=mime_type,
132
+ variants={},
133
+ )
134
+
135
+
136
+ def delete_upload(path) -> bool:
137
+ return storage.delete_file(path, root=upload_root())
138
+
139
+
140
+ def delete_media_file(path: str, *, root: str | Path | None = None, variants: bool = False) -> dict[str, bool]:
141
+ if root is None:
142
+ root = upload_root()
143
+
144
+ relative_path = storage.normalize_media_path(path)
145
+ paths = {"original": relative_path}
146
+ if variants:
147
+ image_variant_relative_paths = _require_image_processing(
148
+ "image_variant_relative_paths"
149
+ )
150
+ paths = image_variant_relative_paths(relative_path)
151
+
152
+ return {
153
+ media_path: storage.delete_file(media_path, root=root)
154
+ for media_path in paths.values()
155
+ }
156
+
157
+
158
+ def serve_media_file(path: str, *, root: str | Path | None = None) -> Response:
159
+ if root is None:
160
+ root = upload_root()
161
+
162
+ try:
163
+ relative_path = storage.normalize_media_path(path)
164
+ target = storage.media_path_to_storage_path(relative_path, root=root)
165
+ if not target.exists() or not target.is_file():
166
+ return Response(404, b"Not found", "text/plain; charset=utf-8")
167
+ content = target.read_bytes()
168
+ except (OSError, UploadStorageError):
169
+ return Response(404, b"Not found", "text/plain; charset=utf-8")
170
+
171
+ content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
172
+ return Response(200, content, content_type)
173
+
174
+
175
+ def get_upload_path(filename: str, category: str = "documents") -> Path:
176
+ return storage.get_upload_path(filename, category, root=upload_root())
@@ -0,0 +1,53 @@
1
+ """Rate limiting pour les routes d'upload (mémoire, fenêtre glissante).
2
+
3
+ Même structure que core.security.hashing pour le login : dict en mémoire,
4
+ thread-safe, fenêtre glissante. Les compteurs upload sont isolés des
5
+ compteurs de connexion.
6
+
7
+ Usage dans un contrôleur :
8
+
9
+ from forge_mvc_files.rate_limit import is_upload_rate_limited, record_upload_attempt
10
+
11
+ def upload_avatar(request):
12
+ if is_upload_rate_limited(request.ip):
13
+ body = json.dumps({
14
+ "success": False,
15
+ "error": {"code": "rate_limited", "message": "Trop d'uploads."},
16
+ }).encode()
17
+ return Response(429, body, "application/json")
18
+ record_upload_attempt(request.ip)
19
+ # ... traitement normal
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ import time
25
+
26
+ UPLOAD_MAX_PAR_FENETRE: int = 10 # uploads autorisés par fenêtre par IP
27
+ UPLOAD_RATE_LIMIT_WINDOW: int = 60 # durée de la fenêtre glissante (secondes)
28
+
29
+ _compteurs: dict[str, list[float]] = {}
30
+ _lock = threading.Lock()
31
+
32
+
33
+ def is_upload_rate_limited(ip: str) -> bool:
34
+ """Retourne True si l'IP a atteint la limite d'uploads sur la fenêtre courante."""
35
+ with _lock:
36
+ maintenant = time.time()
37
+ historique = _compteurs.get(ip, [])
38
+ recents = [t for t in historique if maintenant - t < UPLOAD_RATE_LIMIT_WINDOW]
39
+ if recents:
40
+ _compteurs[ip] = recents
41
+ else:
42
+ _compteurs.pop(ip, None)
43
+ return len(recents) >= UPLOAD_MAX_PAR_FENETRE
44
+
45
+
46
+ def record_upload_attempt(ip: str) -> None:
47
+ """Enregistre une tentative d'upload pour cette IP."""
48
+ with _lock:
49
+ maintenant = time.time()
50
+ historique = _compteurs.get(ip, [])
51
+ historique.append(maintenant)
52
+ recents = [t for t in historique if maintenant - t < UPLOAD_RATE_LIMIT_WINDOW]
53
+ _compteurs[ip] = recents
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import posixpath
5
+ import re
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ # FILES-MOVE-PIPELINE-001 (ADR-019) : validation/exceptions restent dans le core.
10
+ from core.forms.upload_exceptions import UploadStorageError
11
+
12
+
13
+ _SAFE_CATEGORY = re.compile(r"^[A-Za-z0-9_-]+$")
14
+ _UNSAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
15
+ _URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
16
+
17
+
18
+ def ensure_upload_dirs(root: str | Path, categories=("images", "documents", "tmp")) -> list[Path]:
19
+ root_path = Path(root)
20
+ created: list[Path] = []
21
+ root_path.mkdir(parents=True, exist_ok=True)
22
+ created.append(root_path)
23
+ for category in categories:
24
+ directory = root_path / safe_category(category)
25
+ directory.mkdir(parents=True, exist_ok=True)
26
+ created.append(directory)
27
+ return created
28
+
29
+
30
+ def safe_category(category: str) -> str:
31
+ category = (category or "").strip()
32
+ if not category or not _SAFE_CATEGORY.fullmatch(category):
33
+ raise UploadStorageError(f"Categorie d'upload invalide : {category!r}.")
34
+ return category
35
+
36
+
37
+ def secure_filename(filename: str) -> str:
38
+ base = os.path.basename(filename or "").strip().replace(" ", "_")
39
+ base = _UNSAFE_CHARS.sub("_", base).strip("._")
40
+ if not base:
41
+ raise UploadStorageError("Nom de fichier vide apres normalisation.")
42
+ return base
43
+
44
+
45
+ def generate_unique_filename(original_name: str) -> str:
46
+ safe_name = secure_filename(original_name)
47
+ path = Path(safe_name)
48
+ stem = path.stem or "upload"
49
+ suffix = path.suffix.lower()
50
+ return f"{stem}-{uuid4().hex}{suffix}"
51
+
52
+
53
+ def normalize_media_path(path: str) -> str:
54
+ """Retourne un chemin media relatif, normalise et sur.
55
+
56
+ Le chemin retourne est destine a etre stocke en base, typiquement dans
57
+ `Media.path`. Il est toujours relatif a `storage/uploads`.
58
+ """
59
+ if not isinstance(path, str):
60
+ raise UploadStorageError("Chemin media invalide.")
61
+
62
+ value = path.strip()
63
+ if not value:
64
+ raise UploadStorageError("Chemin media vide.")
65
+ if "\x00" in value:
66
+ raise UploadStorageError("Chemin media invalide.")
67
+ if _URI_SCHEME.match(value):
68
+ raise UploadStorageError("Chemin media absolu ou URL interdit.")
69
+
70
+ value = value.replace("\\", "/")
71
+ while "//" in value:
72
+ value = value.replace("//", "/")
73
+ if value.startswith("/"):
74
+ raise UploadStorageError("Chemin media absolu interdit.")
75
+
76
+ normalized = posixpath.normpath(value)
77
+ if normalized in {"", "."}:
78
+ raise UploadStorageError("Chemin media vide.")
79
+ if normalized == "storage/uploads":
80
+ raise UploadStorageError("Chemin media incomplet.")
81
+ if normalized.startswith("storage/uploads/"):
82
+ normalized = normalized[len("storage/uploads/"):]
83
+
84
+ parts = normalized.split("/")
85
+ if any(part in {"", ".", ".."} for part in parts):
86
+ raise UploadStorageError("Chemin media avec traversal interdit.")
87
+ if normalized.startswith("../") or normalized == "..":
88
+ raise UploadStorageError("Chemin media avec traversal interdit.")
89
+ return normalized
90
+
91
+
92
+ def is_safe_media_path(path: str) -> bool:
93
+ try:
94
+ normalize_media_path(path)
95
+ except UploadStorageError:
96
+ return False
97
+ return True
98
+
99
+
100
+ def media_path_to_storage_path(path: str, *, root: str | Path) -> Path:
101
+ root_path = Path(root).resolve()
102
+ relative_path = normalize_media_path(path)
103
+ target = (root_path / relative_path).resolve()
104
+ try:
105
+ if os.path.commonpath([root_path, target]) != str(root_path):
106
+ raise UploadStorageError("Chemin media hors du dossier d'upload.")
107
+ except ValueError as exc:
108
+ raise UploadStorageError("Chemin media invalide.") from exc
109
+ return target
110
+
111
+
112
+ def category_dir(root: str | Path, category: str) -> Path:
113
+ root_path = Path(root).resolve()
114
+ directory = (root_path / safe_category(category)).resolve()
115
+ try:
116
+ if os.path.commonpath([root_path, directory]) != str(root_path):
117
+ raise UploadStorageError("Chemin d'upload hors du dossier racine.")
118
+ except ValueError as exc:
119
+ raise UploadStorageError("Chemin d'upload invalide.") from exc
120
+ directory.mkdir(parents=True, exist_ok=True)
121
+ return directory
122
+
123
+
124
+ def get_upload_path(filename: str, category: str, *, root: str | Path) -> Path:
125
+ filename = secure_filename(filename)
126
+ target = (category_dir(root, category) / filename).resolve()
127
+ root_path = Path(root).resolve()
128
+ try:
129
+ if os.path.commonpath([root_path, target]) != str(root_path):
130
+ raise UploadStorageError("Chemin d'upload hors du dossier racine.")
131
+ except ValueError as exc:
132
+ raise UploadStorageError("Chemin d'upload invalide.") from exc
133
+ return target
134
+
135
+
136
+ def save_bytes(data: bytes, *, original_name: str, category: str, root: str | Path) -> Path:
137
+ directory = category_dir(root, category)
138
+ for _ in range(20):
139
+ filename = generate_unique_filename(original_name)
140
+ target = directory / filename
141
+ try:
142
+ with target.open("xb") as file:
143
+ file.write(data)
144
+ return target
145
+ except FileExistsError:
146
+ continue
147
+ except OSError as exc:
148
+ raise UploadStorageError(f"Impossible d'enregistrer le fichier : {exc}") from exc
149
+ raise UploadStorageError("Impossible de generer un nom de fichier unique.")
150
+
151
+
152
+ def delete_file(path: str | Path, *, root: str | Path) -> bool:
153
+ root_path = Path(root).resolve()
154
+ target = Path(path)
155
+ if not target.is_absolute():
156
+ target = root_path / target
157
+ target = target.resolve()
158
+ try:
159
+ if os.path.commonpath([root_path, target]) != str(root_path):
160
+ raise UploadStorageError("Suppression refusee hors du dossier d'upload.")
161
+ except ValueError as exc:
162
+ raise UploadStorageError("Chemin d'upload invalide.") from exc
163
+ if not target.exists():
164
+ return False
165
+ if not target.is_file():
166
+ raise UploadStorageError("Suppression refusee : le chemin n'est pas un fichier.")
167
+ try:
168
+ target.unlink()
169
+ return True
170
+ except OSError as exc:
171
+ raise UploadStorageError(f"Impossible de supprimer le fichier : {exc}") from exc
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: forge-mvc-files
3
+ Version: 1.0.0b16
4
+ Summary: Forge Files — module opt-in propriétaire de l'upload générique : écriture sécurisée, storage anti-traversal, service de fichiers, rate-limit. Extrait du core (ADR-019).
5
+ Author: Roger Lequette
6
+ License-Expression: LicenseRef-Forge-Proprietary
7
+ Project-URL: Homepage, https://github.com/caucrogeGit/Forge
8
+ Project-URL: Repository, https://github.com/caucrogeGit/Forge
9
+ Project-URL: Documentation, https://forgemvc.com/docs/forge/
10
+ Keywords: python,mvc,forge,files,upload,storage
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: forge-mvc<2,>=1.0.0b16
20
+ Dynamic: license-file
21
+
22
+ # forge-mvc-files
23
+
24
+ Module **opt-in** propriétaire de l'**upload générique** dans Forge MVC :
25
+ écriture disque sécurisée, storage anti-traversal, service de fichiers
26
+ (streaming HTTP Range), suppression et rate-limit d'upload.
27
+
28
+ ## Statut : extraction complète (ADR-019)
29
+
30
+ `forge-mvc-files` détient désormais **tout le pipeline d'upload générique**,
31
+ extrait du core : `save_upload`, `SavedUpload`, `serve_media_file` (HTTP Range),
32
+ storage anti-traversal, suppression et rate-limit. `core/uploads/` a été
33
+ supprimé du core (`CORE-DROP-UPLOADS-001`). La **validation pure** de fichier
34
+ (extension/MIME/taille + `UploadError`) reste dans le core (`core.forms`),
35
+ réutilisée par ce paquet.
36
+
37
+ Ce paquet cible la publication PyPI de la release beta.13.
38
+
39
+ ## Pourquoi ce module (ADR-019)
40
+
41
+ Après l'extraction du traitement d'image (ADR-018), l'upload **générique** reste
42
+ le dernier gros bloc applicatif logé dans le noyau. Un framework web peut
43
+ exister sans upload de fichiers : c'est une brique applicative, pas un
44
+ fondement. `forge-mvc-files` devient l'**unique** propriétaire de l'upload
45
+ générique (principes 8 « noyau minimal » et 11 « une seule façon officielle »).
46
+
47
+ ### Ce qui a été déplacé du core
48
+
49
+ - `manager` — `save_upload`, `SavedUpload`, `serve_media_file`, `delete_upload`,
50
+ `delete_media_file`, `get_upload_path`, `upload_root`, `_read_upload` ;
51
+ - `storage` — écriture/anti-traversal (`normalize_media_path`,
52
+ `media_path_to_storage_path`, `is_safe_media_path`, `save_bytes`, `delete_file`) ;
53
+ - `rate_limit` d'upload (`is_upload_rate_limited`, `record_upload_attempt`).
54
+
55
+ ### Ce qui reste dans le core (définitif)
56
+
57
+ - Les **validators purs** (`validate_extension`, `validate_mime_type`,
58
+ `validate_size`) et la hiérarchie d'exceptions `UploadError` : `core/forms`
59
+ (`FileField`) en dépend, et le core ne peut pas dépendre d'un opt-in (ADR-004).
60
+ Ce sont des contrôles purs sans I/O. `forge-mvc-files` les réutilise.
61
+
62
+ ## Plan d'exécution (ADR-019)
63
+
64
+ | Ticket | Description | État |
65
+ |---|---|---|
66
+ | `FILES-PKG-SCAFFOLD-001` | Squelette du paquet + enregistrement opt-in | livré |
67
+ | `FILES-VALIDATORS-KEEP-001` | Relocaliser validators + exceptions dans le core | livré |
68
+ | `FILES-MOVE-PIPELINE-001` | Déplacer manager + storage + rate_limit | livré |
69
+ | `FILES-IMAGES-REPOINT-001` | forge-mvc-images dépend de forge-mvc-files | livré |
70
+ | `FILES-CLI-RENAME-001` | Générateurs + forge_cli/uploads + starter | livré |
71
+ | `FILES-DOCS-PERIMETER-001` | Docs + ADR-004 + CLAUDE.md §3 | livré |
72
+ | `CORE-DROP-UPLOADS-001` | Suppression de `core/uploads/` | livré |
73
+
74
+ ## Installation (mode éditable, depuis les sources)
75
+
76
+ ```bash
77
+ git clone https://github.com/caucrogeGit/Forge.git
78
+ cd Forge
79
+ pip install -e packages/forge-mvc-files/
80
+ ```
81
+
82
+ ## Référence
83
+
84
+ - `docs/adr/019-upload-extraction.md` — décision et périmètre figés.
85
+ - Charte principes 8 (noyau minimal), 11 (une seule façon officielle).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ forge_mvc_files/__init__.py
5
+ forge_mvc_files/manager.py
6
+ forge_mvc_files/rate_limit.py
7
+ forge_mvc_files/storage.py
8
+ forge_mvc_files.egg-info/PKG-INFO
9
+ forge_mvc_files.egg-info/SOURCES.txt
10
+ forge_mvc_files.egg-info/dependency_links.txt
11
+ forge_mvc_files.egg-info/requires.txt
12
+ forge_mvc_files.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ forge-mvc<2,>=1.0.0b16
@@ -0,0 +1 @@
1
+ forge_mvc_files
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "forge-mvc-files"
7
+ version = "1.0.0b16"
8
+ description = "Forge Files — module opt-in propriétaire de l'upload générique : écriture sécurisée, storage anti-traversal, service de fichiers, rate-limit. Extrait du core (ADR-019)."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.12"
11
+ authors = [
12
+ { name = "Roger Lequette" },
13
+ ]
14
+ license = "LicenseRef-Forge-Proprietary"
15
+ keywords = ["python", "mvc", "forge", "files", "upload", "storage"]
16
+ dependencies = [
17
+ "forge-mvc>=1.0.0b16,<2",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/caucrogeGit/Forge"
29
+ Repository = "https://github.com/caucrogeGit/Forge"
30
+ Documentation = "https://forgemvc.com/docs/forge/"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["."]
34
+ include = ["forge_mvc_files*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+