Hướng dẫn mã hóa bài viết Astro Blog
Tại sao muốn mã hóa bài viết
Đôi khi chúng ta muốn bài viết trên blog của mình không phải ai cũng có thể xem trực tiếp, tốt nhất có thể thiết lập xác thực câu hỏi - chỉ những người trả lời đúng mới có thể mở khóa nội dung.
Nghe có vẻ đơn giản, nhưng đối với blog tĩnh thuần túy mà nói, điều này thực sự khá phiền phức.
”Mã hóa” giả tạo trên blog tĩnh
Tất cả nội dung của blog tĩnh, tại thời điểm build đã được ghi vào HTML.
Ngay cả khi bạn dùng CSS hoặc JavaScript để “che giấu” văn bản, người dùng mở công cụ developer của trình duyệt có thể dễ dàng xem được nội dung gốc.
Một số người chọn cách mã hóa nội dung bài viết ở frontend, sau đó giải mã trong trình duyệt rồi hiển thị.
Phương pháp này có thể nâng cao một chút rào cản, nhưng vấn đề là - khóa mã hóa cũng nằm ở frontend, đồng nghĩa với việc người khác vẫn có cách tìm ra nó.
Cách làm thực sự an toàn, không phải là “ẩn nội dung”, mà là để xác thực và giải mã đều diễn ra trên server, trình duyệt chỉ chịu trách nhiệm hiển thị.
SSR: Thêm “bộ não động” cho blog tĩnh
Khi blog chuyển sang server-side rendering (SSR), nội dung bài viết sẽ không được đóng gói sẵn ở frontend, mà được tạo ra bởi server tại thời điểm request.
Lợi ích của cách làm này rõ ràng là:
- Nội dung không còn bị lộ trong HTML
- Có thể xác thực điều kiện truy cập ở phía server
- Logic kiểm soát truy cập rõ ràng hơn, có thể mở rộng
Vì vậy ý tưởng của tôi là: dùng SSR + xác thực API để thực hiện mã hóa bài viết.
Tổng quan cách triển khai
Tôi chia toàn bộ quy trình thành một số phần để triển khai, sử dụng theme: astro-inlove
Các theme khác vẫn có thể áp dụng ý tưởng trong bài viết này để triển khai
1. Chuyển từ static output sang SSR
Đổi output: 'static' trong astro.config.ts thành output: 'server'.
Trong file cấu hình của theme astro-inlove tôi đang sử dụng đã thiết lập sẵn cấu hình tương ứng, các cấu hình astro khác đại khái giống nhau
export default defineConfig({ // Adapter // https://docs.astro.build/en/guides/deploy/ // 1. Vercel (serverless) // adapter: vercel(), // output: 'server', // 2. Vercel (static) // adapter: vercel(), // output: 'static', // 3. Local (standalone) adapter: node({ mode: 'standalone' }), output: 'server',})Chúng đại diện cho 3 cách triển khai blog:
- Vercel (serverless) Chế độ SSR (server-side rendering). Mỗi lần truy cập mới thực hiện render, không truy cập thì không chiếm tài nguyên. Phù hợp triển khai trên nền tảng hosting.
- Vercel (static) Chế độ SSG (static rendering). Tất cả trang được tạo ở giai đoạn build, tốc độ truy cập nhanh nhưng không thể kiểm soát nội dung động.
- Local (standalone) Chế độ SSR (server-side rendering). Sẽ tạo ra một dịch vụ Node có thể chạy trực tiếp, phù hợp hosting. Nhưng là tiến trình chạy lâu dài, không giống như serverless khởi động theo nhu cầu.
Điểm khác biệt chính giữa hai chế độ SSR là có hỗ trợ tác vụ chạy lâu dài hay không (hầu hết hàm serverless sẽ có giới hạn timeout khoảng 10 giây). Đối với chức năng nhẹ như mã hóa bài viết, cả hai đều hoàn toàn đủ dùng.
Nếu chế độ standalone báo lỗi thiếu module
node, cài đặt@astrojs/nodelà được.
2. Thêm trường mã hóa vào Frontmatter
Thêm trường password vào frontmatter của mỗi bài viết, dùng để lưu trữ “câu hỏi và câu trả lời”:
---title: "Ví dụ bài viết mã hóa"publishDate: "2025-11-18"password: - { question: 'Mật khẩu truy cập là (123456)', answer: '123456' } - { question: 'Bao giờ cưới vợ (chịu)', answer: 'chịu' }---Theme astro-inlove tôi đang sử dụng đã thiết lập sẵn các trường frontmatter cho bài viết blog, trong src/content.config.ts, các theme astro khác đại khái giống nhau, cần tự xử lý
// Define blog collectionconst blog = defineCollection({ // Load Markdown and MDX files in the `src/content/blog/` directory. loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }), // Required schema: ({ image }) => z.object({ // Required title: z.string().max(60), description: z.string().max(160), publishDate: z.coerce.date(), // Optional updatedDate: z.coerce.date().optional(), heroImage: z .object({ src: image(), alt: z.string().optional(), inferSize: z.boolean().optional(), width: z.number().optional(), height: z.number().optional(), color: z.string().optional() }) .optional(), tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase), language: z.string().optional(), draft: z.boolean().default(false), // Special fields comment: z.boolean().default(true), // Thêm password password: z.array( z.object({ question: z.string().min(1, 'Câu hỏi không được để trống'), answer: z.string().min(1, 'Câu trả lời không được để trống') }) ).default([]) })})3. Điều chỉnh cách render bài viết
Trong blog Astro ban đầu, trang bài viết được render trong src/pages/blog/[...id].astro như sau:
import { render, type CollectionEntry } from 'astro:content'
import { getBlogCollection, sortMDByDate } from 'astro-inlove/server'import PostLayout from '@/layouts/BlogPost.astro'
export const prerender = true
export async function getStaticPaths() { const posts = sortMDByDate(await getBlogCollection()) return posts.map((post) => ({ params: { id: post.id }, props: { post, posts } }))}
type Props = { post: CollectionEntry<'blog'> posts: CollectionEntry<'blog'>[]}
const { post, posts } = Astro.propsconst { Content, headings, remarkPluginFrontmatter } = await render(post)Cách này thuộc static generation (SSG):
Ở giai đoạn build (astro build), Astro sẽ:
- Thực thi
getStaticPaths(). - Lấy tất cả bài viết (posts) và tạo “danh sách đường dẫn”.
- Với mỗi đường dẫn trong danh sách đều tạo sẵn file HTML (ví dụ
/blog/xxx/index.html). - Ghi dữ liệu
postvào file tĩnh này.
Mà chúng ta không muốn nội dung bài viết được ghi vào html, mà render nội dung bài viết động, đó chính là lý do chúng ta chuyển sang chế độ SSR, trong chế độ SSR:
- Bạn không thể dùng
getStaticPathsđể “tạo trước” trang. Astro.propscũng sẽ không có dữ liệu được inject trước vào trước nhưpost,posts(vì khi SSR không có các props này được build sẵn).
Vì vậy chúng ta cần điều chỉnh src/pages/blog/[...id].astro thành:
import { render, type CollectionEntry } from 'astro:content'
import { getBlogCollection, sortMDByDate } from 'astro-inlove/server'import PostLayout from '@/layouts/BlogPost.astro'
export const prerender = false
type Props = { post: CollectionEntry<'blog'> posts: CollectionEntry<'blog'>[]}
const { id } = Astro.paramsconst allPosts = await getBlogCollection()const posts = sortMDByDate( allPosts.filter((p) => p.collection === 'blog')) as CollectionEntry<'blog'>[]const post = posts.find((p) => p.id === id)if (!post) { throw new Error(`Blog post not found: ${id}`)}
const { Content, headings, remarkPluginFrontmatter } = await render(post)4. Tương tác frontend: Component Vue
Tạo một component Vue nhỏ:
- Chỉ hiển thị câu hỏi, không biết câu trả lời
- Người dùng nhập câu trả lời và gửi lên API
- Dựa vào phản hồi từ API để làm mới trang hoặc thông báo lỗi
Component này có thể sử dụng ở bất kỳ trang nào, vì vậy không mô tả quá nhiều, tạo một file vue trong bất kỳ thư mục nào của dự án, sau đó import trong file .astro cần sử dụng
<template> <div class="relative mb-8 pl-4 border-l-2 border-foreground/10 text-left text-sm sm:text-base text-muted-foreground leading-relaxed"> Bài viết hiện tại là bài viết mã hóa<br /> Vui lòng trả lời câu hỏi để có quyền xem bài viết </div> <div class="grid gap-3.5 sm:grid-cols-1 sm:gap-4 lg:grid-cols-2 [&>*:only-child]:lg:col-span-2"> <div class="not-prose block relative rounded-2xl border px-5 py-3 transition-all hover:border-foreground/25 hover:shadow-sm cursor-pointer" v-for="(item, index) in questions" :key="index"> <div class="flex flex-col gap-y-1.5"> <div class="flex flex-col gap-y-0.5"> <h2 class="text-lg font-medium">Câu hỏi {{ index + 1 }}</h2> <p class="text-muted-foreground">{{ item }}</p> </div> <div> <input v-model="answers[index]" type="text" class="flex-1 w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-1" /> </div> </div> </div> </div> <div class="relative w-full text-end mt-3.5"> <button class="rounded-lg bg-muted px-8 py-2 text-muted-foreground text-sm hover:bg-primary-foreground transition" :disabled="loading" @click="verifyAnswer"> {{ loading ? 'Đang xác thực...' : 'Xác thực' }} </button> </div></template>
<script setup lang="ts">import { reactive, ref } from 'vue';import { showToast } from '@/plugins/toast'
const loading = ref(false);
const props = defineProps<{ slug: string; questions: string[];}>();
const answers = reactive<string[]>(Array(props.questions.length).fill(''));
async function verifyAnswer() { loading.value = true const path = window.location.pathname const formData = new FormData() answers.forEach((a, i) => formData.append(`answer_${i}`, a)) formData.append(`path`, path) const res = await fetch(`/api/verify?slug=${props.slug}`, { method: 'POST', body: formData, credentials: 'same-origin', }); const data = await res.json() if (res.ok && data.success) { window.location.reload() } else { showToast({ message: data.error || 'Xác thực thất bại' }) } loading.value = false}</script>5. Backend API: Xác thực câu trả lời
Dùng file TypeScript để triển khai API xác thực:
- Lấy frontmatter của bài viết thông qua
astro:content. - So sánh câu trả lời người dùng gửi với câu trả lời đúng.
- Nếu xác thực thành công, thiết lập cookie mã hóa, và làm mới trang để hiển thị nội dung bài viết.
import crypto from 'crypto'import type { APIRoute } from 'astro'import { getCollection } from 'astro:content'
const SECRET = process.env.COOKIE_SECRET || 'q1W2e3R4t5Y6u7I8o9P0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789-_'
// Dùng HMAC signaturefunction sign(value: string) { return crypto.createHmac('sha256', SECRET).update(value).digest('base64url')}
// Tạo giá trị cookie đã kýfunction makeSignedCookieValue(slug: string, maxAgeSec = 86400) { const expires = Math.floor(Date.now() / 1000) + maxAgeSec const payload = `${slug}:${expires}` const sig = sign(payload) return `${payload}:${sig}`}
// Xác thực giá trị cookie đã kýexport function verifySignedCookieValue(cookieValue: string | null | undefined) { if (!cookieValue) return false const parts = cookieValue.split(':') if (parts.length !== 3) return false const [slug, expiresStr, sig] = parts const payload = `${slug}:${expiresStr}` const expectedSig = sign(payload) if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false if (parseInt(expiresStr, 10) < Math.floor(Date.now() / 1000)) return false return slug}
export const POST: APIRoute = async ({ request, url }) => { const slug = new URL(url).searchParams.get('slug') if (!slug) return new Response(JSON.stringify({ error: 'Yêu cầu không hợp lệ' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) const posts = await getCollection<'blog'>('blog') const post = posts.find((p) => p.id === slug) if (!post) return new Response(JSON.stringify({ error: 'Bài viết không tồn tại' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) const formData = await request.formData() const password = post.data.password || [] const correct = password.every((p, i) => { const answer = formData.get(`answer_${i}`)?.toString().trim().toLowerCase() return answer === p.answer.trim().toLowerCase() }) if (!correct) { return new Response(JSON.stringify({ error: 'Câu trả lời sai' }), { status: 401, headers: { 'Content-Type': 'application/json' } }) } const path = formData.get(`path`)?.toString().trim() const cookieVal = makeSignedCookieValue(slug, 24 * 3600) const headers = new Headers() headers.append( 'Set-Cookie', `verified-${slug}=${cookieVal}; Path=${path}; HttpOnly; Secure; SameSite=Lax; Max-Age=86400` ) return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json', 'Set-Cookie': `verified-${slug}=${cookieVal}; Path=${path}; HttpOnly; Secure; SameSite=Lax; Max-Age=86400` } })}6. Cải tiến template bài viết
Thêm logic vào template bài viết:
- Kiểm tra xem bài viết có được mã hóa không.
- Kiểm tra xem cookie có tồn tại và hợp lệ không.
- Nếu chưa xác thực, hiển thị component mã hóa Vue.
- Xác thực thành công, hiển thị nội dung bài viết.
Điều chỉnh trong src/layouts/BlogPost.astro:
---import type { MarkdownHeading } from 'astro'import type { CollectionEntry } from 'astro:content'
// Plugin stylesimport 'katex/dist/katex.min.css'
import { MediumZoom } from 'astro-inlove/advanced'import { ArticleBottom, Hero } from 'astro-inlove/components/pages'import PageLayout from '@/layouts/ContentLayout.astro'import { verifySignedCookieValue } from '@/pages/api/verify'import Copyright from '@/components/custom/Copyright.astro'import TOC from '@/components/custom/TOC.astro'import PasswordForm from '@/components/vue/PasswordForm.vue'import { Comment, PageInfo } from '@/components/waline'import { integ } from '@/site-config'
interface Props { post: CollectionEntry<'blog'> posts: CollectionEntry<'blog'>[] headings: MarkdownHeading[] remarkPluginFrontmatter: Record<string, unknown>}
const { post: { id, data }, posts, headings, remarkPluginFrontmatter} = Astro.props
const { description, heroImage, publishDate, title, updatedDate, draft: isDraft, comment: enableComment} = data
const socialImage = heroImage ? typeof heroImage.src === 'string' ? heroImage.src : heroImage.src.src : '/images/social-card.png'const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString()const primaryColor = data.heroImage?.color ?? 'hsl(var(--primary) / var(--un-text-opacity))'
// Đọc cookie và xác thựcconst cookieHeader = Astro.request.headers.get('cookie')let verified = falseif (cookieHeader) { const cookies = Object.fromEntries( cookieHeader.split(';').map((s) => { const [k, v] = s.trim().split('=') return [k, v] }) ) const result = verifySignedCookieValue(cookies[`verified-${id}`]) if (result && result === id) verified = true}
const password = data.password || []const questions = password.map((p) => p.question)if (questions.length == 0) { verified = true}---
<PageLayout meta={{ articleDate, description, ogImage: socialImage, title }} highlightColor={primaryColor} back='/blog'> {verified && !!headings.length && <TOC {headings} slot='sidebar' />}
<Hero {data} {remarkPluginFrontmatter} slot='header'> <Fragment slot='description'> {!isDraft && enableComment && <PageInfo comment class='mt-1' />} </Fragment> </Hero>
{verified ? <slot /> : <PasswordForm slug={id} questions={questions} client:load />}
<Fragment slot='bottom'> {/* Copyright */} <Copyright {data} /> {/* Article recommend */} <ArticleBottom collections={posts} {id} class='mt-3 sm:mt-6' /> {/* Comment */} {!isDraft && enableComment && <Comment class='mt-3 sm:mt-6' />} </Fragment>
<slot name='bottom-sidebar' slot='bottom-sidebar' /></PageLayout>
{integ.mediumZoom.enable && <MediumZoom />}Tổng kết
Thông qua SSR + xác thực API, chúng ta có thể triển khai mã hóa bài viết tương đối an toàn trong Astro Blog:
- Vấn đề cốt lõi của mã hóa blog tĩnh là nội dung đã bị lộ.
- SSR có thể để nội dung bài viết chỉ tồn tại ở phía server, frontend không thể lấy trực tiếp.
- Frontend chỉ chịu trách nhiệm tương tác người dùng, logic xác thực và hiển thị nội dung đều do server xử lý.
Đối với blog cần mã hóa bài viết, cách này trong sự cân bằng giữa tính bảo mật và độ phức tạp triển khai, có thể coi là một điểm cân bằng lý tưởng. Nếu bạn cũng muốn thử cơ chế mã hóa tương tự trong blog của mình, có thể tham khảo ý tưởng trong bài viết này để cải tiến.
Ủng Hộ & Chia Sẻ
Nếu bài viết này giúp ích cho bạn, hãy chia sẻ hoặc ủng hộ nhé!