feat(media): add media management feature module

- add `ZEN_MEDIA` env flag and document it in `.env.example`
- add media schema, server routes, and API handlers (`api.server.js`, `routes.server.js`, `schema.server.js`)
- add `MediaPage`, `MediaGrid`, `MediaFilters`, and `MediaPicker` client components
- expose `@zen/core/features/media` and `@zen/core/features/media/picker` package exports
- register media navigation and permissions; wire module into `init.js`
- document media API, client picker usage, and boundary rules in `MODULES.md` and `ARCHITECTURE.md`
- add `src/features/media/README.md`
This commit is contained in:
2026-04-26 17:07:19 -04:00
parent f5d627f324
commit c9f7b23498
20 changed files with 1674 additions and 3 deletions
+1
View File
@@ -9,6 +9,7 @@ import './pages/SettingsPage.client.js';
import './pages/ConfirmEmailChangePage.client.js';
import './widgets/index.client.js';
import './devkit/DevkitPage.client.js';
import '../media/pages/MediaPage.client.js';
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
const parts = params?.admin || [];
+4
View File
@@ -6,6 +6,10 @@ import {
} from './registry.js';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { PERMISSIONS } from '@zen/core/users/constants';
// Side-effect : déclenche l'enregistrement nav du module Médias (gated par
// ZEN_MEDIA en interne). Importé en haut du fichier pour que les side effects
// s'exécutent lors du premier import du barrel admin.
import '../media/navigation.js';
// Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
+7 -1
View File
@@ -13,13 +13,19 @@
*/
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
import { createTables as mediaCreate, dropTables as mediaDrop } from './media/schema.server.js';
import { done, fail, info, step } from '@zen/core/shared/logger';
import { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js';
import { getRegisteredModules } from '../core/modules/registry.js';
import { registerPermissions } from '../core/users/permissions-registry.js';
// Auth en premier (les autres features peuvent référencer zen_auth_users via FK).
// Les permissions des features additionnelles sont enregistrées au module-load
// dans leur schema.server.js, ce qui garantit qu'elles sont dans le registre
// avant le seed des rôles dans auth.createTables.
const CORE_FEATURES = [
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
{ name: 'media', createTables: mediaCreate, dropTables: mediaDrop },
];
async function loadModules() {
+149
View File
@@ -0,0 +1,149 @@
# Media
Gestionnaire de médias CMS — bibliothèque centrale pour les images, PDFs, vidéos et autres fichiers attachés au contenu publié sur le site (pages, articles, modules tiers).
> `media` est conçu pour les **assets de contenu web** (la plupart privés, certains publics), pas pour le stockage personnel de fichiers utilisateurs.
## Activation
Le module est **désactivé par défaut**. Pour l'activer :
```bash
# .env.local
ZEN_MEDIA=true
```
Puis appliquer le schéma BD :
```bash
npx zen-db init
```
L'item « Médias » apparaît dans la sidebar admin sous une nouvelle section « Contenu », accessible aux utilisateurs ayant la permission `media.view`.
## Architecture
| Fichier | Rôle |
|---|---|
| [`schema.server.js`](./schema.server.js) | Tables `zen_media` et `zen_media_references` + seed des permissions media. |
| [`api.server.js`](./api.server.js) | CRUD + attach/detach. Source de vérité unique pour la BD et S3. |
| [`routes.server.js`](./routes.server.js) | Routes API admin + route publique de service de fichier. |
| [`navigation.js`](./navigation.js) | Enregistre la section sidebar (gated par `ZEN_MEDIA`). |
| [`permissions.js`](./permissions.js) | Catalogue de permissions (`media.view`, `media.upload`, `media.delete`). |
| [`pages/MediaPage.client.js`](./pages/MediaPage.client.js) | Page admin `/admin/media`. |
| [`components/MediaPicker.client.js`](./components/MediaPicker.client.js) | Composant réutilisable par les modules tiers. |
### Modèle de données
`zen_media` — registre central des médias (slug URL-safe, clé S3, MIME, taille, visibilité, owner, alt/caption).
`zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant.
### Stockage
Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu.
## URLs
- **Service de fichier** : `GET /zen/api/media/file/:slug`
- `visibility = 'public'` → servi sans authentification (cache long)
- `visibility = 'private'` → requiert une session avec `media.view`
- **CRUD admin** : `GET|POST|PATCH|DELETE /zen/api/media[/:id]`
Le slug est un identifiant aléatoire de 12 caractères (base64url) — non énumérable.
## API serveur (pour les modules consommateurs)
```js
import {
uploadMedia,
getMediaById,
getMediaBySlug,
listMedia,
updateMedia,
deleteMedia,
attachMedia,
detachMedia,
detachAllForSource,
buildMediaUrl,
isMediaEnabled,
} from '@zen/core/features/media';
// Upload depuis une server action / API handler :
const { success, media } = await uploadMedia({
file, // File ou Buffer
uploadedBy: session.user.id,
visibility: 'public',
altText: 'Logo de la conférence 2026',
});
// Au moment de sauvegarder un post avec featured image :
await attachMedia({
mediaId: media.id,
sourceType: 'post',
sourceId: post.id,
field: 'featured_image',
});
// Au moment de supprimer le post : libérer toutes les références.
await detachAllForSource({ sourceType: 'post', sourceId: post.id });
```
## MediaPicker (composant client)
```jsx
'use client';
import { useState } from 'react';
import MediaPicker from '@zen/core/features/media/picker';
function FeaturedImageField() {
const [open, setOpen] = useState(false);
const [media, setMedia] = useState(null);
return (
<>
<button onClick={() => setOpen(true)}>Choisir une image</button>
<MediaPicker
isOpen={open}
onClose={() => setOpen(false)}
accept="image/*"
visibility="public"
uploadVisibility="public"
onSelect={(m) => { setMedia(m); }}
/>
{media && <img src={`/zen/api/media/file/${media.slug}`} alt={media.alt_text} />}
</>
);
}
```
| Prop | Type | Description |
|---|---|---|
| `isOpen` | `boolean` | Contrôlé par le parent. |
| `onClose` | `() => void` | |
| `onSelect` | `(media \| media[]) => void` | Reçoit l'objet média (ou tableau si `multiple`). |
| `accept` | `string` | Filtre MIME pour l'`<input type="file">` et le filtre par défaut de la grille. |
| `visibility` | `'public' \| 'private' \| 'any'` | Filtre par défaut. |
| `uploadVisibility` | `'public' \| 'private'` | Visibilité appliquée aux nouveaux uploads. |
| `multiple` | `boolean` | Sélection multiple avec validation explicite. |
## Permissions
| Clé | Description |
|---|---|
| `media.view` | Voir la liste et accéder aux médias privés. |
| `media.upload` | Téléverser et modifier les métadonnées. |
| `media.delete` | Supprimer un média non référencé. |
Toutes sont auto-attribuées au rôle `admin` au moment du seed.
## Limites
- Pas de génération de thumbnails — l'original est servi tel quel (le navigateur fait le redimensionnement). Un v2 pourrait intégrer `sharp` ou déléguer à Cloudflare Images.
- Pas de dossiers ni de tags — liste plate avec recherche par nom + filtre par type/visibilité.
- Pas d'édition d'image (crop, rotation).
- SVG bloqué par `validateUpload` (risque XSS).
## Distinction avec un futur module `files`
Le nom **`files`** est réservé à un futur module type Drive/Dropbox/Nextcloud (stockage personnel privé, partage, dossiers, versioning). Ce module-ci (`media`) est strictement orienté **assets attachés au contenu publié**.
+377
View File
@@ -0,0 +1,377 @@
/**
* Media Feature — Server-side API
*
* Toutes les opérations CRUD sur le registre `zen_media` et `zen_media_references`.
* Ne contient AUCUNE logique HTTP — utilisable depuis routes.server.js, server
* actions, et modules externes (`@zen/module-*`).
*
* Stockage : délégué à `@zen/core/storage` (R2/Backblaze). La table est la
* source de vérité ; S3 ne stocke que les bytes.
*/
import { randomBytes } from 'node:crypto';
import { query, queryOne } from '@zen/core/database';
import {
uploadFile,
deleteFile,
generateUniqueFilename,
validateUpload,
FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS,
} from '@zen/core/storage';
import { generateId } from '../../core/users/password.js';
import { fail, info } from '@zen/core/shared/logger';
const SLUG_BYTES = 9; // 9 octets → 12 caractères base64url
/**
* Génère un slug URL-safe court (12 caractères) pour identifier un média
* publiquement sans exposer l'ID interne.
*/
function generateSlug() {
return randomBytes(SLUG_BYTES).toString('base64url');
}
/**
* Construit la clé S3 sous le préfixe `media/<yyyy>/<mm>/<filename-unique>`.
* Le préfixe `media/` reste **privé** côté S3 — l'accès public passe par
* notre route HTTP qui valide la visibilité en BD avant de proxyfier.
*/
function buildStorageKey(originalName) {
const now = new Date();
const yyyy = String(now.getUTCFullYear());
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const filename = generateUniqueFilename(originalName, 'media');
return `media/${yyyy}/${mm}/${filename}`;
}
/**
* Devine la nature du fichier selon son MIME pour le filtrage UI et les
* validations spécifiques (images vs documents).
*/
export function classifyMime(mimeType) {
if (!mimeType) return 'other';
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'application/pdf') return 'document';
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('sheet')) return 'document';
return 'other';
}
const MIME_BY_EXT = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp',
'.pdf': 'application/pdf',
'.txt': 'text/plain', '.csv': 'text/csv',
'.mp4': 'video/mp4', '.webm': 'video/webm',
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg',
};
function deriveContentType(filename) {
const lower = filename.toLowerCase();
const dot = lower.lastIndexOf('.');
if (dot === -1) return 'application/octet-stream';
return MIME_BY_EXT[lower.slice(dot)] ?? 'application/octet-stream';
}
/**
* Upload + insertion BD en une opération transactionnelle.
*
* Stratégie : on persiste en BD AVANT le commit final côté S3. En cas d'échec
* BD on supprime l'objet S3 fraîchement uploadé pour ne pas laisser d'orphelin.
*
* @param {object} args
* @param {File|Buffer} args.file - Fichier source (FormData File ou Buffer brut)
* @param {string} [args.filename] - Nom original (requis si file est un Buffer)
* @param {number} [args.size] - Taille (requise si file est un Buffer)
* @param {string} args.uploadedBy - ID utilisateur qui upload
* @param {'private'|'public'} [args.visibility='private']
* @param {string} [args.altText]
* @param {string} [args.caption]
* @returns {Promise<{ success: true, media: object } | { success: false, error: string }>}
*/
export async function uploadMedia({ file, filename, size, uploadedBy, visibility = 'private', altText = null, caption = null }) {
let buffer;
let originalName;
let fileSize;
if (file && typeof file.arrayBuffer === 'function') {
buffer = Buffer.from(await file.arrayBuffer());
originalName = file.name ?? filename ?? 'unnamed';
fileSize = file.size ?? buffer.length;
} else if (Buffer.isBuffer(file)) {
if (!filename) return { success: false, error: 'filename is required when uploading a Buffer' };
buffer = file;
originalName = filename;
fileSize = size ?? buffer.length;
} else {
return { success: false, error: 'file must be a File or a Buffer' };
}
const isImage = deriveContentType(originalName).startsWith('image/');
const validation = validateUpload({
filename: originalName,
size: fileSize,
allowedTypes: isImage ? FILE_TYPE_PRESETS.IMAGES : FILE_TYPE_PRESETS.DOCUMENTS,
maxSize: isImage ? FILE_SIZE_LIMITS.IMAGE : FILE_SIZE_LIMITS.DOCUMENT,
buffer,
});
if (!validation.valid) {
return { success: false, error: validation.errors.join(', ') };
}
const contentType = deriveContentType(originalName);
const storageKey = buildStorageKey(originalName);
const uploadResult = await uploadFile({
key: storageKey,
body: buffer,
contentType,
metadata: { uploadedBy, originalName },
cacheControl: visibility === 'public' ? 'public, max-age=31536000, immutable' : 'private, max-age=0, no-store',
});
if (!uploadResult.success) {
return { success: false, error: 'Storage upload failed' };
}
const id = generateId();
const slug = generateSlug();
try {
const inserted = await queryOne(
`INSERT INTO zen_media (id, slug, storage_key, original_name, mime_type, size_bytes, visibility, uploaded_by, alt_text, caption)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[id, slug, storageKey, originalName, contentType, fileSize, visibility, uploadedBy, altText, caption]
);
return { success: true, media: inserted };
} catch (error) {
try {
await deleteFile(storageKey);
} catch (rollbackError) {
fail(`uploadMedia: rollback delete failed for orphan ${storageKey}: ${rollbackError.message}`);
}
fail(`uploadMedia: DB insert failed: ${error.message}`);
return { success: false, error: 'Failed to record media' };
}
}
export async function getMediaById(id) {
return queryOne(`SELECT * FROM zen_media WHERE id = $1`, [id]);
}
export async function getMediaBySlug(slug) {
return queryOne(`SELECT * FROM zen_media WHERE slug = $1`, [slug]);
}
/**
* Liste paginée + filtres facultatifs.
*
* @param {object} [opts]
* @param {number} [opts.page=1]
* @param {number} [opts.limit=24]
* @param {string} [opts.search] - Recherche LIKE sur original_name
* @param {'image'|'video'|'audio'|'document'|'other'} [opts.kind]
* @param {'private'|'public'} [opts.visibility]
* @param {string} [opts.uploadedBy]
*/
export async function listMedia({ page = 1, limit = 24, search, kind, visibility, uploadedBy } = {}) {
const safePage = Math.max(1, Math.floor(page));
const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 100);
const offset = (safePage - 1) * safeLimit;
const conditions = [];
const params = [];
if (search) {
params.push(`%${search}%`);
conditions.push(`original_name ILIKE $${params.length}`);
}
if (visibility) {
params.push(visibility);
conditions.push(`visibility = $${params.length}`);
}
if (uploadedBy) {
params.push(uploadedBy);
conditions.push(`uploaded_by = $${params.length}`);
}
if (kind) {
if (kind === 'image') conditions.push(`mime_type LIKE 'image/%'`);
else if (kind === 'video') conditions.push(`mime_type LIKE 'video/%'`);
else if (kind === 'audio') conditions.push(`mime_type LIKE 'audio/%'`);
else if (kind === 'document') conditions.push(`(mime_type = 'application/pdf' OR mime_type LIKE 'text/%' OR mime_type LIKE 'application/vnd%')`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(safeLimit, offset);
const rows = (await query(
`SELECT * FROM zen_media ${where} ORDER BY created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
)).rows;
const countParams = params.slice(0, -2);
const totalRow = await queryOne(`SELECT COUNT(*)::int AS count FROM zen_media ${where}`, countParams);
const total = totalRow?.count ?? 0;
return {
media: rows,
pagination: { page: safePage, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) },
};
}
/**
* Met à jour les metadata éditables d'un média (visibilité, alt, caption).
* La clé S3 et le contenu binaire ne sont jamais modifiés ici.
*/
export async function updateMedia(id, { visibility, altText, caption } = {}) {
const fields = [];
const params = [];
if (visibility !== undefined) {
if (!['private', 'public'].includes(visibility)) {
return { success: false, error: 'Invalid visibility value' };
}
params.push(visibility);
fields.push(`visibility = $${params.length}`);
}
if (altText !== undefined) {
params.push(altText || null);
fields.push(`alt_text = $${params.length}`);
}
if (caption !== undefined) {
params.push(caption || null);
fields.push(`caption = $${params.length}`);
}
if (fields.length === 0) {
return { success: false, error: 'No fields to update' };
}
fields.push(`updated_at = CURRENT_TIMESTAMP`);
params.push(id);
const updated = await queryOne(
`UPDATE zen_media SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
params
);
if (!updated) return { success: false, error: 'Media not found' };
return { success: true, media: updated };
}
/**
* Compte les références. Utile pour l'UI ("utilisé par X contenus") et avant
* de tenter une suppression.
*/
export async function countReferences(mediaId) {
const row = await queryOne(
`SELECT COUNT(*)::int AS count FROM zen_media_references WHERE media_id = $1`,
[mediaId]
);
return row?.count ?? 0;
}
export async function listReferences(mediaId) {
return (await query(
`SELECT media_id, source_type, source_id, field, created_at
FROM zen_media_references WHERE media_id = $1 ORDER BY created_at DESC`,
[mediaId]
)).rows;
}
/**
* Supprime un média (BD puis S3). Refuse si des références existent — la FK
* RESTRICT le bloque déjà au niveau BD, on retourne juste un message clair.
*/
export async function deleteMedia(id) {
const media = await getMediaById(id);
if (!media) return { success: false, error: 'Media not found' };
const refCount = await countReferences(id);
if (refCount > 0) {
return { success: false, error: `Ce média est utilisé par ${refCount} contenu(s). Détachez-le avant de le supprimer.`, referenceCount: refCount };
}
try {
await query(`DELETE FROM zen_media WHERE id = $1`, [id]);
} catch (error) {
fail(`deleteMedia: DB delete failed for ${id}: ${error.message}`);
return { success: false, error: 'Failed to delete media record' };
}
try {
await deleteFile(media.storage_key);
} catch (error) {
fail(`deleteMedia: S3 delete failed for ${media.storage_key} (orphan): ${error.message}`);
}
info(`deleteMedia: removed ${id} (${media.storage_key})`);
return { success: true };
}
/**
* Attache un média à une ressource consommatrice. Utilisé par les modules
* externes (Posts, Pages, etc.) au moment de sauvegarder leur contenu.
*
* @param {object} args
* @param {string} args.mediaId
* @param {string} args.sourceType - Ex: 'post', 'page', '@zen/module-shop:product'
* @param {string} args.sourceId
* @param {string} [args.field=''] - Ex: 'featured_image', 'gallery'
*/
export async function attachMedia({ mediaId, sourceType, sourceId, field = '' }) {
if (!mediaId || !sourceType || !sourceId) {
return { success: false, error: 'mediaId, sourceType and sourceId are required' };
}
await query(
`INSERT INTO zen_media_references (media_id, source_type, source_id, field)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING`,
[mediaId, sourceType, sourceId, field]
);
return { success: true };
}
export async function detachMedia({ mediaId, sourceType, sourceId, field = '' }) {
if (!mediaId || !sourceType || !sourceId) {
return { success: false, error: 'mediaId, sourceType and sourceId are required' };
}
await query(
`DELETE FROM zen_media_references WHERE media_id = $1 AND source_type = $2 AND source_id = $3 AND field = $4`,
[mediaId, sourceType, sourceId, field]
);
return { success: true };
}
/**
* Détache toutes les références d'une source donnée. À appeler lorsqu'un
* consommateur supprime sa propre ressource (post, page) pour libérer les
* médias attachés.
*/
export async function detachAllForSource({ sourceType, sourceId }) {
if (!sourceType || !sourceId) {
return { success: false, error: 'sourceType and sourceId are required' };
}
await query(
`DELETE FROM zen_media_references WHERE source_type = $1 AND source_id = $2`,
[sourceType, sourceId]
);
return { success: true };
}
/**
* Construit l'URL servie par notre route HTTP. Le slug est utilisé en lieu et
* place de l'ID pour éviter l'énumération séquentielle.
*
* Cette URL fonctionne pour les deux niveaux de visibilité :
* - public → servi sans session
* - privé → servi uniquement aux sessions avec `media.view`
*/
export function buildMediaUrl(media) {
if (!media?.slug) return null;
return `/zen/api/media/file/${media.slug}`;
}
@@ -0,0 +1,56 @@
'use client';
/**
* Barre de filtres compacte : recherche par nom + type + visibilité.
* État contrôlé par le parent.
*/
import { Input, Select } from '@zen/core/shared/components';
const KIND_OPTIONS = [
{ value: '', label: 'Tous les types' },
{ value: 'image', label: 'Images' },
{ value: 'video', label: 'Vidéos' },
{ value: 'audio', label: 'Audio' },
{ value: 'document', label: 'Documents' },
{ value: 'other', label: 'Autre' },
];
const VISIBILITY_OPTIONS = [
{ value: '', label: 'Toute visibilité' },
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Privé' },
];
export default function MediaFilters({ filters, onChange }) {
const update = (patch) => onChange({ ...filters, ...patch });
return (
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Input
type="search"
value={filters.search || ''}
onChange={(value) => update({ search: value })}
placeholder="Rechercher par nom de fichier..."
/>
</div>
<div className="w-full sm:w-44">
<Select
value={filters.kind || ''}
onChange={(value) => update({ kind: value })}
options={KIND_OPTIONS}
placeholder=""
/>
</div>
<div className="w-full sm:w-44">
<Select
value={filters.visibility || ''}
onChange={(value) => update({ visibility: value })}
options={VISIBILITY_OPTIONS}
placeholder=""
/>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
'use client';
/**
* Grille présentationnelle des médias. Composant pur : ne fait pas de fetch.
* Utilisé à la fois par la page admin et par le MediaPicker.
*/
import { Image01Icon, Pdf02Icon, Mp402Icon, Mp302Icon, File02Icon, EyeIcon, BlockedIcon, Tick02Icon } from '@zen/core/shared/icons';
const KIND_ICON = {
'image/jpeg': Image01Icon,
'image/png': Image01Icon,
'image/gif': Image01Icon,
'image/webp': Image01Icon,
'application/pdf': Pdf02Icon,
'video/mp4': Mp402Icon,
'audio/mpeg': Mp302Icon,
};
function iconForMime(mimeType) {
return KIND_ICON[mimeType] ?? File02Icon;
}
function isImage(mimeType) {
return typeof mimeType === 'string' && mimeType.startsWith('image/');
}
function formatSize(bytes) {
if (!Number.isFinite(bytes)) return '';
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
export default function MediaGrid({
items = [],
loading = false,
onSelect,
selectedIds = new Set(),
emptyMessage = 'Aucun média',
}) {
if (loading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="aspect-square rounded-lg bg-neutral-100 dark:bg-neutral-900 animate-pulse" />
))}
</div>
);
}
if (items.length === 0) {
return (
<div className="py-16 text-center text-sm text-neutral-500 dark:text-neutral-400">
{emptyMessage}
</div>
);
}
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{items.map(media => {
const Icon = iconForMime(media.mime_type);
const isSelected = selectedIds.has(media.id);
const url = `/zen/api/media/file/${media.slug}`;
return (
<button
key={media.id}
type="button"
onClick={() => onSelect?.(media)}
className={`
group relative flex flex-col items-stretch
rounded-lg overflow-hidden
border ${isSelected ? 'border-blue-500 ring-2 ring-blue-500/40' : 'border-neutral-200 dark:border-neutral-800'}
bg-white dark:bg-[#0B0B0B]
text-left transition-colors
hover:border-neutral-300 dark:hover:border-neutral-700
`}
>
<div className="aspect-square bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center overflow-hidden">
{isImage(media.mime_type) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={media.alt_text || media.original_name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<Icon className="w-10 h-10 text-neutral-400" />
)}
</div>
<div className="px-2 py-1.5 text-[11px] leading-tight">
<div className="truncate text-neutral-900 dark:text-white" title={media.original_name}>
{media.original_name}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
{media.visibility === 'public' ? (
<span className="inline-flex items-center gap-0.5"><EyeIcon className="w-3 h-3" /> Public</span>
) : (
<span className="inline-flex items-center gap-0.5"><BlockedIcon className="w-3 h-3" /> Privé</span>
)}
<span>·</span>
<span>{formatSize(Number(media.size_bytes))}</span>
</div>
</div>
{isSelected && (
<div className="absolute top-1.5 right-1.5 rounded-full bg-blue-500 text-white p-0.5">
<Tick02Icon className="w-3 h-3" />
</div>
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,196 @@
'use client';
/**
* MediaPicker — composant réutilisable par les modules consommateurs (Posts,
* Pages, modules externes) pour choisir un média existant ou en uploader un.
*
* @example
* <MediaPicker
* isOpen={open}
* onClose={() => setOpen(false)}
* accept="image/*"
* visibility="public"
* onSelect={(media) => setFeaturedImage(media)}
* />
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { CloudUploadIcon } from '@zen/core/shared/icons';
import MediaGrid from './MediaGrid.client.js';
import MediaFilters from './MediaFilters.client.js';
const MEDIA_API = '/zen/api/media';
function deriveKindFromAccept(accept) {
if (!accept) return undefined;
if (accept.startsWith('image/')) return 'image';
if (accept.startsWith('video/')) return 'video';
if (accept.startsWith('audio/')) return 'audio';
if (accept === 'application/pdf') return 'document';
return undefined;
}
export default function MediaPicker({
isOpen,
onClose,
onSelect,
accept,
visibility,
multiple = false,
title = 'Sélectionner un média',
uploadVisibility = 'public',
}) {
const toast = useToast();
const fileInputRef = useRef(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [filters, setFilters] = useState({
search: '',
kind: deriveKindFromAccept(accept) ?? '',
visibility: visibility && visibility !== 'any' ? visibility : '',
});
const [selectedIds, setSelectedIds] = useState(new Set());
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ limit: '60' });
if (filters.search) params.set('search', filters.search);
if (filters.kind) params.set('kind', filters.kind);
if (filters.visibility) params.set('visibility', filters.visibility);
const response = await fetch(`${MEDIA_API}?${params}`, { credentials: 'include' });
if (!response.ok) throw new Error('Échec du chargement');
const data = await response.json();
setItems(data.media || []);
} catch (err) {
toast.error(err.message || 'Échec du chargement des médias');
} finally {
setLoading(false);
}
}, [filters, toast]);
useEffect(() => {
if (!isOpen) return;
fetchItems();
}, [isOpen, fetchItems]);
useEffect(() => {
if (!isOpen) {
setSelectedIds(new Set());
}
}, [isOpen]);
const handleSelect = (media) => {
if (multiple) {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(media.id)) next.delete(media.id);
else next.add(media.id);
return next;
});
} else {
onSelect?.(media);
onClose?.();
}
};
const handleConfirmMultiple = () => {
const selected = items.filter(m => selectedIds.has(m.id));
onSelect?.(selected);
onClose?.();
};
const handleUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('visibility', uploadVisibility);
const response = await fetch(MEDIA_API, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec du téléversement');
toast.success('Média téléversé');
await fetchItems();
if (!multiple && data.media) {
onSelect?.(data.media);
onClose?.();
}
} catch (err) {
toast.error(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
size="xl"
footer={multiple ? (
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-500">{selectedIds.size} sélectionné(s)</span>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={handleConfirmMultiple} disabled={selectedIds.size === 0}>
Sélectionner
</Button>
</div>
</div>
) : (
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>Fermer</Button>
</div>
)}
>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center sm:justify-between">
<div className="flex-1">
<MediaFilters filters={filters} onChange={setFilters} />
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleUpload}
className="hidden"
/>
<Button
variant="secondary"
icon={CloudUploadIcon}
onClick={() => fileInputRef.current?.click()}
loading={uploading}
>
Téléverser
</Button>
</div>
</div>
<MediaGrid
items={items}
loading={loading}
onSelect={handleSelect}
selectedIds={selectedIds}
emptyMessage="Aucun média ne correspond aux filtres"
/>
</div>
</Modal>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Media Feature — barrel public.
*
* API serveur (CRUD, références) exposée aux modules consommateurs.
* Composant client `MediaPicker` réutilisable depuis tout module externe.
*
* Activation : ce module est inerte tant que `ZEN_MEDIA=true` n'est pas défini.
* - L'enregistrement nav/page est conditionnel (voir navigation.js et la page).
* - Les fonctions d'API restent importables et opérationnelles si la BD est
* initialisée — mais n'auront aucun effet visible dans l'admin tant que le
* flag n'est pas activé.
*
* Side effects à l'import :
* - navigation.js → enregistre l'item sidebar si isMediaEnabled()
* - permissions enregistrées via schema.server.js (côté serveur uniquement)
*/
import './navigation.js';
export {
uploadMedia,
deleteMedia,
updateMedia,
getMediaById,
getMediaBySlug,
listMedia,
countReferences,
listReferences,
attachMedia,
detachMedia,
detachAllForSource,
buildMediaUrl,
classifyMime,
} from './api.server.js';
export { MEDIA_PERMISSIONS, MEDIA_PERMISSION_DEFINITIONS } from './permissions.js';
export { isMediaEnabled } from '@zen/core/shared/config';
// Routes API à enregistrer dans initializeZen.
export { routes } from './routes.server.js';
// DB schema export (utilisé par features/init.js).
export { createTables, dropTables } from './schema.server.js';
+25
View File
@@ -0,0 +1,25 @@
/**
* Media Feature — Admin navigation.
*
* Side effect : enregistre la section "Contenu" et l'item "Médias" dans la
* sidebar admin si le module est activé via ZEN_MEDIA=true.
*/
// Import direct depuis le registre pour éviter une dépendance circulaire :
// admin/navigation.js → media/navigation.js → admin barrel → admin/navigation.js
import { registerNavSection, registerNavItem } from '../admin/registry.js';
import { isMediaEnabled } from '@zen/core/shared/config';
import { MEDIA_PERMISSIONS } from './permissions.js';
if (isMediaEnabled()) {
registerNavSection({ id: 'content', title: 'Contenu', icon: 'File02Icon', order: 25 });
registerNavItem({
id: 'media',
label: 'Médias',
icon: 'Image01Icon',
href: '/admin/media',
sectionId: 'content',
order: 10,
permission: MEDIA_PERMISSIONS.VIEW,
});
}
@@ -0,0 +1,318 @@
'use client';
/**
* Page admin "/admin/media" — gestionnaire central des médias.
*
* Listing + filtres + upload + détails (visibilité, alt, caption) + suppression.
* Tout est gardé dans un seul fichier pour rester lisible — la complexité
* grandit-elle, on extraira un MediaDetailsDrawer dédié.
*/
import { registerPage } from '../../admin/registry.js';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, Button, Modal, Input, Textarea, Select } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { CloudUploadIcon, Delete02Icon, Copy01Icon } from '@zen/core/shared/icons';
import AdminHeader from '../../admin/components/AdminHeader.js';
import MediaGrid from '../components/MediaGrid.client.js';
import MediaFilters from '../components/MediaFilters.client.js';
const MEDIA_API = '/zen/api/media';
const VISIBILITY_OPTIONS = [
{ value: 'private', label: 'Privé' },
{ value: 'public', label: 'Public' },
];
function MediaDetails({ media, onClose, onUpdated, onDeleted, canDelete, canEdit }) {
const toast = useToast();
const [visibility, setVisibility] = useState(media.visibility);
const [altText, setAltText] = useState(media.alt_text || '');
const [caption, setCaption] = useState(media.caption || '');
const [referenceCount, setReferenceCount] = useState(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
let cancelled = false;
fetch(`${MEDIA_API}/${media.id}`, { credentials: 'include' })
.then(r => r.json())
.then(data => { if (!cancelled) setReferenceCount(data.referenceCount ?? 0); })
.catch(() => {});
return () => { cancelled = true; };
}, [media.id]);
const url = `/zen/api/media/file/${media.slug}`;
const isImage = media.mime_type?.startsWith('image/');
const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}${url}` : url;
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility, altText, caption }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la mise à jour');
toast.success('Média mis à jour');
onUpdated?.(data.media);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Supprimer définitivement ce média ?')) return;
setDeleting(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la suppression');
toast.success('Média supprimé');
onDeleted?.(media.id);
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
};
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(fullUrl);
toast.success('URL copiée');
} catch {
toast.error('Impossible de copier');
}
};
return (
<Modal
isOpen={true}
onClose={onClose}
title={media.original_name}
size="xl"
footer={
<div className="flex items-center justify-between">
{canDelete ? (
<Button variant="danger" icon={Delete02Icon} onClick={handleDelete} loading={deleting}>
Supprimer
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Fermer</Button>
{canEdit && (
<Button variant="primary" onClick={handleSave} loading={saving}>
Enregistrer
</Button>
)}
</div>
</div>
}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-neutral-50 dark:bg-neutral-950 rounded-lg overflow-hidden flex items-center justify-center min-h-64">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={altText || media.original_name} className="max-w-full max-h-96 object-contain" />
) : (
<div className="text-center p-6 text-sm text-neutral-500">
{media.mime_type}
<br />
<a href={url} target="_blank" rel="noreferrer" className="text-blue-500 hover:underline mt-2 inline-block">
Ouvrir le fichier
</a>
</div>
)}
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 dark:text-white mb-1">URL publique</label>
<div className="flex gap-1">
<Input value={fullUrl} onChange={() => {}} disabled />
<Button variant="secondary" icon={Copy01Icon} onClick={handleCopyUrl} />
</div>
<p className="mt-1 text-[11px] text-neutral-500">
{visibility === 'public' ? 'Accessible sans connexion.' : 'Privé : accessible uniquement aux utilisateurs avec la permission media.view.'}
</p>
</div>
<Select
label="Visibilité"
value={visibility}
onChange={setVisibility}
options={VISIBILITY_OPTIONS}
disabled={!canEdit}
placeholder=""
/>
{isImage && (
<Input
label="Texte alternatif"
value={altText}
onChange={setAltText}
disabled={!canEdit}
description="Décrit l'image pour les lecteurs d'écran et le SEO."
/>
)}
<Textarea
label="Légende"
value={caption}
onChange={setCaption}
disabled={!canEdit}
rows={2}
/>
<div className="text-xs text-neutral-500 dark:text-neutral-400 space-y-1 pt-2 border-t border-neutral-200 dark:border-neutral-800">
<div>Type : <span className="text-neutral-700 dark:text-neutral-300">{media.mime_type}</span></div>
<div>Taille : <span className="text-neutral-700 dark:text-neutral-300">{Math.round(Number(media.size_bytes) / 1024)} Ko</span></div>
{referenceCount !== null && (
<div>Utilisé par : <span className="text-neutral-700 dark:text-neutral-300">{referenceCount} contenu(s)</span></div>
)}
</div>
</div>
</div>
</Modal>
);
}
const MediaPage = ({ user }) => {
const toast = useToast();
const fileInputRef = useRef(null);
const canUpload = user?.permissions?.includes('media.upload');
const canDelete = user?.permissions?.includes('media.delete');
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [filters, setFilters] = useState({ search: '', kind: '', visibility: '' });
const [selected, setSelected] = useState(null);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ limit: '60' });
if (filters.search) params.set('search', filters.search);
if (filters.kind) params.set('kind', filters.kind);
if (filters.visibility) params.set('visibility', filters.visibility);
const response = await fetch(`${MEDIA_API}?${params}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Error ${response.status}`);
const data = await response.json();
setItems(data.media || []);
} catch (err) {
toast.error(err.message || 'Échec du chargement');
} finally {
setLoading(false);
}
}, [filters, toast]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleUpload = async (event) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
setUploading(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('visibility', 'private');
const response = await fetch(MEDIA_API, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await response.json();
if (!response.ok) {
toast.error(`${file.name} : ${data.message || 'échec'}`);
}
}
toast.success(`${files.length} fichier(s) téléversé(s)`);
await fetchItems();
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader
title="Médias"
description="Bibliothèque de fichiers utilisés dans le contenu du site."
action={canUpload && (
<>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleUpload}
className="hidden"
/>
<Button
variant="primary"
icon={CloudUploadIcon}
onClick={() => fileInputRef.current?.click()}
loading={uploading}
>
Téléverser
</Button>
</>
)}
/>
<Card variant="default" padding="default">
<MediaFilters filters={filters} onChange={setFilters} />
</Card>
<Card variant="default" padding="default">
<MediaGrid
items={items}
loading={loading}
onSelect={(media) => setSelected(media)}
emptyMessage={
filters.search || filters.kind || filters.visibility
? 'Aucun média ne correspond aux filtres'
: 'Aucun média téléversé pour le moment'
}
/>
</Card>
{selected && (
<MediaDetails
media={selected}
onClose={() => setSelected(null)}
onUpdated={(updated) => {
setItems(prev => prev.map(m => m.id === updated.id ? updated : m));
setSelected(updated);
}}
onDeleted={(id) => {
setItems(prev => prev.filter(m => m.id !== id));
setSelected(null);
}}
canDelete={canDelete}
canEdit={canUpload}
/>
)}
</div>
);
};
export default MediaPage;
registerPage({ slug: 'media', title: 'Médias', Component: MediaPage });
+18
View File
@@ -0,0 +1,18 @@
/**
* Media Feature — Permissions
*
* Catalogue de permissions du module Médias. Importé par schema.server.js
* (pour seed BD) et par routes.server.js (pour gating des routes admin).
*/
export const MEDIA_PERMISSIONS = {
VIEW: 'media.view',
UPLOAD: 'media.upload',
DELETE: 'media.delete',
};
export const MEDIA_PERMISSION_DEFINITIONS = [
{ key: 'media.view', name: 'Voir les médias', description: 'Permet de consulter la liste des médias et leurs détails.', group_name: 'Médias' },
{ key: 'media.upload', name: 'Téléverser un média', description: 'Permet d\'ajouter de nouveaux médias.', group_name: 'Médias' },
{ key: 'media.delete', name: 'Supprimer un média', description: 'Permet de supprimer des médias non référencés.', group_name: 'Médias' },
];
+179
View File
@@ -0,0 +1,179 @@
/**
* Media Feature — API Routes
*
* Routes admin (gated par permission) pour piloter le gestionnaire depuis
* l'interface, plus une route "publique" qui sert le contenu binaire des
* médias selon leur visibilité.
*
* La route de service de fichier est `/zen/api/media/file/:slug` (préfixe
* dédié pour ne pas entrer en collision avec les endpoints admin `/media/:id`) :
* - visibilité = 'public' → servie sans authentification
* - visibilité = 'private' → requiert une session avec media.view
*
* On garde tout sous /zen/api/ pour profiter du file-response builder du core
* qui détecte `{ success: true, file: {...} }` et stream le binaire.
*/
import { defineApiRoutes } from '@zen/core/api';
import { apiError, apiSuccess } from '@zen/core/api';
import { getFile } from '@zen/core/storage';
import { hasPermission } from '@zen/core/users';
import { getSessionCookieName } from '@zen/core/shared/config';
import { fail } from '@zen/core/shared/logger';
import {
uploadMedia,
deleteMedia,
updateMedia,
getMediaById,
getMediaBySlug,
listMedia,
countReferences,
listReferences,
} from './api.server.js';
import { MEDIA_PERMISSIONS } from './permissions.js';
const COOKIE_NAME = getSessionCookieName();
// ─── Admin handlers ────────────────────────────────────────────────────────
async function handleListMedia(request) {
const url = new URL(request.url);
const result = await listMedia({
page: parseInt(url.searchParams.get('page') || '1', 10),
limit: parseInt(url.searchParams.get('limit') || '24', 10),
search: url.searchParams.get('search') || undefined,
kind: url.searchParams.get('kind') || undefined,
visibility: url.searchParams.get('visibility') || undefined,
});
return apiSuccess(result);
}
async function handleUploadMedia(request, _params, { session }) {
try {
const formData = await request.formData();
const file = formData.get('file');
if (!file) return apiError('Bad Request', 'No file provided');
const visibility = formData.get('visibility') === 'public' ? 'public' : 'private';
const altText = formData.get('altText') || null;
const caption = formData.get('caption') || null;
const result = await uploadMedia({
file,
uploadedBy: session.user.id,
visibility,
altText,
caption,
});
if (!result.success) return apiError('Bad Request', result.error);
return apiSuccess({ media: result.media });
} catch (error) {
fail(`handleUploadMedia: ${error.message}`);
return apiError('Internal Server Error', 'Échec du téléversement');
}
}
async function handleGetMediaAdmin(_request, { id }) {
const media = await getMediaById(id);
if (!media) return apiError('Not Found', 'Média introuvable');
const referenceCount = await countReferences(id);
return apiSuccess({ media, referenceCount });
}
async function handleListReferencesAdmin(_request, { id }) {
const media = await getMediaById(id);
if (!media) return apiError('Not Found', 'Média introuvable');
const references = await listReferences(id);
return apiSuccess({ references });
}
async function handleUpdateMedia(request, { id }) {
try {
const body = await request.json();
const result = await updateMedia(id, {
visibility: body.visibility,
altText: body.altText,
caption: body.caption,
});
if (!result.success) {
const code = result.error === 'Media not found' ? 'Not Found' : 'Bad Request';
return apiError(code, result.error);
}
return apiSuccess({ media: result.media });
} catch (error) {
fail(`handleUpdateMedia: ${error.message}`);
return apiError('Internal Server Error', 'Échec de la mise à jour');
}
}
async function handleDeleteMedia(_request, { id }) {
const result = await deleteMedia(id);
if (!result.success) {
if (result.error === 'Media not found') return apiError('Not Found', result.error);
if (result.referenceCount > 0) return apiError('Bad Request', result.error);
return apiError('Internal Server Error', result.error);
}
return apiSuccess({ success: true });
}
// ─── Public file serving ──────────────────────────────────────────────────
async function handleServeMediaBySlug(_request, { slug }) {
try {
if (!slug || /[^A-Za-z0-9_-]/.test(slug)) {
return apiError('Bad Request', 'Invalid slug');
}
const media = await getMediaBySlug(slug);
if (!media) return apiError('Not Found', 'Média introuvable');
// Visibilité privée : exiger une session avec media.view.
if (media.visibility !== 'public') {
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) return apiError('Not Found', 'Média introuvable');
const { getSessionResolver } = await import('@zen/core/api');
const session = await getSessionResolver()(sessionToken);
if (!session) return apiError('Not Found', 'Média introuvable');
const allowed = await hasPermission(session.user.id, MEDIA_PERMISSIONS.VIEW);
if (!allowed) return apiError('Not Found', 'Média introuvable');
}
const fetched = await getFile(media.storage_key);
if (!fetched.success) {
fail(`handleServeMediaBySlug: storage miss for ${media.storage_key}: ${fetched.error}`);
return apiError('Not Found', 'Média introuvable');
}
return {
success: true,
file: {
body: fetched.data.body,
contentType: media.mime_type,
contentLength: fetched.data.contentLength,
lastModified: fetched.data.lastModified,
},
};
} catch (error) {
fail(`handleServeMediaBySlug: ${error.message}`);
return apiError('Internal Server Error', 'Failed to serve media');
}
}
// ─── Routes ───────────────────────────────────────────────────────────────
export const routes = defineApiRoutes([
{ path: '/media', method: 'GET', handler: handleListMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
{ path: '/media', method: 'POST', handler: handleUploadMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
{ path: '/media/:id', method: 'GET', handler: handleGetMediaAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
{ path: '/media/:id', method: 'PATCH', handler: handleUpdateMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
{ path: '/media/:id', method: 'DELETE', handler: handleDeleteMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.DELETE },
{ path: '/media/:id/references', method: 'GET', handler: handleListReferencesAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
// Public file-serving route — préfixe dédié `/file/` pour éviter la collision
// avec `/media/:id`. Le handler enforce la visibilité en interne.
{ path: '/media/file/:slug', method: 'GET', handler: handleServeMediaBySlug, auth: 'public' },
]);
+126
View File
@@ -0,0 +1,126 @@
/**
* Media Feature — Database Schema
*
* Tables :
* - zen_media : registre central des médias (slug, clé S3, visibilité, owner, metadata).
* - zen_media_references : tracking des usages pour empêcher la suppression d'un
* média référencé (ON DELETE RESTRICT côté FK).
*
* Les permissions media sont enregistrées au module-load pour qu'elles soient
* disponibles avant le seed des rôles (ce qui les ajoute automatiquement au rôle
* admin lors d'un `npx zen-db init`).
*/
import { query, tableExists } from '@zen/core/database';
import { done, warn } from '@zen/core/shared/logger';
import { registerPermissions } from '../../core/users/permissions-registry.js';
import { MEDIA_PERMISSION_DEFINITIONS } from './permissions.js';
// Side effect : enregistre les permissions dès l'import du module.
registerPermissions(MEDIA_PERMISSION_DEFINITIONS);
const MEDIA_TABLES = [
{
name: 'zen_media',
sql: `
CREATE TABLE zen_media (
id text NOT NULL PRIMARY KEY,
slug text NOT NULL UNIQUE,
storage_key text NOT NULL UNIQUE,
original_name text NOT NULL,
mime_type text NOT NULL,
size_bytes bigint NOT NULL,
visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'public')),
uploaded_by text REFERENCES zen_auth_users(id) ON DELETE SET NULL,
alt_text text,
caption text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`,
indexes: [
`CREATE INDEX zen_media_visibility_idx ON zen_media (visibility)`,
`CREATE INDEX zen_media_mime_type_idx ON zen_media (mime_type)`,
`CREATE INDEX zen_media_uploaded_by_idx ON zen_media (uploaded_by)`,
`CREATE INDEX zen_media_created_at_idx ON zen_media (created_at DESC)`,
],
},
{
name: 'zen_media_references',
sql: `
CREATE TABLE zen_media_references (
media_id text NOT NULL REFERENCES zen_media(id) ON DELETE RESTRICT,
source_type text NOT NULL,
source_id text NOT NULL,
field text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (media_id, source_type, source_id, field)
)
`,
indexes: [
`CREATE INDEX zen_media_references_source_idx ON zen_media_references (source_type, source_id)`,
],
},
];
/**
* Idempotent : insère les permissions media dans le catalogue BD et les attribue
* au rôle admin. Permet d'activer le module après un init initial sans recréer
* la BD entière.
*/
async function syncMediaPermissions() {
for (const perm of MEDIA_PERMISSION_DEFINITIONS) {
await query(
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
[perm.key, perm.name, perm.description, perm.group_name]
);
}
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
SELECT r.id, p.key
FROM zen_auth_roles r
CROSS JOIN zen_auth_permissions p
WHERE r.name = 'admin' AND p.key = ANY($1::text[])
ON CONFLICT DO NOTHING`,
[MEDIA_PERMISSION_DEFINITIONS.map(p => p.key)]
);
}
export async function createTables() {
const created = [];
const skipped = [];
for (const table of MEDIA_TABLES) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
for (const indexSql of table.indexes ?? []) {
await query(indexSql);
}
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
}
}
await syncMediaPermissions();
return { created, skipped };
}
export async function dropTables() {
const dropOrder = [...MEDIA_TABLES].reverse().map(t => t.name);
warn('Dropping all Zen media tables...');
for (const tableName of dropOrder) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All media tables dropped');
}
+4
View File
@@ -52,3 +52,7 @@ export function getAppConfig() {
export function isDevkitEnabled() {
return process.env.ZEN_DEVKIT === 'true';
}
export function isMediaEnabled() {
return process.env.ZEN_MEDIA === 'true';
}
+4
View File
@@ -18,6 +18,8 @@ import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeature
import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
import { validateSession } from '../../features/auth/session.js';
import { routes as authRoutes } from '../../features/auth/api.js';
import { routes as mediaRoutes } from '../../features/media/routes.server.js';
import { MEDIA_PERMISSION_DEFINITIONS } from '../../features/media/permissions.js';
import { storageAccessPolicies } from '../../features/auth/storage-policies.js';
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
@@ -50,8 +52,10 @@ export async function initializeZen({ modules = [] } = {}) {
configureRouter({ resolveSession: validateSession });
registerFeatureRoutes(authRoutes);
registerFeatureRoutes(mediaRoutes);
registerStoragePolicies(storageAccessPolicies);
registerPermissions(PERMISSION_DEFINITIONS);
registerPermissions(MEDIA_PERMISSION_DEFINITIONS);
// Activation des modules @zen/module-* via le manifeste statique fourni.
registerModules(modules);