Files
core/src/modules/posts/README.md
T
hykocx c33383adf7 docs: translate posts README to French and update language guide
- Rewrite content with clearer structure, adding env variable examples and improving field type descriptions
2026-04-12 14:32:21 -04:00

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 ?

  • date stocke "2026-03-14". Suffisant pour un billet de blogue ou un événement.
  • datetime stocke "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 pour createPost)
  • 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