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.
- forge_mvc_files-1.0.0b16/LICENSE +36 -0
- forge_mvc_files-1.0.0b16/PKG-INFO +85 -0
- forge_mvc_files-1.0.0b16/README.md +64 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files/__init__.py +75 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files/manager.py +176 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files/rate_limit.py +53 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files/storage.py +171 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files.egg-info/PKG-INFO +85 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files.egg-info/SOURCES.txt +12 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files.egg-info/dependency_links.txt +1 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files.egg-info/requires.txt +1 -0
- forge_mvc_files-1.0.0b16/forge_mvc_files.egg-info/top_level.txt +1 -0
- forge_mvc_files-1.0.0b16/pyproject.toml +34 -0
- forge_mvc_files-1.0.0b16/setup.cfg +4 -0
|
@@ -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
|
+
|
|
@@ -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*"]
|