ordinarthur b2dd991c58
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m15s
fix(blog/admin): accept upload URLs (absolute + relative /uploads paths)
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>
2026-05-09 17:54:06 +02:00

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