Skip to content

[Nom de la Ressource de Stockage Cloudflare]

[Nom de la Ressource de Stockage Cloudflare]

Documentation technique

Type de ressource : Bucket Cloudflare R2 / Bibliothèque Cloudflare Stream
Fichier source : services/[worker-name]/wrangler.toml ou configuration associée
Statut : [Actif / Déprécié / En développement]

1. Vue d’ensemble

1.1. Description

[Description concise du rôle de cette ressource de stockage dans l’architecture du site]

1.2. Objectif

  • [Objectif principal : ex. stocker les images du blog et du portfolio]
  • [Bénéfice pour l’architecture : ex. découplage des médias du CMS Sanity]

1.3. Périmètre concerné

  • Type de médias : [Images / Vidéos / Documents / Backups / Les deux]
  • Accès : [Public / Privé / URLs signées / Les deux]
  • Consommateurs : [Sanity / Astro / Workers L3 / Les deux]
  • Optimisation : [Cloudflare Polish / Cloudflare Images / Cloudflare Stream / Aucune]

2. Architecture et flux

2.1. Diagramme de flux

[Sanity Studio]
│ (upload via Worker L3)
▼
[Worker media-upload (L3)]
│ • Génération URL présignée R2
│ • Ou Direct Upload Token Stream
▼
[Cloudflare R2 / Stream]
│ • Stockage structuré
│ • Optimisation Edge
▼
[Sanity]
│ • Stocke uniquement URL + métadonnées
▼
[Astro / Site]
│ • Consomme les URLs R2/Stream
▼
[Utilisateur final]

2.2. Composants impliqués

Composant Rôle Couche
Bucket R2 / Bibliothèque Stream Stockage des médias Infrastructure
Worker media-upload Génération d’URLs présignées L3
Worker media-cleanup Nettoyage des orphelins L3
Worker sanity-backup Backups vers R2 L3
Sanity Stockage des métadonnées + URLs L1
Astro Consommation des URLs L2
Cloudflare CDN Distribution Edge Infrastructure

3. Configuration du bucket

3.1. Variables d’environnement requises

Variable Description Obligatoire Valeur par défaut
R2_ACCOUNT_ID ID du compte Cloudflare ✅ -
R2_BUCKET_NAME Nom du bucket (ex: pub-medias) ✅ -
R2_ACCESS_KEY_ID Clé d’accès API R2 ✅ -
R2_SECRET_ACCESS_KEY Clé secrète API R2 ✅ -
R2_PUBLIC_DOMAIN Domaine public (ex: pub-medias.votredomaine.com) ✅ -

3.2. Configuration Wrangler (wrangler.toml)

services/media-upload/wrangler.toml
name = "media-upload"
main = "src/index.ts"
compatibility_date = "2026-09-01"
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "pub-medias"
preview_bucket_name = "pub-medias-staging"
[vars]
R2_ACCOUNT_ID = "your-account-id"
R2_BUCKET_NAME = "pub-medias"
R2_PUBLIC_DOMAIN = "pub-medias.votredomaine.com"
[env.production]
vars = { R2_BUCKET_NAME = "pub-medias" }
[env.staging]
vars = { R2_BUCKET_NAME = "pub-medias-staging" }

3.3. Injection des secrets

Terminal window
# Injection des credentials R2 via Wrangler CLI
wrangler secret put R2_ACCESS_KEY_ID --env production
wrangler secret put R2_SECRET_ACCESS_KEY --env production
wrangler secret put R2_ACCESS_KEY_ID --env staging
wrangler secret put R2_SECRET_ACCESS_KEY --env staging

3.4. Configuration du domaine public

Dans le Dashboard Cloudflare :

  1. R2 → Bucket pub-medias → Settings
  2. Custom domains → Connect Domain
  3. Domaine : pub-medias.votredomaine.com
  4. Cloudflare proxy activé (orange cloud)

4. Organisation des chemins

4.1. Structure des dossiers

pub-medias/
├── medias/
│ ├── images/
│ │ ├── blog/ # Images des articles de blog
│ │ │ └── 2026-09-03-article-title-image.webp
│ │ ├── portfolio/ # Images des projets portfolio
│ │ │ └── project-name-gallery-01.webp
│ │ └── pages/ # Images des pages vitrine
│ │ └── hero-background.webp
│ ├── videos/ # Fichiers sources (si besoin)
│ │ └── raw-video.mp4
│ ├── documents/
│ │ ├── pdf/ # Documents PDF
│ │ │ └── whitepaper-2026.pdf
│ │ └── autres/ # Autres documents
│ │ └── presentation.pptx
│ └── temp/ # Uploads temporaires (nettoyage auto)
│ └── upload-123456.jpg
└── backups/
└── sanity/ # Backups Sanity
└── backup-2026-09-03.tar.gz

4.2. Convention de nommage

Type Format Exemple
Images blog YYYY-MM-DD-slug-image.webp 2026-09-03-mon-article-hero.webp
Images portfolio project-name-gallery-XX.webp projet-alpha-gallery-01.webp
Images pages page-slug-block-image.webp accueil-hero-background.webp
Documents document-name-YYYY.pdf whitepaper-2026.pdf
Temp upload-{timestamp}-{filename} upload-1693735200-image.jpg
Backups backup-YYYY-MM-DD.tar.gz backup-2026-09-03.tar.gz

4.3. Règles d’organisation

  • ✅ Toujours préfixer par medias/ pour les médias du site
  • ✅ Séparer par type (images, videos, documents)
  • ✅ Séparer par contexte (blog, portfolio, pages)
  • ✅ Utiliser des noms de fichiers descriptifs et en kebab-case
  • ✅ Inclure la date dans le nom pour les articles de blog
  • ✅ Le dossier temp/ est réservé aux uploads temporaires

5. Types de médias et traitement

5.1. Images1/2

Aspect Configuration Rationale
Formats WebP, AVIF (auto via Cloudflare) Performance optimale
Optimisation Cloudflare Polish (lossy/lossless) Réduction de taille automatique
Redimensionnement Cloudflare Images (si besoin) Transformation à la volée
Dimensions max 1920x1080 (contenu), 3840x2160 (hero) Équilibre qualité/performance
Poids max 500 KB (contenu), 2 MB (hero) Performance de chargement
Métadonnées Alt, hotspot, dimensions dans Sanity Accessibilité + cropping intelligent

5.1. Images2/2

URL publique :

https://pub-medias.votredomaine.com/medias/images/blog/2026-09-03-article-image.webp

URL avec transformation Cloudflare Images :

https://pub-medias.votredomaine.com/cdn-cgi/image/width=800,height=600,fit=cover/medias/images/blog/2026-09-03-article-image.webp

5.2. Vidéos1/3

Aspect Configuration Rationale
Stockage Cloudflare Stream (pas R2) Transcoding + adaptive bitrate
Upload Direct Upload Token via Worker L3 Évite de transiter par le Worker
Stockage métadonnées UID Stream dans Sanity Référence vers la vidéo
Lecture Iframe Stream ou SDK officiel Player intégré
Durée max 3600 secondes (1 heure) Limite Stream

5.2. Vidéos2/3

UID Stream stocké dans Sanity :

{
"_type": "video",
"streamUid": "abc123def456",
"title": "Présentation du projet"
}

5.2. Vidéos3/3

Embed dans Astro :

<iframe
src="https://iframe.videodelivery.net/abc123def456"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
allowfullscreen
></iframe>

5.3. Documents (PDF, etc.)1/2

Aspect Configuration Rationale
Stockage Cloudflare R2 Simplicité + performance
Accès URL publique ou signée Selon sensibilité
Prévisualisation Miniature générée par Worker (optionnel) UX améliorée
Poids max 10 MB Limite raisonnable

5.3. Documents (PDF, etc.)2/2

URL publique :

https://pub-medias.votredomaine.com/medias/documents/pdf/whitepaper-2026.pdf

URL signée (si sensible) :

https://pub-medias.votredomaine.com/medias/documents/pdf/confidentiel.pdf?X-Amz-Algorithm=...

5.4. Backups Sanity1/2

Aspect Configuration Rationale
Stockage Bucket R2 dédié (backups-sanity) Isolation des backups
Fréquence Quotidienne (Cron: 0 2 * * *) RPO de 24h
Format .tar.gz Compression efficace
Rétention 30 jours (Lifecycle Rules R2) Équilibre coût/sécurité
Streaming Pipe direct Sanity → R2 (pas de RAM) Évite OOM

5.4. Backups Sanity2/2

Chemin de backup :

backups-sanity/backups/sanity/backup-2026-09-03.tar.gz

6. Upload et génération d’URLs présignées

6.1. Flux d’upload (Worker media-upload)1/2

6.1. Flux d’upload (Worker media-upload)2/2

// services/media-upload/src/index.ts (extrait)
import { AwsClient } from 'aws4fetch';
export interface Env {
R2_BUCKET: R2Bucket;
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
R2_BUCKET_NAME: string;
CLOUDFLARE_STREAM_API_TOKEN: string;
CLOUDFLARE_ACCOUNT_ID: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { type, filename, contentType } = await request.json();
// Case 1: Upload vidéo via Cloudflare Stream Direct Upload
if (type === 'video') {
const streamRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/stream/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CLOUDFLARE_STREAM_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ maxDurationSeconds: 3600 }),
}
);
const streamData = await streamRes.json();
return new Response(JSON.stringify({
uploadUrl: streamData.result.uploadURL,
uid: streamData.result.uid
}), { status: 200 });
}
// Case 2: Upload images/documents via URL présignée R2
const aws = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const key = `medias/${type}s/${Date.now()}-${filename}`;
const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${key}`);
url.searchParams.set('X-Amz-Expires', '900'); // Valide 15 minutes
const signedRequest = await aws.sign(new Request(url.toString(), {
method: 'PUT',
headers: { 'Content-Type': contentType },
}), { aws: { signQuery: true } });
return new Response(JSON.stringify({
uploadUrl: signedRequest.url,
fileUrl: `https://${env.R2_PUBLIC_DOMAIN}/${key}`,
key,
}), { status: 200 });
},
};

6.2. Utilisation depuis Sanity Studio

sanity-studio/plugins/media-upload/input.tsx
const handleUpload = async (file: File) => {
const res = await fetch('https://api.votredomaine.com/media-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: file.type.startsWith('image/') ? 'image' : 'document',
filename: file.name,
contentType: file.type,
}),
});
const { uploadUrl, fileUrl, key } = await res.json();
// Upload direct vers R2 (sans transiter par le Worker)
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
// Retourne l'URL publique à Sanity
return {
asset: {
_type: 'file',
url: fileUrl,
metadata: {
key,
originalFilename: file.name,
},
},
};
};

7. Nettoyage et garbage collection

7.1. Worker media-cleanup (Cron hebdomadaire)1/2

Spécifications :

  • Fréquence : 0 3 * * 0 (tous les dimanches à 3h00)
  • Objectif : Supprimer les fichiers orphelins (non référencés dans Sanity depuis 30 jours)
  • Safety Locks :
    1. Annulation si Sanity renvoie moins de 5 documents (protection panne API)
    2. Annulation si suppression > 10% du bucket (protection contre erreur massive)

Code de production :

7.1. Worker media-cleanup (Cron hebdomadaire)2/2

// services/media-cleanup/src/index.ts (extrait)
export default {
async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
// 1. Requête GROQ pour récupérer toutes les URLs référencées
const query = encodeURIComponent(`*[_type in ["post", "project", "page"]]{
"images": [mainImage.asset->url, gallery[].asset->url, pageBuilder[].images[].asset->url]
}`);
const sanityRes = await fetch(
`https://${env.SANITY_PROJECT_ID}.api.sanity.io/v2025-01-01/data/query/${env.SANITY_DATASET}?query=${query}`,
{ headers: { Authorization: `Bearer ${env.SANITY_READ_TOKEN}` } }
);
const sanityData = await sanityRes.json();
const referencedUrls = new Set<string>();
sanityData.result.forEach(doc => {
doc.images?.flat().filter(Boolean).forEach(url => referencedUrls.add(url));
});
// Safety Lock #1
if (referencedUrls.size < 5) {
console.error('SAFETY LOCK : Volume Sanity trop faible. Annulation.');
return;
}
// 2. Listing des objets R2
const objects = await env.R2_BUCKET.list({ prefix: 'medias/' });
const toDelete: string[] = [];
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
for (const obj of objects.objects) {
const publicUrl = `https://${env.R2_PUBLIC_DOMAIN}/${obj.key}`;
const isOrphan = !referencedUrls.has(publicUrl);
const isOlderThan30Days = (now - obj.uploaded.getTime()) > THIRTY_DAYS_MS;
if (isOrphan && isOlderThan30Days) {
toDelete.push(obj.key);
}
}
// Safety Lock #2
if (toDelete.length > (objects.objects.length * 0.10)) {
console.error(`SAFETY LOCK : Suppression > 10%. Annulation.`);
return;
}
// 3. Suppression
for (const key of toDelete) {
await env.R2_BUCKET.delete(key);
}
},
};

7.2. Nettoyage du dossier temp/

  • Fréquence : Quotidienne (Cron: 0 4 * * *)
  • Rétention : 7 jours
  • Méthode : Lifecycle Rules natives de R2 ou Worker dédié

7.3. Lifecycle Rules R2

Règle Condition Action
Suppression temp/ Fichiers > 7 jours Delete
Archivage backups/ Fichiers > 30 jours Delete
Transition videos/raw/ Fichiers > 90 jours Delete (après transcoding Stream)

8. Sécurité et accès

8.1. Types d’accès

Type Usage Configuration
Public Images du site, documents non sensibles Domaine public R2 + CDN Cloudflare
Signé Documents sensibles, previews URLs présignées avec expiration
Privé Backups, fichiers internes Accès via Worker uniquement

8.2. Génération d’URLs signées

// Exemple de génération d'URL signée (valide 1 heure)
const aws = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${key}`);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 heure
const signedRequest = await aws.sign(new Request(url.toString(), {
method: 'GET',
}), { aws: { signQuery: true } });
return signedRequest.url;

8.3. Règles de sécurité

  • ✅ Les credentials R2 ne sont jamais exposés côté client
  • ✅ Les credentials sont injectés via wrangler secret put
  • ✅ Les URLs signées ont une durée de vie limitée (1h à 24h max)
  • ✅ Le domaine public R2 est protégé par le proxy Cloudflare (WAF, DDoS)
  • ✅ Les buckets de backups sont isolés des buckets publics
  • ✅ Les uploads temporaires sont automatiquement nettoyés

8.4. CORS (si nécessaire)

# Configuration CORS dans le Dashboard R2
[cors]
allowed_origins = ["https://votresite.com", "https://studio.votresite.com"]
allowed_methods = ["GET", "PUT", "POST"]
allowed_headers = ["*"]
max_age = 3600

9. Intégration avec Sanity

9.1. Stockage des métadonnées

Sanity ne stocke que les métadonnées et les URLs R2/Stream :

{
"_type": "image",
"asset": {
"_type": "reference",
"_ref": "image-abc123"
},
"url": "https://pub-medias.votredomaine.com/medias/images/blog/2026-09-03-article.webp",
"metadata": {
"key": "medias/images/blog/2026-09-03-article.webp",
"dimensions": {
"width": 1920,
"height": 1080
},
"alt": "Description de l'image pour l'accessibilité",
"hotspot": {
"x": 0.5,
"y": 0.5,
"width": 0.5,
"height": 0.5
}
}
}

9.2. Champs personnalisés dans Sanity

sanity-studio/schemas/fields/customImage.ts
export const customImageField = {
name: 'mainImage',
title: 'Image principale',
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
title: 'Texte alternatif',
type: 'string',
validation: Rule => Rule.required(),
},
{
name: 'r2Key',
title: 'Clé R2',
type: 'string',
readOnly: true,
description: 'Chemin du fichier dans R2 (généré automatiquement)',
},
],
};

9.3. Requêtes GROQ pour les médias

*[_type == "post"]{
title,
mainImage {
url,
metadata {
dimensions,
alt,
hotspot
}
},
gallery[] {
url,
metadata {
dimensions,
alt
}
}
}

10. Intégration avec Astro

10.1. Consommation des URLs R2

src/pages/blog/[slug].astro
---
import { getSanityClient } from '../../lib/sanity';
const post = await getSanityClient(Astro).fetch(`*[_type == "post" && slug.current == $slug][0]{
title,
mainImage {
url,
metadata {
dimensions,
alt
}
}
}`, { slug: Astro.params.slug });
---
<img
src={post.mainImage.url}
alt={post.mainImage.metadata.alt}
width={post.mainImage.metadata.dimensions.width}
height={post.mainImage.metadata.dimensions.height}
loading="lazy"
decoding="async"
/>

10.2. Utilisation de Cloudflare Images (transformation à la volée)

---
const imageUrl = post.mainImage.url;
const transformedUrl = `https://pub-medias.votredomaine.com/cdn-cgi/image/width=800,height=600,fit=cover,format=webp/${imageUrl.replace('https://pub-medias.votredomaine.com/', '')}`;
---
<img
src={transformedUrl}
alt={post.mainImage.metadata.alt}
width="800"
height="600"
loading="lazy"
/>

10.3. Composant Image réutilisable

src/components/ui/OptimizedImage.astro
---
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
sizes?: string;
}
const { src, alt, width, height, loading = 'lazy', sizes } = Astro.props;
// Génération automatique du srcset pour le responsive
const srcset = width
? [
`${transformUrl(src, width * 2)} 2x`,
`${transformUrl(src, width)} 1x`,
`${transformUrl(src, width / 2)} 0.5x`,
].join(', ')
: undefined;
---
<img
src={src}
srcset={srcset}
sizes={sizes || '(max-width: 768px) 100vw, 50vw'}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
/>

11. Performance et optimisation

11.1. Optimisation des images

Technique Implémentation Gain
Cloudflare Polish Activé sur la zone -30% à -50% de taille
Cloudflare Images Transformation à la volée Adaptation automatique
Formats modernes WebP/AVIF auto -25% à -40% vs JPEG
Lazy loading loading="lazy" Chargement différé
Srcset responsive srcset + sizes Adaptation à l’écran
Dimensions explicites width + height Évite le CLS

11.2. Cache Edge Cloudflare

Type de fichier Cache-Control TTL
Images public, max-age=31536000, immutable 1 an
Vidéos Stream Géré par Stream N/A
Documents PDF public, max-age=86400 1 jour
Backups private, no-cache 0 (accès Worker uniquement)

11.3. Bonnes pratiques

  • ✅ Toujours fournir width, height et alt
  • ✅ Utiliser loading="lazy" sauf pour les images LCP (Hero)
  • ✅ Générer des srcset / sizes pour le responsive
  • ✅ Optimiser les images avant upload (max 500 KB)
  • ✅ Utiliser WebP/AVIF (conversion automatique via Cloudflare)
  • ✅ Ne jamais servir de vidéos depuis R2 (utiliser Stream)

12. Débogage et troubleshooting

12.1. Problèmes courants

Symptôme Cause probable Solution
Image 404 Clé R2 incorrecte Vérifier le chemin dans Sanity et R2
Upload échoue URL présignée expirée Régénérer l’URL (valide 15 min)
CORS error Domaine non autorisé Ajouter le domaine dans la config CORS R2
Image non optimisée Polish non activé Activer Polish dans le Dashboard Cloudflare
CLS (Cumulative Layout Shift) Dimensions manquantes Toujours fournir width + height
Vidéo ne lit pas UID Stream incorrect Vérifier l’UID stocké dans Sanity

12.2. Logs et monitoring

Terminal window
# Vérifier les objets dans R2 via Wrangler CLI
wrangler r2 object list pub-medias --prefix medias/images/blog/
# Vérifier les logs du Worker media-cleanup
wrangler tail media-cleanup
# Vérifier les logs du Worker media-upload
wrangler tail media-upload

12.3. Tests manuels

Terminal window
# Test d'upload via URL présignée
curl -X PUT "https://[signed-url]" \
-H "Content-Type: image/webp" \
--data-binary @test.webp
# Test d'accès public
curl -I "https://pub-medias.votredomaine.com/medias/images/blog/test.webp"
# Attendu : 200 OK + headers de cache
# Test d'accès signé
curl -I "https://[signed-url]"
# Attendu : 200 OK (si URL valide)

13. Bonnes pratiques

13.1. Pour les développeurs

  • ✅ Toujours utiliser des URLs présignées pour les uploads (pas de transit par Worker)
  • ✅ Valider les types de fichiers avant upload (images, vidéos, documents uniquement)
  • ✅ Logger tous les uploads et suppressions pour l’audit
  • ✅ Tester les safety locks du Worker media-cleanup en staging
  • ✅ Documenter les changements de configuration dans ce fichier

13.2. Pour les contributeurs

  • ✅ Optimiser les images avant upload (max 500 KB, dimensions adaptées)
  • ✅ Toujours fournir un texte alternatif descriptif
  • ✅ Utiliser des noms de fichiers descriptifs (pas IMG_1234.jpg)
  • ✅ Ne pas uploader de vidéos directement (passer par Cloudflare Stream)
  • ✅ Vérifier le rendu responsive avant publication

13.3. Considérations de coût

  • ⚠️ R2 facture le stockage + les opérations (lectures/écritures)
  • ⚠️ Stream facture le stockage + le transcoding + la lecture
  • ✅ Nettoyer régulièrement les orphelins (Worker media-cleanup)
  • ✅ Utiliser les Lifecycle Rules pour supprimer automatiquement les vieux fichiers
  • ✅ Optimiser les images pour réduire le stockage et la bande passante

14. Évolutions et roadmap

14.1. Améliorations planifiées

  • [Évolution 1 : ex. Génération automatique de miniatures pour les PDF]
  • [Évolution 2 : ex. Support du format AVIF natif]
  • [Évolution 3 : ex. Dashboard d’audit des médias orphelins]

14.2. Limitations connues

  • ❌ R2 ne supporte pas nativement la transformation d’images (nécessite Cloudflare Images)
  • ❌ Les URLs signées ont une durée de vie limitée (pas de partage long terme)
  • ❌ Le nettoyage des orphelins peut être lent avec beaucoup de fichiers (> 10 000)

15. Ressources liées1/2

  • Documentation Cloudflare R2 : Getting Started
  • Documentation Cloudflare Stream : Getting Started
  • Documentation Cloudflare Images : Getting Started
  • DAT L1 : §4.8 - Gestion des Médias (Cloudflare R2 + Stream)
  • DAT L2 : §5.4 - Consommation et optimisation des médias
  • DAT L3 : §5.3 - Worker media-upload
  • DAT L3 : §5.4 - Worker media-cleanup

15. Ressources liées2/2

  • DAT L3 : §5.5 - Worker sanity-backup
  • Fichiers concernés :
    • services/media-upload/wrangler.toml
    • services/media-cleanup/wrangler.toml
    • services/sanity-backup/wrangler.toml
    • Configuration Dashboard Cloudflare → R2 → Buckets

16. Historique des modifications1/2

Date Version Description Auteur
YYYY-MM-DD 1.0 Création initiale [Nom]
YYYY-MM-DD 1.1 [Modification] [Nom]

16. Historique des modifications2/2

---
**Points clés de ce template :**
- Documente l'architecture complète de stockage (R2 + Stream)
- Détaille l'organisation des chemins et conventions de nommage
- Inclut les mécanismes d'upload (URLs présignées, Direct Upload Stream)
- Explique le nettoyage automatique (orphelins, temp, backups)
- Fournit l'intégration avec Sanity (métadonnées) et Astro (consommation)
- Couvre la sécurité (accès public/signé/privé, CORS)
- Inclut les bonnes pratiques de performance et d'optimisation
Switch to normal mode

Contents