- Rewrite content with clearer structure, adding env variable examples and improving field type descriptions
14 KiB
Module Posts
Types de contenus configurables via variables d'environnement. Chaque projet déclare ses propres types (blogue, CVE, emploi, événement...) avec les champs dont il a besoin, sans toucher au code.
Configuration
Variables d'environnement
Copier les variables de .env.example dans votre .env.
# Liste des types (séparés par |, en minuscules)
# Format optionnel avec label : cle:Label
ZEN_MODULE_POSTS_TYPES=blogue:Blogue|cve:CVE|emploi:Emplois
# Champs par type : nom:type|nom:type|...
ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image
ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|date:datetime|description:markdown|tags:relation:tag
ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug
Si aucun label n'est fourni (ZEN_MODULE_POSTS_TYPES=blogue), le nom affiché sera la clé avec la première lettre en majuscule.
Types de champs
| Type | Syntaxe .env |
Description |
|---|---|---|
title |
nom:title |
Champ texte principal, génère le slug automatiquement |
slug |
nom:slug |
Slug unique par type, pré-rempli depuis le titre |
text |
nom:text |
Zone de texte libre |
markdown |
nom:markdown |
Éditeur Markdown avec prévisualisation |
date |
nom:date |
Sélecteur de date (YYYY-MM-DD) |
datetime |
nom:datetime |
Date et heure (ISO 8601, UTC) |
color |
nom:color |
Sélecteur de couleur, stocke un code hex #rrggbb |
category |
nom:category |
Menu déroulant lié à la table des catégories |
image |
nom:image |
Upload d'image vers le stockage Zen |
relation |
nom:relation:type_cible |
Sélection multiple vers des posts d'un autre type |
Chaque type doit avoir au moins un champ title et un champ slug.
date ou datetime ?
datestocke"2026-03-14". Suffisant pour un billet de blogue ou un événement.datetimestocke"2026-03-14T10:30:00.000Z". Nécessaire pour les CVE ou tout contenu avec une heure précise.
Champ relation
Le champ relation associe plusieurs posts d'un autre type (many-to-many).
ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|source:relation:source|tags:relation:tag
ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug
ZEN_MODULE_POSTS_TYPE_SOURCE=title:title|slug:slug
- Le nom avant
relation(source,tags) est le nom du champ dans le formulaire et dans la réponse API. - La valeur après
relation(source,tag) est le type cible. - La sélection est multiple avec recherche en temps réel.
- Les relations sont stockées dans
zen_posts_relations. - Dans l'API, les relations sont retournées comme un tableau d'objets avec tous les champs du post lié.
Images
Si un type utilise le champ image, configurer le stockage Zen dans le .env principal : ZEN_STORAGE_REGION, ZEN_STORAGE_ACCESS_KEY, ZEN_STORAGE_SECRET_KEY, ZEN_STORAGE_BUCKET.
Base de données
Les tables sont créées automatiquement avec npx zen-db init.
| Table | Description |
|---|---|
zen_posts |
Posts de tous les types (champs personnalisés dans data JSONB) |
zen_posts_category |
Catégories par type (créée si un champ category est défini) |
zen_posts_relations |
Relations entre posts (créée si un champ relation est défini) |
Tous les champs personnalisés sont dans la colonne data JSONB. Ajouter ou retirer un champ dans le .env ne nécessite aucune migration SQL.
Interface d'administration
| Page | URL |
|---|---|
| Liste des posts | /admin/posts/{type}/list |
| Créer un post | /admin/posts/{type}/new |
| Modifier un post | /admin/posts/{type}/edit/{id} |
| Liste des catégories | /admin/posts/{type}/categories |
| Créer une catégorie | /admin/posts/{type}/categories/new |
| Modifier une catégorie | /admin/posts/{type}/categories/edit/{id} |
API publique
Pas d'authentification requise.
Config
GET /zen/api/posts/config
Retourne la liste de tous les types configurés avec leurs champs.
Liste de posts
GET /zen/api/posts/{type}
| Paramètre | Défaut | Description |
|---|---|---|
page |
1 |
Page courante |
limit |
20 |
Résultats par page |
category_id |
— | Filtrer par catégorie |
sortBy |
created_at |
Trier par (nom de champ du type) |
sortOrder |
DESC |
ASC ou DESC |
withRelations |
false |
true pour inclure les champs relation |
withRelations=true exécute une requête SQL supplémentaire par post. Garder un limit raisonnable (20 maximum). Sur une page de détail, préférer /posts/{type}/{slug} qui charge toujours les relations.
Réponse sans relations (défaut) :
{
"success": true,
"posts": [
{
"id": 1,
"post_type": "actualite",
"slug": "faille-critique-openssh",
"title": "Faille critique dans OpenSSH",
"date": "2026-03-14T10:30:00.000Z",
"resume": "Une faille critique...",
"image": "blog/1234567890-image.webp",
"created_at": "2026-03-14T12:00:00Z",
"updated_at": "2026-03-14T12:00:00Z"
}
],
"total": 42,
"totalPages": 3,
"page": 1,
"limit": 20
}
Réponse avec withRelations=true :
{
"success": true,
"posts": [
{
"id": 1,
"slug": "faille-critique-openssh",
"title": "Faille critique dans OpenSSH",
"date": "2026-03-14T10:30:00.000Z",
"source": [
{ "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" }
],
"tags": [
{ "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
{ "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
]
}
]
}
Post par slug
GET /zen/api/posts/{type}/{slug}
Les relations sont toujours incluses sur un post individuel.
Catégories
GET /zen/api/posts/{type}/categories
Retourne les catégories actives du type (pour alimenter un filtre).
Images
Les clés d'image s'utilisent avec la route de stockage :
<img src={`/zen/api/storage/${post.image}`} alt={post.title} />
Intégration Next.js
Liste de posts
// app/actualites/page.js
export default async function ActualitesPage() {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&sortBy=date&sortOrder=DESC`
);
const { posts, total, totalPages } = await res.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/actualites/${post.slug}`}>{post.title}</a>
<p>{post.resume}</p>
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
</li>
))}
</ul>
);
}
Liste avec relations
// app/actualites/page.js
export default async function ActualitesPage() {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&withRelations=true`
);
const { posts } = await res.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/actualites/${post.slug}`}>{post.title}</a>
{post.source?.[0] && (
<span>Source : {post.source[0].title}</span>
)}
<div>
{post.tags?.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
))}
</div>
</li>
))}
</ul>
);
}
Page de détail
// app/actualites/[slug]/page.js
export default async function ActualiteDetailPage({ params }) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
);
const { post } = await res.json();
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{post.source?.[0] && (
<p>Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a></p>
)}
{post.tags?.length > 0 && (
<div>
{post.tags.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
))}
</div>
)}
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
<div>{post.content}</div>
</article>
);
}
Métadonnées SEO dynamiques
// app/actualites/[slug]/page.js
export async function generateMetadata({ params }) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
);
const { post } = await res.json();
if (!post) return {};
return {
title: post.title,
description: post.resume,
openGraph: {
title: post.title,
description: post.resume,
images: post.image ? [`/zen/api/storage/${post.image}`] : [],
},
};
}
Ajouter ou modifier un type
Ajouter un type : modifier uniquement le .env, pas besoin de redémarrer la base.
# Avant
ZEN_MODULE_POSTS_TYPES=cve:CVE|actualite:Actualités
# Après
ZEN_MODULE_POSTS_TYPES=cve:CVE|actualite:Actualités|evenement:Événements
ZEN_MODULE_POSTS_TYPE_EVENEMENT=title:title|slug:slug|date:datetime|location:text|description:markdown|image:image
Redémarrer le serveur. Les tables ne changent pas (les nouveaux champs utilisent le JSONB existant).
Modifier les champs d'un type existant : mettre à jour la variable ZEN_MODULE_POSTS_TYPE_* et redémarrer. Les posts existants conservent leurs données en JSONB, même si un champ est retiré de la config.
Utilisation programmatique
Les fonctions CRUD sont importables côté serveur. Idéal pour les cron jobs, scripts d'import ou fetchers automatisés.
Fonctions disponibles
import {
createPost, // Créer un post
updatePost, // Modifier un post
getPostBySlug, // Chercher par slug
getPostByField, // Chercher par n'importe quel champ JSONB
upsertPost, // Créer ou mettre à jour (idempotent)
getPosts, // Liste avec pagination
deletePost, // Supprimer
} from '@hykocx/zen/modules/posts/crud';
upsertPost(postType, rawData, uniqueField)
Crée le post s'il n'existe pas, le met à jour sinon.
postType: le type de post ('cve','actualite'...)rawData: les données du post (mêmes champs que pourcreatePost)uniqueField: le champ de déduplication ('slug'par défaut)
Retourne { post, created: boolean }.
Champs relation dans rawData
Les champs relation reçoivent un tableau d'IDs de posts existants.
// Correct
{ tags: [7, 8, 12], source: [3] }
// Incorrect
{ tags: ['openssh', 'vuln'], source: { id: 3 } }
Si les posts liés n'existent pas encore, les créer d'abord avec upsertPost puis utiliser leurs IDs.
Exemple : fetcher de CVE
// src/cron/fetch-cves.js
import { upsertPost } from '@hykocx/zen/modules/posts/crud';
export async function fetchAndImportCVEs() {
const response = await fetch('https://api.example.com/cves/recent');
const { cves } = await response.json();
const results = { created: 0, updated: 0, errors: 0 };
for (const cve of cves) {
try {
// Résoudre les relations : s'assurer que les tags existent
const tagIds = [];
for (const tagName of (cve.tags || [])) {
const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug');
tagIds.push(tag.id);
}
// Upsert du CVE, dédupliqué sur cve_id
const { created } = await upsertPost('cve', {
title: cve.title,
cve_id: cve.id,
severity: cve.severity,
score: String(cve.cvssScore),
product: cve.affectedProduct,
date: cve.publishedAt,
description: cve.description,
tags: tagIds,
}, 'cve_id');
created ? results.created++ : results.updated++;
} catch (err) {
console.error(`[CVE import] Error for ${cve.id}:`, err.message);
results.errors++;
}
}
console.log(`[CVE import] Done — created: ${results.created}, updated: ${results.updated}, errors: ${results.errors}`);
return results;
}
Exemple : fetcher d'actualités avec source
import { upsertPost } from '@hykocx/zen/modules/posts/crud';
export async function fetchAndImportActualites(sourceName, articles) {
// S'assurer que la source existe
const { post: source } = await upsertPost('source', { title: sourceName }, 'slug');
for (const article of articles) {
await upsertPost('actualite', {
title: article.title,
date: article.publishedAt,
resume: article.summary,
content: article.content,
source: [source.id],
tags: [],
}, 'slug');
}
}
API d'administration
Authentification requise.
| Méthode | Route | Description |
|---|---|---|
GET |
/zen/api/admin/posts/config |
Config complète de tous les types |
GET |
/zen/api/admin/posts/search?type={type}&q={query} |
Recherche pour le sélecteur de relation |
GET |
/zen/api/admin/posts/posts?type={type} |
Liste des posts d'un type |
GET |
/zen/api/admin/posts/posts?type={type}&withRelations=true |
Liste avec relations |
GET |
/zen/api/admin/posts/posts?type={type}&id={id} |
Post par ID (relations toujours incluses) |
POST |
/zen/api/admin/posts/posts?type={type} |
Créer un post |
PUT |
/zen/api/admin/posts/posts?type={type}&id={id} |
Modifier un post |
DELETE |
/zen/api/admin/posts/posts?type={type}&id={id} |
Supprimer un post |
POST |
/zen/api/admin/posts/upload-image |
Upload d'image |
GET |
/zen/api/admin/posts/categories?type={type} |
Liste des catégories |
POST |
/zen/api/admin/posts/categories?type={type} |
Créer une catégorie |
PUT |
/zen/api/admin/posts/categories?type={type}&id={id} |
Modifier une catégorie |
DELETE |
/zen/api/admin/posts/categories?type={type}&id={id} |
Supprimer une catégorie |