chore: import codes
This commit is contained in:
@@ -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 |
|
||||
Reference in New Issue
Block a user