All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m15s
L'upload renvoie maintenant une URL absolue construite depuis APP_URL (`https://app.rubis.pro/api/v1/uploads/blog/{uuid}.{ext}`), pour que la landing publique l'affiche directement en <img src> sans absolutize. Le validator post (createPostValidator + updatePostValidator) accepte : * Les URLs HTTPS absolues (image externe ou notre upload absolutisé) * Les paths relatifs `/api/v1/uploads/...` (rétro-compat sécurité — si une URL relative arrive d'une autre source, on la laisse passer plutôt que 422 sur un champ qui résout côté client) Bug initial : POST /api/v1/admin/uploads renvoyait `/api/v1/uploads/...` (relatif), puis le PATCH /admin/posts/:id rejetait ce path en 422 car `vine.string().url()` exige une URL absolue. Cause = double oubli (path relatif côté upload + validator strict). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
import vine from '@vinejs/vine'
|
|
|
|
/**
|
|
* Règles SEO + édito :
|
|
* - title ≤ 60 chars (sweet spot Google snippet)
|
|
* - excerpt 120-160 chars (sweet spot meta description)
|
|
* - slug kebab-case ASCII unique
|
|
* - tags max 5
|
|
*/
|
|
const slug = () =>
|
|
vine
|
|
.string()
|
|
.minLength(3)
|
|
.maxLength(200)
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
|
|
|
const title = () => vine.string().minLength(5).maxLength(80)
|
|
const excerpt = () => vine.string().minLength(80).maxLength(280)
|
|
const tags = () => vine.array(vine.string().maxLength(50)).maxLength(5).distinct()
|
|
|
|
/**
|
|
* URL d'image acceptée pour `hero_image_url` / `og_image_url` :
|
|
* - URL absolue HTTPS (image externe — rare, mais possible)
|
|
* - Path relatif commençant par /api/v1/uploads/... (uploads MinIO via
|
|
* notre endpoint `BlogUploadsController.show`)
|
|
*
|
|
* On refuse `http://` (sécurité mixed content) et autres chemins relatifs.
|
|
*/
|
|
const imagePathOrUrl = () =>
|
|
vine
|
|
.string()
|
|
.maxLength(500)
|
|
.regex(/^(https:\/\/.+|\/api\/v1\/uploads\/.+)$/)
|
|
|
|
/**
|
|
* Création d'un brouillon. On accepte `status` mais c'est toujours `draft`
|
|
* à la création — la publication passe par /publish.
|
|
*/
|
|
export const createPostValidator = vine.compile(
|
|
vine.object({
|
|
slug: slug(),
|
|
title: title(),
|
|
excerpt: excerpt(),
|
|
contentMd: vine.string().minLength(50),
|
|
tags: tags().optional(),
|
|
authorName: vine.string().minLength(2).maxLength(100).optional(),
|
|
heroImageUrl: imagePathOrUrl().nullable().optional(),
|
|
heroImageAlt: vine.string().maxLength(250).nullable().optional(),
|
|
ogImageUrl: imagePathOrUrl().nullable().optional(),
|
|
canonicalUrl: vine.string().url().nullable().optional(),
|
|
noindex: vine.boolean().optional(),
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* Update partiel d'un post existant. Tous les champs optionnels.
|
|
* `status` n'est pas mutable ici (passer par publish/unpublish).
|
|
*/
|
|
export const updatePostValidator = vine.compile(
|
|
vine.object({
|
|
slug: slug().optional(),
|
|
title: title().optional(),
|
|
excerpt: excerpt().optional(),
|
|
contentMd: vine.string().minLength(50).optional(),
|
|
tags: tags().optional(),
|
|
authorName: vine.string().minLength(2).maxLength(100).optional(),
|
|
heroImageUrl: imagePathOrUrl().nullable().optional(),
|
|
heroImageAlt: vine.string().maxLength(250).nullable().optional(),
|
|
ogImageUrl: imagePathOrUrl().nullable().optional(),
|
|
canonicalUrl: vine.string().url().nullable().optional(),
|
|
noindex: vine.boolean().optional(),
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* Au publish on durcit les règles SEO inline dans AdminPostsController.publish
|
|
* (cf. PUBLISH_REQUIREMENTS) — pas un validator vine séparé car on raisonne
|
|
* sur l'objet Post déjà persisté (avec wordCount calculé), pas sur un payload
|
|
* de requête.
|
|
*/
|
|
export const PUBLISH_REQUIREMENTS = {
|
|
titleMaxChars: 60, // sweet spot snippet Google
|
|
excerptMinChars: 120,
|
|
excerptMaxChars: 160,
|
|
minWordCount: 600, // ~3 min de lecture, considéré "substantiel"
|
|
} as const
|