fix(blog/admin): accept upload URLs (absolute + relative /uploads paths)
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>
This commit is contained in:
ordinarthur 2026-05-09 17:54:06 +02:00
parent 52bc7507fb
commit b2dd991c58
2 changed files with 24 additions and 5 deletions

View File

@ -59,8 +59,13 @@ export async function uploadBlogImage(file: MultipartFile): Promise<UploadResult
} }
await drive.use().moveFromFs(file.tmpPath, storageKey) await drive.use().moveFromFs(file.tmpPath, storageKey)
// URL absolue pour que la landing publique (rubis.pro) puisse l'afficher
// directement en <img src> sans avoir à connaître l'API host. APP_URL est
// posé par le ConfigMap k3s rubis-api-config ('https://app.rubis.pro').
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
return { return {
publicPath: `/api/v1/uploads/blog/${filename}`, publicPath: `${apiHost}/api/v1/uploads/blog/${filename}`,
storageKey, storageKey,
contentType: extToContentType(ext), contentType: extToContentType(ext),
sizeBytes: file.size, sizeBytes: file.size,

View File

@ -18,6 +18,20 @@ const title = () => vine.string().minLength(5).maxLength(80)
const excerpt = () => vine.string().minLength(80).maxLength(280) const excerpt = () => vine.string().minLength(80).maxLength(280)
const tags = () => vine.array(vine.string().maxLength(50)).maxLength(5).distinct() 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` * Création d'un brouillon. On accepte `status` mais c'est toujours `draft`
* à la création la publication passe par /publish. * à la création la publication passe par /publish.
@ -30,9 +44,9 @@ export const createPostValidator = vine.compile(
contentMd: vine.string().minLength(50), contentMd: vine.string().minLength(50),
tags: tags().optional(), tags: tags().optional(),
authorName: vine.string().minLength(2).maxLength(100).optional(), authorName: vine.string().minLength(2).maxLength(100).optional(),
heroImageUrl: vine.string().url().nullable().optional(), heroImageUrl: imagePathOrUrl().nullable().optional(),
heroImageAlt: vine.string().maxLength(250).nullable().optional(), heroImageAlt: vine.string().maxLength(250).nullable().optional(),
ogImageUrl: vine.string().url().nullable().optional(), ogImageUrl: imagePathOrUrl().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(), canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(), noindex: vine.boolean().optional(),
}), }),
@ -50,9 +64,9 @@ export const updatePostValidator = vine.compile(
contentMd: vine.string().minLength(50).optional(), contentMd: vine.string().minLength(50).optional(),
tags: tags().optional(), tags: tags().optional(),
authorName: vine.string().minLength(2).maxLength(100).optional(), authorName: vine.string().minLength(2).maxLength(100).optional(),
heroImageUrl: vine.string().url().nullable().optional(), heroImageUrl: imagePathOrUrl().nullable().optional(),
heroImageAlt: vine.string().maxLength(250).nullable().optional(), heroImageAlt: vine.string().maxLength(250).nullable().optional(),
ogImageUrl: vine.string().url().nullable().optional(), ogImageUrl: imagePathOrUrl().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(), canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(), noindex: vine.boolean().optional(),
}), }),