From b2dd991c5825b432aec9f9c5faed67a6bf47b65b Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Sat, 9 May 2026 17:54:06 +0200
Subject: [PATCH] fix(blog/admin): accept upload URLs (absolute + relative
/uploads paths)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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
---
apps/api/app/services/blog_uploads.ts | 7 ++++++-
apps/api/app/validators/post.ts | 22 ++++++++++++++++++----
2 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/apps/api/app/services/blog_uploads.ts b/apps/api/app/services/blog_uploads.ts
index fab92f7..7e518d4 100644
--- a/apps/api/app/services/blog_uploads.ts
+++ b/apps/api/app/services/blog_uploads.ts
@@ -59,8 +59,13 @@ export async function uploadBlogImage(file: MultipartFile): Promise 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 {
- publicPath: `/api/v1/uploads/blog/${filename}`,
+ publicPath: `${apiHost}/api/v1/uploads/blog/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: file.size,
diff --git a/apps/api/app/validators/post.ts b/apps/api/app/validators/post.ts
index 80b51b4..ae60ddf 100644
--- a/apps/api/app/validators/post.ts
+++ b/apps/api/app/validators/post.ts
@@ -18,6 +18,20 @@ 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.
@@ -30,9 +44,9 @@ export const createPostValidator = vine.compile(
contentMd: vine.string().minLength(50),
tags: tags().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(),
- ogImageUrl: vine.string().url().nullable().optional(),
+ ogImageUrl: imagePathOrUrl().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(),
}),
@@ -50,9 +64,9 @@ export const updatePostValidator = vine.compile(
contentMd: vine.string().minLength(50).optional(),
tags: tags().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(),
- ogImageUrl: vine.string().url().nullable().optional(),
+ ogImageUrl: imagePathOrUrl().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(),
}),