feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts
- 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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user