Skip to content

Commit 2e1c480

Browse files
committed
feat: add configurable Cache-Control for media files (S3 + db_storage)
- Add MEDIA_CACHE_CONTROL constant backed by env var (default: 1h) - Wire it through S3 object_parameters for new uploads - Replace hardcoded value in db_storage ServeFileView - Add set_s3_cache_control backfill command for existing S3 objects
1 parent b94a69a commit 2e1c480

3 files changed

Lines changed: 182 additions & 1 deletion

File tree

config/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222

2323
load_dotenv()
2424

25+
# Cache-Control default for all served media files.
26+
# Used by S3 object_parameters, db_storage view, and backfill command.
27+
# Override per-deployment via the MEDIA_CACHE_CONTROL env var.
28+
MEDIA_CACHE_CONTROL = os.getenv("MEDIA_CACHE_CONTROL", "public, max-age=3600")
29+
2530
# Build paths inside the project like this: BASE_DIR / 'subdir'.
2631
BASE_DIR = Path(__file__).resolve().parent.parent
2732

@@ -286,6 +291,8 @@ def show_toolbar(request):
286291
bucket_name = os.getenv("S3_BUCKET_NAME", "set-bucket-name")
287292
public_host = os.getenv("S3_PUBLIC_HOST", "")
288293

294+
s3_cache_control = os.getenv("S3_CACHE_CONTROL", MEDIA_CACHE_CONTROL)
295+
289296
options = {
290297
"bucket_name": bucket_name,
291298
"access_key": os.getenv("S3_KEY_ID", "123"),
@@ -294,6 +301,9 @@ def show_toolbar(request):
294301
"region_name": os.getenv("S3_BUCKET_REGION", "fr"),
295302
"file_overwrite": False,
296303
"location": os.getenv("S3_LOCATION", ""),
304+
"object_parameters": {
305+
"CacheControl": s3_cache_control,
306+
},
297307
}
298308

299309
if public_host:
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Set or update Cache-Control headers on all existing objects in the S3 bucket.
3+
4+
This is a one-shot backfill command for objects uploaded before the
5+
``object_parameters["CacheControl"]`` setting was added to ``config/settings.py``.
6+
7+
Usage::
8+
9+
python manage.py set_s3_cache_control # apply
10+
python manage.py set_s3_cache_control --dry-run # preview
11+
python manage.py set_s3_cache_control --cache-control "public, max-age=604800" # custom value
12+
13+
The default ``cache-control`` value matches ``config/settings.py``:
14+
``public, max-age=31536000, immutable``.
15+
"""
16+
17+
import os
18+
19+
import boto3
20+
from botocore.exceptions import ClientError
21+
from django.conf import settings
22+
from django.core.management.base import BaseCommand, CommandError
23+
24+
25+
class Command(BaseCommand):
26+
help = "Set Cache-Control headers on all existing S3 media objects."
27+
28+
def add_arguments(self, parser):
29+
parser.add_argument(
30+
"--dry-run",
31+
action="store_true",
32+
help="Preview objects that would be updated without making changes.",
33+
)
34+
parser.add_argument(
35+
"--cache-control",
36+
default="",
37+
help='Cache-Control header value (default: same as settings.py, i.e. "public, max-age=31536000, immutable").',
38+
)
39+
40+
def handle(self, *args, **options):
41+
dry_run = options["dry_run"]
42+
header_value = options["cache-control"] or os.getenv(
43+
"S3_CACHE_CONTROL", settings.MEDIA_CACHE_CONTROL
44+
)
45+
46+
s3_config = self._get_s3_config()
47+
if not s3_config:
48+
raise CommandError(
49+
"S3 is not configured. Set S3_HOST, S3_KEY_ID, S3_KEY_SECRET, "
50+
"and S3_BUCKET_NAME environment variables."
51+
)
52+
53+
self.stdout.write(f"S3 endpoint: {s3_config['endpoint_url']}")
54+
self.stdout.write(f"S3 bucket: {s3_config['bucket_name']}")
55+
self.stdout.write(f"S3 location prefix: {s3_config['location'] or '(none)'}")
56+
self.stdout.write(f"Cache-Control value: {header_value}")
57+
self.stdout.write("")
58+
59+
self._set_cache_control(s3_config, header_value, dry_run)
60+
61+
# ─────────────────────────────────────
62+
# S3 configuration helpers
63+
# ─────────────────────────────────────
64+
65+
def _get_s3_config(self):
66+
"""Read S3 configuration from environment (same vars as settings.py)."""
67+
host = os.getenv("S3_HOST")
68+
if not host:
69+
return None
70+
71+
protocol = os.getenv("S3_PROTOCOL", "https")
72+
return {
73+
"endpoint_url": f"{protocol}://{host}",
74+
"bucket_name": os.getenv("S3_BUCKET_NAME", ""),
75+
"access_key": os.getenv("S3_KEY_ID", ""),
76+
"secret_key": os.getenv("S3_KEY_SECRET", ""),
77+
"region_name": os.getenv("S3_BUCKET_REGION", "fr"),
78+
"location": os.getenv("S3_LOCATION", ""),
79+
}
80+
81+
def _get_s3_client(self, s3_config):
82+
"""Create a boto3 S3 client."""
83+
return boto3.client(
84+
"s3",
85+
endpoint_url=s3_config["endpoint_url"],
86+
aws_access_key_id=s3_config["access_key"],
87+
aws_secret_access_key=s3_config["secret_key"],
88+
region_name=s3_config["region_name"],
89+
)
90+
91+
# ─────────────────────────────────────
92+
# Update Cache-Control on existing objects
93+
# ─────────────────────────────────────
94+
95+
def _set_cache_control(self, s3_config, header_value, dry_run):
96+
self.stdout.write(self.style.MIGRATE_HEADING("Listing objects in S3 bucket…"))
97+
98+
client = self._get_s3_client(s3_config)
99+
bucket = s3_config["bucket_name"]
100+
location = s3_config["location"]
101+
102+
# Build prefix if location is set
103+
prefix = f"{location.rstrip('/')}/" if location else ""
104+
105+
updated = 0
106+
skipped = 0
107+
errors = 0
108+
109+
paginator = client.get_paginator("list_objects_v2")
110+
111+
try:
112+
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
113+
contents = page.get("Contents", [])
114+
for obj in contents:
115+
key = obj["Key"]
116+
117+
# Check current Cache-Control
118+
try:
119+
head = client.head_object(Bucket=bucket, Key=key)
120+
except ClientError as e:
121+
errors += 1
122+
self.stderr.write(self.style.ERROR(f" Error reading {key}: {e}"))
123+
continue
124+
125+
current_cc = head.get("CacheControl", "")
126+
if current_cc == header_value:
127+
skipped += 1
128+
continue
129+
130+
if dry_run:
131+
self.stdout.write(
132+
f" [DRY RUN] Would update: {key} "
133+
f"(current: {current_cc or 'none'})"
134+
)
135+
updated += 1
136+
continue
137+
138+
# Copy the object onto itself with new Cache-Control
139+
try:
140+
client.copy_object(
141+
Bucket=bucket,
142+
Key=key,
143+
CopySource={"Bucket": bucket, "Key": key},
144+
MetadataDirective="REPLACE",
145+
CacheControl=header_value,
146+
# Preserve the original content type
147+
ContentType=head.get("ContentType", "application/octet-stream"),
148+
)
149+
updated += 1
150+
self.stdout.write(f" Updated: {key}")
151+
except ClientError as e:
152+
errors += 1
153+
self.stderr.write(self.style.ERROR(f" Error updating {key}: {e}"))
154+
155+
except ClientError as e:
156+
raise CommandError(f"Failed to list objects: {e}")
157+
158+
self.stdout.write("")
159+
if dry_run:
160+
self.stdout.write(
161+
self.style.SUCCESS(
162+
f" [DRY RUN] Would update {updated} object(s), "
163+
f"{skipped} already have the target Cache-Control."
164+
)
165+
)
166+
else:
167+
parts = [f"Updated {updated} object(s), {skipped} already had the correct header."]
168+
if errors:
169+
parts.append(f"{errors} error(s).")
170+
self.stdout.write(self.style.SUCCESS(" ".join(parts)))

sites_conformes/db_storage/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.conf import settings
12
from django.http import HttpResponse, HttpResponseNotFound
23
from django.views import View
34

@@ -25,5 +26,5 @@ def get(self, request):
2526
content_type=stored_file.content_type,
2627
)
2728
response["Content-Length"] = stored_file.size
28-
response["Cache-Control"] = "public, max-age=3600"
29+
response["Cache-Control"] = settings.MEDIA_CACHE_CONTROL
2930
return response

0 commit comments

Comments
 (0)