chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+547
View File
@@ -0,0 +1,547 @@
# Posts Module
Configurable Custom Post Types via environment variables. Inspired by the WordPress CPT concept: each project declares its own content types (blog, CVE, job, event, etc.) with the fields it needs, without modifying code.
---
## Features
- **Multiple post types** in a single module (blog, CVE, job...)
- **Dynamic fields** per type: title, slug, text, markdown, date, datetime, color, category, image, relation
- **Generic admin**: forms adapt automatically to the config
- **Public API** per type for integration in a Next.js site
- **Optional categories** per type (enabled if a `category` field is defined)
- **Relations** between types (many-to-many, e.g. CVE → Tags)
- **Unique slugs** per type (scoped: `blogue/mon-article``cve/mon-article`)
---
## Installation
### 1. Environment variables
Copy variables from [`.env.example`](.env.example) into your `.env`:
> If no label is provided (`ZEN_MODULE_POSTS_TYPES=blogue`), the display name will be the key with the first letter capitalized (`Blogue`).
**Optional (images):**
If one of your types uses the `image` field, configure Zen storage in your main `.env` (`ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`).
### 2. Available field types
| Type | `.env` syntax | Description |
|---|---|---|
| `title` | `name:title` | Main text field — auto-generates the slug |
| `slug` | `name:slug` | Unique URL slug per type — auto-filled from title |
| `text` | `name:text` | Free text area (textarea) |
| `markdown` | `name:markdown` | Markdown editor with preview |
| `date` | `name:date` | Date picker only (YYYY-MM-DD) |
| `datetime` | `name:datetime` | Date **and time** picker (YYYY-MM-DDTHH:MM) |
| `color` | `name:color` | Color picker — stores a hex code `#rrggbb` |
| `category` | `name:category` | Dropdown linked to the category table |
| `image` | `name:image` | Image upload to Zen storage |
| `relation` | `name:relation:target_type` | Multi-select to posts of another type |
> **Rule:** each type must have at least one `title` field and one `slug` field. The `category` field automatically creates the `zen_posts_category` table. The `relation` field automatically creates the `zen_posts_relations` table.
#### `date` vs `datetime`
- `date` → stores `"2026-03-14"` — sufficient for blog posts, events
- `datetime` → stores `"2026-03-14T10:30:00.000Z"` (ISO 8601, UTC) — needed for CVEs, security bulletins, precise schedules
### `relation` field — Linking posts together
The `relation` field associates multiple posts of another type (many-to-many). Example: news posts referencing a source and tags.
```bash
ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|image:image|source:relation:source|tags:relation:tag
ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug
ZEN_MODULE_POSTS_TYPE_SOURCE=title:title|slug:slug
```
- The name before `relation` (`source`, `tags`) is the field name in the form and in the API response
- The value after `relation` (`source`, `tag`) is the target post type
- Selection is **multiple** (multi-select with real-time search)
- Relations are stored in `zen_posts_relations` (junction table)
- In the API, relations are returned as an array of objects containing **all fields** of the linked post: `tags: [{ id, slug, title, color, ... }]`
### 3. Database tables
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
| Table | Description |
|---|---|
| `zen_posts` | Posts (all types — custom fields in `data JSONB`) |
| `zen_posts_category` | Categories per type (if a `category` field is defined) |
| `zen_posts_relations` | Relations between posts (if a `relation` field is defined) |
> **Design:** all custom fields are stored in the `data JSONB` column. Adding or removing a field in `.env` requires no SQL migration.
---
## Admin interface
| Page | URL |
|---|---|
| Post list for a type | `/admin/posts/{type}/list` |
| Create a post | `/admin/posts/{type}/new` |
| Edit a post | `/admin/posts/{type}/edit/{id}` |
| Category list for a type | `/admin/posts/{type}/categories` |
| Create a category | `/admin/posts/{type}/categories/new` |
| Edit a category | `/admin/posts/{type}/categories/edit/{id}` |
---
## Public API (no authentication)
### Config
```
GET /zen/api/posts/config
```
Returns the list of all configured types with their fields.
### Post list
```
GET /zen/api/posts/{type}
```
**Query parameters:**
| Parameter | Default | Description |
|---|---|---|
| `page` | `1` | Current page |
| `limit` | `20` | Results per page |
| `category_id` | — | Filter by category |
| `sortBy` | `created_at` | Sort by (field name of the type) |
| `sortOrder` | `DESC` | `ASC` or `DESC` |
| `withRelations` | `false` | `true` to include relation fields in each post |
> **Performance:** `withRelations=true` runs an additional SQL query per post. Use with a reasonable `limit` (≤ 20). On a detail page, prefer `/posts/{type}/{slug}` which always loads relations.
**Response without `withRelations` (default):**
```json
{
"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
}
```
**Response with `withRelations=true`:**
```json
{
"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é" }
]
}
]
}
```
### Single post by slug
```
GET /zen/api/posts/{type}/{slug}
```
Relations are **always included** on a single post.
**Response for a news post:**
```json
{
"success": true,
"post": {
"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 a été découverte...",
"content": "# Détails\n\n...",
"image": "blog/1234567890-image.webp",
"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é" }
],
"created_at": "2026-03-14T12:00:00Z",
"updated_at": "2026-03-14T12:00:00Z"
}
}
```
### Categories
```
GET /zen/api/posts/{type}/categories
```
Returns the list of active categories for the type (to populate a filter).
### Images
Image keys are built as follows: `/zen/api/storage/{image_field_value}`
```jsx
<img src={`/zen/api/storage/${post.image}`} alt={post.title} />
```
---
## Next.js integration examples
### News list (without relations)
```js
// 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>
);
}
```
### News list with tags and source (withRelations)
```js
// 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>
{/* Source (array, usually 1 element) */}
{post.source?.[0] && (
<span>Source : {post.source[0].title}</span>
)}
{/* Tags (array of 0..N elements) */}
<div>
{post.tags?.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
))}
</div>
</li>
))}
</ul>
);
}
```
### News detail page (relations always included)
```js
// 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>
{/* datetime field: display with time */}
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{/* Source — array even with a single element */}
{post.source?.[0] && (
<p>
Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a>
</p>
)}
{/* Tags */}
{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>
);
}
```
### CVE detail page
```js
// app/cve/[slug]/page.js
export default async function CVEDetailPage({ params }) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/cve/${params.slug}`
);
const { post } = await res.json();
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>ID : {post.cve_id}</p>
<p>Sévérité : {post.severity} Score : {post.score}</p>
<p>Produit : {post.product}</p>
{/* Disclosure date and time */}
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{/* Associated tags */}
{post.tags?.length > 0 && (
<div>
{post.tags.map(tag => (
<span key={tag.id}>{tag.title}</span>
))}
</div>
)}
<div>{post.description}</div>
</article>
);
}
```
### Dynamic SEO metadata
```js
// 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}`] : [],
},
};
}
```
---
## Adding a new post type
Edit `.env` only — no database restart needed:
```bash
# Before
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités
# After — adding the 'evenement' type
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités|evenement:Événements
ZEN_MODULE_POSTS_TYPE_EVENEMENT=title:title|slug:slug|date:datetime|location:text|description:markdown|image:image
```
Restart the server. Tables are unchanged (new fields use the existing JSONB).
## Modifying fields on an existing type
Update the `ZEN_MODULE_POSTS_TYPE_*` variable and restart. Existing posts keep their data in JSONB even if a field is removed from the config — it simply won't appear in the form anymore.
---
## Programmatic usage (importers / fetchers)
CRUD functions are directly importable server-side. No need to go through the HTTP API — ideal for cron jobs, import scripts, or automated fetchers.
### Available functions
```js
import {
createPost, // Create a post
updatePost, // Update a post
getPostBySlug, // Find by slug
getPostByField, // Find by any JSONB field
upsertPost, // Create or update (idempotent, for importers)
getPosts, // List with pagination
deletePost, // Delete
} from '@hykocx/zen/modules/posts/crud';
```
### `upsertPost(postType, rawData, uniqueField)`
Key function for importers: creates the post if it doesn't exist, updates it otherwise.
- `postType`: the post type (`'cve'`, `'actualite'`, etc.)
- `rawData`: post data (same fields as for `createPost`)
- `uniqueField`: the deduplication key field (`'slug'` by default, or `'cve_id'`, etc.)
Returns `{ post, created: boolean }`.
### Example — CVE fetcher (cron job)
```js
// src/cron/fetch-cves.js
import { upsertPost } from '@hykocx/zen/modules/posts/crud';
export async function fetchAndImportCVEs() {
// 1. Fetch data from an external source
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 {
// 2. Resolve relations — ensure tags exist
const tagIds = [];
for (const tagName of (cve.tags || [])) {
const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug');
tagIds.push(tag.id);
}
// 3. Upsert the CVE (deduplicated on cve_id)
const { created } = await upsertPost('cve', {
title: cve.title, // title field
cve_id: cve.id, // text field
severity: cve.severity, // text field
score: String(cve.cvssScore), // text field
product: cve.affectedProduct, // text field
date: cve.publishedAt, // datetime field (ISO 8601)
description: cve.description, // markdown field
tags: tagIds, // relation:tag field — array of IDs
}, 'cve_id'); // deduplicate on 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;
}
```
### Example — News fetcher with source
```js
import { upsertPost, getPostByField } from '@hykocx/zen/modules/posts/crud';
export async function fetchAndImportActualites(sourceName, sourceUrl, articles) {
// 1. Ensure the source exists
const { post: source } = await upsertPost('source', {
title: sourceName,
color: '#3b82f6',
}, 'slug');
for (const article of articles) {
await upsertPost('actualite', {
title: article.title,
date: article.publishedAt,
resume: article.summary,
content: article.content,
source: [source.id], // relation:source — array of IDs
tags: [], // relation:tag
}, 'slug');
}
}
```
### Rules for relation fields in `rawData`
`relation` fields must receive an **array of IDs** of existing posts:
```js
// Correct
{ tags: [7, 8, 12], source: [3] }
// Incorrect — no slugs or objects
{ tags: ['openssh', 'vuln'], source: { id: 3 } }
```
If the linked posts don't exist yet, create them first with `upsertPost` then use their IDs.
---
## Admin API (authentication required)
These routes require an active admin session.
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/posts/config` | Full config for all types |
| `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Search posts for the relation picker |
| `GET` | `/zen/api/admin/posts/posts?type={type}` | Post list for a type |
| `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | List with relations included |
| `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post by ID (relations always included) |
| `POST` | `/zen/api/admin/posts/posts?type={type}` | Create a post |
| `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Update a post |
| `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Delete a post |
| `POST` | `/zen/api/admin/posts/upload-image` | Upload an image |
| `GET` | `/zen/api/admin/posts/categories?type={type}` | Category list |
| `POST` | `/zen/api/admin/posts/categories?type={type}` | Create a category |
| `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Update a category |
| `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Delete a category |