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:
+4
-1
@@ -55,4 +55,7 @@ ZEN_PUBLIC_LOGO_URL=
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# DEVKIT (developer tools)
|
||||
ZEN_DEVKIT=false
|
||||
ZEN_DEVKIT=false
|
||||
|
||||
# MEDIA (gestionnaire de médias CMS — images, PDFs, vidéos attachés au contenu du site)
|
||||
ZEN_MEDIA=false
|
||||
@@ -211,6 +211,41 @@ export default function BlogCreatePage() {
|
||||
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
|
||||
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |
|
||||
|
||||
### Médias attachés au contenu
|
||||
|
||||
Pour qu'un module puisse attacher des médias (image à la une, galerie, PDF) à ses propres ressources, utiliser `@zen/core/features/media` côté serveur et `@zen/core/features/media/picker` côté client.
|
||||
|
||||
```js
|
||||
// côté serveur — au moment de sauvegarder un billet :
|
||||
import { attachMedia, detachAllForSource } from '@zen/core/features/media';
|
||||
|
||||
await attachMedia({
|
||||
mediaId: payload.featuredImageId,
|
||||
sourceType: '@zen/module-blog:post',
|
||||
sourceId: post.id,
|
||||
field: 'featured_image',
|
||||
});
|
||||
|
||||
// au moment de supprimer le billet : libérer toutes les références.
|
||||
await detachAllForSource({ sourceType: '@zen/module-blog:post', sourceId: post.id });
|
||||
```
|
||||
|
||||
```jsx
|
||||
// côté client — sélecteur dans un formulaire :
|
||||
'use client';
|
||||
import MediaPicker from '@zen/core/features/media/picker';
|
||||
|
||||
<MediaPicker
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
accept="image/*"
|
||||
visibility="public"
|
||||
onSelect={(media) => setFeaturedImage(media)}
|
||||
/>
|
||||
```
|
||||
|
||||
Le module Médias doit être activé (`ZEN_MEDIA=true`) dans le projet consommateur — sinon les permissions ne sont pas seedées et les API renvoient 403. Vérifier avec `isMediaEnabled()` côté serveur si on veut adapter l'UI.
|
||||
|
||||
### Widgets dashboard
|
||||
|
||||
```js
|
||||
@@ -356,6 +391,7 @@ Sous-entrées explicitement safe pour un import depuis un fichier `'use client'`
|
||||
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
|
||||
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
|
||||
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
|
||||
| `@zen/core/features/media/picker` | Composant client `MediaPicker` (modale de sélection de média). Importer **uniquement ce sous-chemin** depuis un client — `@zen/core/features/media` (barrel) tire le code serveur. |
|
||||
| `@zen/core/themes` | Tokens/utilitaires de thème. |
|
||||
| `@zen/core/toast` | API toast côté client. |
|
||||
| `@zen/core/shared/icons` | Composants d'icônes. |
|
||||
|
||||
@@ -31,4 +31,6 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
||||
|
||||
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
||||
|
||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
||||
|
||||
**Médias** — Pour gérer les fichiers attachés au contenu publié (images, PDFs, vidéos), utiliser `src/features/media`. Activable via `ZEN_MEDIA=true`. Expose `uploadMedia`/`deleteMedia`/`attachMedia`/`detachMedia` côté serveur et un composant `MediaPicker` réutilisable côté client. Voir `src/features/media/README.md`. Distinct d'un futur module `files` (style Drive/Dropbox) — ne pas confondre les deux usages.
|
||||
@@ -88,6 +88,12 @@
|
||||
"./features/admin/components": {
|
||||
"import": "./dist/features/admin/components/index.js"
|
||||
},
|
||||
"./features/media": {
|
||||
"import": "./dist/features/media/index.js"
|
||||
},
|
||||
"./features/media/picker": {
|
||||
"import": "./dist/features/media/components/MediaPicker.client.js"
|
||||
},
|
||||
"./features/provider": {
|
||||
"import": "./dist/features/provider/index.js"
|
||||
},
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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é**.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 });
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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' },
|
||||
]);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user