feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
  API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
  30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
  posts were migrated to Directus earlier and the JSX copies are removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:17:40 +03:00
parent 25ffec5789
commit 4e67f30d6d
4 changed files with 86 additions and 413 deletions

View File

@@ -4,6 +4,7 @@ import {
Controller,
Get,
Headers,
HttpCode,
Param,
Post,
ServiceUnavailableException,
@@ -13,6 +14,25 @@ import { Public } from "../common/decorators/public.decorator";
import type { CreateBlogPost } from "./blog.dto";
import { BlogService } from "./blog.service";
// Bearer-token check shared by the automation endpoints (n8n pipeline and the
// Directus cache-purge Flow). Mirrors the changelog/internal token pattern.
function assertAutomationToken(authHeader: string | undefined): void {
const token = process.env.BLOG_AUTOMATION_TOKEN;
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
}
@Controller("blog")
export class BlogController {
constructor(private readonly blogService: BlogService) {}
@@ -30,30 +50,27 @@ export class BlogController {
}
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
// Mirrors the changelog/internal Bearer-token pattern.
@Post("posts/internal")
@Public()
async createInternal(
@Headers("authorization") authHeader: string,
@Body() body: CreateBlogPost,
) {
const token = process.env.BLOG_AUTOMATION_TOKEN;
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
assertAutomationToken(authHeader);
if (!body?.title || !body?.body_markdown) {
throw new UnauthorizedException("title and body_markdown are required");
}
return this.blogService.create(body);
}
// Called by a Directus Flow whenever a post is created/updated/deleted, so
// edits made in the CMS admin appear immediately instead of after cache TTL.
@Post("cache/purge")
@Public()
@HttpCode(200)
async purgeCache(@Headers("authorization") authHeader: string) {
assertAutomationToken(authHeader);
await this.blogService.purgeListCache();
return { purged: true };
}
}

View File

@@ -18,6 +18,13 @@ const DIRECTUS_URL = process.env.DIRECTUS_URL?.replace(/\/+$/, "");
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN;
const DIRECTUS_PROJECT = process.env.DIRECTUS_PROJECT ?? "sase";
// Files uploaded in the Directus admin land in the shared MinIO `blog-assets`
// bucket (public read), served from the MinIO domain — NOT from Directus,
// which is Tailscale-only and unreachable for site visitors.
const BLOG_ASSETS_BASE_URL = (
process.env.BLOG_ASSETS_BASE_URL ?? "https://storage.sase.tr/blog-assets"
).replace(/\/+$/, "");
// Row shape of the shared `posts` collection in Directus.
interface DirectusPost {
id: string;
@@ -27,6 +34,7 @@ interface DirectusPost {
body_markdown: string;
tags: string[] | null;
cover_image: string | null;
cover_file: { filename_disk: string } | null;
status: string;
source: string;
published_at: string;
@@ -34,6 +42,16 @@ interface DirectusPost {
date_updated: string | null;
}
// cover_image (external URL, e.g. from the content pipeline) wins over
// cover_file (admin upload, resolved to its public MinIO URL).
function resolveCover(row: Pick<DirectusPost, "cover_image" | "cover_file">): string | null {
if (row.cover_image) return row.cover_image;
if (row.cover_file?.filename_disk) {
return `${BLOG_ASSETS_BASE_URL}/${row.cover_file.filename_disk}`;
}
return null;
}
// URL-safe slug, Turkish-aware. The content_blog prompt already emits a clean
// slug, but we slugify defensively (and to derive one if it's missing).
function slugify(input: string): string {
@@ -91,7 +109,7 @@ export class BlogService {
metaDescription: row.meta_description,
bodyMarkdown: row.body_markdown,
tags: row.tags ?? [],
coverImage: row.cover_image,
coverImage: resolveCover(row),
status: row.status as BlogPost["status"],
source: row.source,
publishedAt: row.published_at,
@@ -100,6 +118,12 @@ export class BlogService {
};
}
// Called by the Directus Flow webhook when a post changes, so both envs
// drop their 30-minute list cache immediately.
async purgeListCache(): Promise<void> {
await this.redis.del(LIST_CACHE_KEY);
}
async findAllPublished(): Promise<BlogPostListItem[]> {
const cached = await this.redis.getJson<BlogPostListItem[]>(LIST_CACHE_KEY);
if (cached) return cached;
@@ -112,7 +136,7 @@ export class BlogService {
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[status][_eq]": "published",
sort: "-published_at",
fields: "slug,title,meta_description,tags,cover_image,published_at",
fields: "slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
limit: "-1",
}),
);
@@ -127,7 +151,7 @@ export class BlogService {
title: r.title,
metaDescription: r.meta_description,
tags: r.tags ?? [],
coverImage: r.cover_image,
coverImage: resolveCover(r),
publishedAt: r.published_at,
}));
@@ -144,6 +168,7 @@ export class BlogService {
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[slug][_eq]": slug,
"filter[status][_eq]": "published",
fields: "*,cover_file.filename_disk",
limit: "1",
}),
);