Skip to content

Commit 9622e24

Browse files
Ash-Crowchaibaxclaude
authored
feat: stockage des médias en PostgreSQL (alternative au S3) (#482)
* feat: add PostgreSQL database storage backend for media files Add a new `db_storage` app that provides a custom Django Storage backend storing files directly in PostgreSQL. This offers a persistent storage alternative for PaaS deployments (Scalingo, Heroku, etc.) where the filesystem is ephemeral and S3 is not available. Activated via SF_USE_DB_STORAGE=1. Priority: S3 > DB Storage > FileSystem. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 to database storage migration command Adds `migrate_s3_to_db` management command that: - Downloads all files from S3 bucket and stores them as StoredFile entries - Updates hardcoded S3 URLs in Wagtail Revision content_json - Scans URLField/CharField and RichTextField on all models for S3 URLs - Supports --dry-run, --skip-files, and --skip-urls options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add DB storage documentation and update README Add comprehensive documentation for the PostgreSQL media storage feature including activation guide, S3 migration steps, and architecture overview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve ruff E501 and black formatting issues Fix line length violations (>119 chars) in test_migrate_s3.py by extracting S3 env dict to module constant. Remove unused imports and fix black formatting in migrate_s3_to_db.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused BytesIO import in storage.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: conditionner db_storage app et URL à SF_USE_DB_STORAGE L'app db_storage et sa route /db-storage/ ne sont ajoutées que si SF_USE_DB_STORAGE=True, suivant le même pattern que SF_USE_WHITENOISE et PROCONNECT_ACTIVATED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: ruff F405 noqa in settings_test, revert getattr MEDIA_ROOT - Add noqa: F405 for INSTALLED_APPS references from star import - Revert getattr(settings, "MEDIA_ROOT", "") to settings.MEDIA_ROOT since Django provides a default empty string in global_settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add 1 GB disclaimer and DB-to-S3 reverse migration - Add warning that DB storage is not recommended beyond 1 GB of media in settings.py, .env.example, and docs/db-storage.md - Add migrate_db_to_s3 management command to transfer stored files from PostgreSQL back to an S3 bucket (with --dry-run support and skip-if-exists logic) - Add 5 tests for the new command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: apply black formatting to migrate_db_to_s3 files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clarify storage selection order and add VPS use case Replace misleading "Priority" wording with "Selection order" to distinguish code precedence from recommendation. Add VPS to filesystem use cases in documentation table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Attempt fix * fix: handle Revision.content as JSONField in migrate_s3_to_db Wagtail 3+ changed Revision.content_json (TextField) to Revision.content (JSONField/dict). The previous "Attempt fix" renamed the field but still called .replace() on a dict, which would fail at runtime. This fix serializes the JSON content to string for URL search/replace, then parses back. Also adds a regression test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix issue that prevented Images admin view to show * Changed order of routes so that the db_storage works on a dev machine * Allow the use of MinIO for local testing of S3 * Fix migrate_db_to_s3 command * Necessary so that the migration script works * Add the missing migrate_db_to_files script * Update release number * Update scalingo deployment file * Updates after review --------- Co-authored-by: Chaïb Martinez <chaibax@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e2dd8f commit 9622e24

35 files changed

Lines changed: 2798 additions & 887 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ USE_DOCKER=0
1515
USE_UV=0
1616
# SF_USE_WHITENOISE: Set 1 or True to use Whitenoise
1717
SF_USE_WHITENOISE=0
18+
# SF_USE_DB_STORAGE: Set 1 or True to store media files in PostgreSQL
19+
# instead of the filesystem. Useful for PaaS with ephemeral filesystems.
20+
# /!\ Not recommended beyond 1 GB of media — prefer S3 for larger volumes.
21+
# Selection order: S3_HOST wins if set, then SF_USE_DB_STORAGE, then filesystem (default)
22+
SF_USE_DB_STORAGE=0
1823

1924
DATABASE_NAME=djdb
2025
DATABASE_USER=dju
@@ -31,6 +36,9 @@ S3_BUCKET_NAME=
3136
S3_BUCKET_REGION=eu-west-3
3237
# S3_LOCATION: If the S3 bucket is shared, add a unique folder name
3338
S3_LOCATION=
39+
# S3_PUBLIC_HOST: If the S3 bucket has a different public address (for example when using MinIO), specify it
40+
# This disables the signature on the URLs, so the bucket must allow public read access.
41+
S3_PUBLIC_HOST=
3442

3543
# (Optional) FORCE_SCRIPT_NAME: used to allow to the site to be served under a sub-path
3644
FORCE_SCRIPT_NAME=

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ dmypy.json
145145
.VSCode
146146
.vscode/
147147

148+
# AI stuff
149+
.claude/
150+
148151
# Project-specific stuff
149152
config.json
150153
cron.json

ONBOARDING.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,48 @@ psql -c "CREATE USER sitesconformes WITH CREATEDB LOGIN PASSWORD 'votre_mot_de_p
113113
psql -c "CREATE DATABASE sitesconformes OWNER sitesconformes;" -U postgres
114114
```
115115

116+
### Utilisation de MinIO
117+
118+
[MinIO](https://min.io/) permet de simuler un stockage objet compatible S3 en local, utile pour tester la configuration de production sans avoir besoin d'un vrai bucket S3.
119+
120+
#### Lancer MinIO
121+
122+
```sh
123+
docker run -d \
124+
--name minio \
125+
-p 9000:9000 \
126+
-p 9001:9001 \
127+
-v ~/minio-data:/data \
128+
-e MINIO_ROOT_USER=admin \
129+
-e MINIO_ROOT_PASSWORD=password123 \
130+
quay.io/minio/minio server /data --console-address ":9001"
131+
```
132+
133+
#### Créer le bucket
134+
135+
Accéder à la console MinIO sur <http://localhost:9001> (identifiants : `admin` / `password123`), puis créer un bucket (par exemple `sc-local`).
136+
137+
Pour éviter l'utilisation d'URLs signées (plus simple en local), rendre le bucket public : _Buckets → sc-local → Anonymous → Add Access Rule → Prefix `/`, Access `readonly`_.
138+
139+
#### Variables d'environnement
140+
141+
Ajouter les variables suivantes dans le fichier `.env` :
142+
143+
```sh
144+
S3_HOST=host.docker.internal:9000
145+
S3_PUBLIC_HOST=localhost:9000
146+
S3_PROTOCOL=http
147+
S3_KEY_ID=admin
148+
S3_KEY_SECRET=password123
149+
S3_BUCKET_NAME=sc-local
150+
S3_BUCKET_REGION=
151+
S3_LOCATION=medias/
152+
```
153+
154+
> **Note :** C'est la variable `S3_HOST` qui active le stockage S3 dans l'application. Sans elle, les médias seront stockés sur le système de fichiers local, quelle que soit la configuration MinIO.
155+
156+
Alternativement, il est également possible de passer par un stockage des fichiers directement dans la base PostgreSQL, cf. [documentation](./docs/db-storage.md)
157+
116158
## Fonctionnement depuis un sous-répertoire
117159

118160
Lorsque la variable `FORCE_SCRIPT_NAME` est configurée, le site tourne dans un sous-répertoire, fonctionnalité qui n’est pas gérée par le serveur de développement de base de Django (`runserver`).

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Sites Conformes est développé en utilisant le framework [Django](https://www.d
4848
- **[django-dsfr](https://github.com/numerique-gouv/django-dsfr)** : Permet d’utiliser facilement le [système de design de l’État](https://www.systeme-de-design.gouv.fr/) dans des templates Django.
4949
- **events** : Similaire à `blog`, mais permet de gérer des événements et des pages de calendrier, ainsi que les exports iCal correspondants.
5050
- **forms** : implémentation du [module de création de formulaire](https://docs.wagtail.org/en/stable/reference/contrib/forms/index.html) de Wagtail, par exemple pour les pages de contact. Volontairement assez limité (suffisant pour un formulaire de contact mais pas beaucoup plus), pour les cas complexes il vaut mieux privilégier l’intégration de [Démarches simplifiées](https://www.demarches-simplifiees.fr) ou de [Grist](https://grist.numerique.gouv.fr/).
51+
- **db_storage** : stockage des médias en base de données PostgreSQL, alternative au S3 pour les PaaS avec filesystem éphémère (cf. [documentation](./docs/db-storage.md))
5152
- **proconnect** : permet la connexion via [ProConnect](https://www.proconnect.gouv.fr/)
5253

5354
### Structure du dépôt

config/settings.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ def getenv_bool(key: str, default: bool):
5858
# Allow enabling WhiteNoise via an environment variable (disabled by default)
5959
SF_USE_WHITENOISE = getenv_bool("SF_USE_WHITENOISE", False)
6060

61+
# Allow storing media files in PostgreSQL instead of the filesystem (disabled by default)
62+
# Useful for PaaS deployments with ephemeral filesystems (Scalingo, Heroku, etc.)
63+
# /!\ Not recommended beyond 1 GB of media — prefer S3 for larger volumes.
64+
# Selection order: S3_HOST wins if set, then SF_USE_DB_STORAGE, then filesystem (default)
65+
SF_USE_DB_STORAGE = getenv_bool("SF_USE_DB_STORAGE", False)
66+
6167
INTERNAL_IPS = [
6268
"127.0.0.1",
6369
]
@@ -107,6 +113,9 @@ def getenv_bool(key: str, default: bool):
107113
"wagtail.admin",
108114
]
109115

116+
if SF_USE_DB_STORAGE:
117+
INSTALLED_APPS.insert(-1, "db_storage")
118+
110119
if SF_USE_WHITENOISE:
111120
INSTALLED_APPS.insert(0, "whitenoise.runserver_nostatic")
112121

@@ -272,22 +281,47 @@ def show_toolbar(request):
272281
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
273282

274283
if os.getenv("S3_HOST"):
275-
endpoint_url = f"{os.getenv('S3_PROTOCOL', 'https')}://{os.getenv('S3_HOST')}"
284+
protocol = os.getenv("S3_PROTOCOL", "https")
285+
endpoint_url = f"{protocol}://{os.getenv('S3_HOST')}"
286+
bucket_name = os.getenv("S3_BUCKET_NAME", "set-bucket-name")
287+
public_host = os.getenv("S3_PUBLIC_HOST", "")
288+
289+
options = {
290+
"bucket_name": bucket_name,
291+
"access_key": os.getenv("S3_KEY_ID", "123"),
292+
"secret_key": os.getenv("S3_KEY_SECRET", "secret"),
293+
"endpoint_url": endpoint_url,
294+
"region_name": os.getenv("S3_BUCKET_REGION", "fr"),
295+
"file_overwrite": False,
296+
"location": os.getenv("S3_LOCATION", ""),
297+
}
298+
299+
if public_host:
300+
# Presigned URLs bind the signature to the endpoint hostname, so when the
301+
# internal endpoint differs from the public one, signing must be disabled.
302+
# The bucket is appended to custom_domain to produce path-style URLs for MinIO.
303+
options["custom_domain"] = f"{public_host}/{bucket_name}"
304+
options["url_protocol"] = f"{protocol}:"
305+
options["querystring_auth"] = False
306+
public_endpoint = f"{protocol}://{public_host}"
307+
else:
308+
public_endpoint = endpoint_url
276309

277310
STORAGES["default"] = {
278311
"BACKEND": "storages.backends.s3.S3Storage",
279-
"OPTIONS": {
280-
"bucket_name": os.getenv("S3_BUCKET_NAME", "set-bucket-name"),
281-
"access_key": os.getenv("S3_KEY_ID", "123"),
282-
"secret_key": os.getenv("S3_KEY_SECRET", "secret"),
283-
"endpoint_url": endpoint_url,
284-
"region_name": os.getenv("S3_BUCKET_REGION", "fr"),
285-
"file_overwrite": False,
286-
"location": os.getenv("S3_LOCATION", ""),
287-
},
312+
"OPTIONS": options,
288313
}
289314

290-
MEDIA_URL = f"{endpoint_url}/"
315+
MEDIA_URL = f"{public_endpoint}/"
316+
elif SF_USE_DB_STORAGE:
317+
STORAGES["default"] = {
318+
"BACKEND": "db_storage.storage.DatabaseStorage",
319+
}
320+
MEDIA_URL = os.getenv("MEDIA_URL", "db-storage/")
321+
MEDIA_ROOT = os.path.join(BASE_DIR, os.getenv("MEDIA_ROOT", ""))
322+
323+
if FORCE_SCRIPT_NAME and not MEDIA_URL.startswith(FORCE_SCRIPT_NAME):
324+
MEDIA_URL = f"{FORCE_SCRIPT_NAME}/{MEDIA_URL}"
291325
else:
292326
STORAGES["default"] = {
293327
"BACKEND": "django.core.files.storage.FileSystemStorage",

config/settings_test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from config.settings import * # NOSONAR # noqa: F401,F403
22

3+
# Enable db_storage for testing its functionality
4+
SF_USE_DB_STORAGE = True
5+
if "db_storage" not in INSTALLED_APPS: # noqa: F405
6+
INSTALLED_APPS.insert(-1, "db_storage") # noqa: F405
7+
38
WHITENOISE_AUTOREFRESH = True
49
WHITENOISE_MANIFEST_STRICT = False
510

config/urls.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@
2222
"robots.txt",
2323
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
2424
),
25-
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
25+
]
26+
27+
if settings.SF_USE_DB_STORAGE:
28+
urlpatterns += [
29+
path("db-storage/", include("db_storage.urls")),
30+
]
31+
32+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
2633

2734
# Only add this on a dev machine, outside of tests
2835
if not settings.TESTING and settings.DEBUG and "localhost" in settings.HOST_URL:

db_storage/__init__.py

Whitespace-only changes.

db_storage/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class DbStorageConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "db_storage"

db_storage/management/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)