功能概述

为 Quartz 博客新增了文章密码保护功能,支持对单篇文章设置访问密码。访客需要输入正确密码才能查看受保护的文章内容。

使用方法

在文章的 frontmatter 中添加 password 字段即可:

---
title: 你的文章标题
password: "your-password-here"
date: 2025-11-25
tags:
  - 标签
---

功能特性

🎨 用户界面

  • 美观的密码输入界面,带 🔒 锁图标
  • 输入错误时有抖动动画反馈
  • 自动适配亮暗主题(支持 Dark Mode)
  • 响应式设计,移动端友好

💾 会话记忆

  • 使用 localStorage 记住已解锁的文章
  • 同一浏览器再次访问无需重新输入密码
  • 切换浏览器或设备需要重新输入

🔐 安全性说明

重要提醒: 这是一个前端密码保护方案,安全等级为”礼貌性拦截”。

适用场景:

  • ✅ 个人博客的半公开文章
  • ✅ 不想被搜索引擎收录的内容
  • ✅ 朋友圈内分享的笔记
  • ✅ 个人想法的内容分级访问

不适用场景:

  • ❌ 商业机密文档
  • ❌ 真正的敏感信息(财务、密码等)
  • ❌ 需要防止技术人员获取的内容

技术限制:

  • 密码使用 Base64 编码存储在前端
  • 懂技术的人可以通过浏览器开发者工具破解
  • frontmatter 中的密码是明文,不要使用重要密码

技术实现

架构设计

quartz/components/
├── PasswordProtection.tsx        # 主组件
├── scripts/
│   └── password.inline.ts        # 客户端验证脚本
└── styles/
    └── password.scss             # 样式文件

核心组件 - PasswordProtection.tsx

文件路径: quartz/components/PasswordProtection.tsx

import passwordScript from "./scripts/password.inline"
import styles from "./styles/password.scss"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
 
interface PasswordProtectionOptions {
  hint?: string
  buttonText?: string
  errorText?: string
}
 
const defaultOptions: PasswordProtectionOptions = {
  hint: "此文章已加密,请输入密码查看",
  buttonText: "确认",
  errorText: "密码错误,请重试",
}
 
export default ((userOpts?: Partial<PasswordProtectionOptions>) => {
  const opts = { ...defaultOptions, ...userOpts }
 
  const PasswordProtection: QuartzComponent = ({
    displayClass,
    fileData,
  }: QuartzComponentProps) => {
    const password = fileData.frontmatter?.password as string | undefined
 
    // 如果没有设置密码,不渲染组件
    if (!password) {
      return null
    }
 
    // 使用 Base64 编码密码
    const hashedPassword = btoa(password)
 
    return (
      <div
        class={classNames(displayClass, "password-protection")}
        data-password-hash={hashedPassword}
        data-slug={fileData.slug}
      >
        <div class="password-overlay">
          <div class="password-container">
            <div class="lock-icon">
              <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
                <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
              </svg>
            </div>
            <p class="password-hint">{opts.hint}</p>
            <div class="password-input-group">
              <input type="password" class="password-input" placeholder="请输入密码" autocomplete="off" />
              <button type="button" class="password-submit">{opts.buttonText}</button>
            </div>
            <p class="password-error" style="display: none;">{opts.errorText}</p>
          </div>
        </div>
      </div>
    )
  }
 
  // 注册客户端脚本和样式
  PasswordProtection.beforeDOMLoaded = passwordScript
  PasswordProtection.css = styles
 
  return PasswordProtection
}) satisfies QuartzComponentConstructor<Partial<PasswordProtectionOptions>>

关键设计点:

  1. 条件渲染: 只有文章设置了 password 字段才渲染密码输入界面
  2. Base64 编码: 将密码编码后存储在 data-password-hash 属性中
  3. 数据属性: 使用 data-slug 记录文章标识,用于 localStorage 管理
  4. 生命周期钩子: 使用 beforeDOMLoaded 确保脚本在 DOM 加载前执行,防止内容闪现

客户端验证脚本 - password.inline.ts

文件路径: quartz/components/scripts/password.inline.ts

// localStorage 管理函数
function getUnlockedPages(): Set<string> {
  try {
    const stored = localStorage.getItem("unlocked-pages")
    return stored ? new Set(JSON.parse(stored)) : new Set()
  } catch {
    return new Set()
  }
}
 
function saveUnlockedPages(pages: Set<string>) {
  localStorage.setItem("unlocked-pages", JSON.stringify([...pages]))
}
 
// 检查并应用密码保护
function checkAndApplyProtection() {
  const protectionElement = document.querySelector(".password-protection") as HTMLElement | null
  if (!protectionElement) return
 
  const passwordHash = protectionElement.dataset.passwordHash
  const slug = protectionElement.dataset.slug
 
  if (!passwordHash || !slug) return
 
  // 检查是否已解锁
  const unlockedPages = getUnlockedPages()
  if (unlockedPages.has(slug)) {
    // 已解锁,显示内容
    protectionElement.style.display = "none"
    const article = document.querySelector("article")
    if (article) {
      article.classList.remove("password-hidden")
    }
    return
  }
 
  // 未解锁,显示密码输入界面
  protectionElement.style.display = "block"
  const article = document.querySelector("article")
  if (article) {
    article.classList.add("password-hidden")
  }
}
 
// 页面加载时立即执行
checkAndApplyProtection()
 
// SPA 路由导航时重新检查
document.addEventListener("nav", () => {
  checkAndApplyProtection()
 
  const protectionElement = document.querySelector(".password-protection") as HTMLElement | null
  if (!protectionElement) return
 
  const passwordHash = protectionElement.dataset.passwordHash
  const slug = protectionElement.dataset.slug
  const input = protectionElement.querySelector(".password-input") as HTMLInputElement | null
  const submitBtn = protectionElement.querySelector(".password-submit") as HTMLButtonElement | null
  const errorMsg = protectionElement.querySelector(".password-error") as HTMLElement | null
 
  if (!passwordHash || !slug || !input || !submitBtn) return
 
  // 密码验证逻辑
  const handleSubmit = () => {
    const enteredPassword = input.value
    const enteredHash = btoa(enteredPassword)
 
    if (enteredHash === passwordHash) {
      // 密码正确
      const unlockedPages = getUnlockedPages()
      unlockedPages.add(slug)
      saveUnlockedPages(unlockedPages)
 
      // 显示内容
      protectionElement.style.display = "none"
      const article = document.querySelector("article")
      if (article) {
        article.classList.remove("password-hidden")
      }
 
      // 清理输入
      input.value = ""
      if (errorMsg) {
        errorMsg.style.display = "none"
      }
    } else {
      // 密码错误
      if (errorMsg) {
        errorMsg.style.display = "block"
      }
      input.classList.add("shake")
      setTimeout(() => input.classList.remove("shake"), 500)
    }
  }
 
  // 绑定事件
  submitBtn.addEventListener("click", handleSubmit)
  window.addCleanup(() => submitBtn.removeEventListener("click", handleSubmit))
 
  const handleKeydown = (e: KeyboardEvent) => {
    if (e.key === "Enter") {
      handleSubmit()
    }
  }
  input.addEventListener("keydown", handleKeydown)
  window.addCleanup(() => input.removeEventListener("keydown", handleKeydown))
})

关键逻辑:

  1. 初始检查: 页面加载时立即检查是否已解锁
  2. Base64 对比: 用户输入的密码也进行 Base64 编码后与存储的 hash 对比
  3. localStorage 持久化: 解锁成功后保存到 localStorage
  4. SPA 兼容: 监听 nav 事件,确保 SPA 路由切换时正常工作
  5. 事件清理: 使用 window.addCleanup 防止内存泄漏

样式设计 - password.scss

文件路径: quartz/components/styles/password.scss

.password-protection {
  display: none;
  position: relative;
}
 
.password-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--light);
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.password-container {
  background: var(--lightgray);
  border-radius: 12px;
  padding: 2.5rem;
  max-width: 400px;
  width: 90%;
  text-align: center;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
 
.password-input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid var(--gray);
  border-radius: 6px;
  font-size: 1rem;
  background: var(--light);
  color: var(--dark);
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
 
  &:focus {
    border-color: var(--secondary);
    box-shadow: 0 0 0 3px rgba(var(--secondary-rgb), 0.1);
  }
 
  &.shake {
    animation: shake 0.5s ease-in-out;
  }
}
 
// 隐藏受保护的内容
article.password-hidden {
  visibility: hidden;
  height: 0;
  overflow: hidden;
  padding: 0 !important;
  margin: 0 !important;
}
 
// 错误抖动动画
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
  20%, 40%, 60%, 80% { transform: translateX(5px); }
}
 
// 暗黑模式适配
:root[saved-theme="dark"] {
  .password-overlay {
    background: var(--dark);
  }
 
  .password-container {
    background: var(--darkgray);
  }
 
  .password-input {
    background: var(--dark);
    border-color: var(--gray);
  }
}

设计特点:

  1. 固定遮罩层: 使用 position: fixed 覆盖整个页面
  2. 居中布局: Flexbox 实现完美居中
  3. 响应式: max-width: 400pxwidth: 90% 适配各种屏幕
  4. 动画反馈: 错误时的抖动动画提升用户体验
  5. 主题适配: 使用 CSS 变量,自动适配亮暗主题

组件注册

1. 导出组件 (quartz/components/index.ts:28,58)

import PasswordProtection from "./PasswordProtection"
 
export {
  // ... 其他组件
  PasswordProtection,
}

2. 注册到布局 (quartz.layout.ts:39)

export const defaultContentPageLayout: PageLayout = {
  beforeBody: [
    Component.PasswordProtection(),  // 必须放在第一位
    Component.CoverImage(),
    Component.Breadcrumbs(),
    // ... 其他组件
  ],
  // ...
}

注意: PasswordProtection 必须放在 beforeBody 数组的第一位,确保在其他内容渲染前执行。

自定义配置

可以在 quartz.layout.ts 中自定义提示文字:

Component.PasswordProtection({
  hint: "自定义提示文字",
  buttonText: "确认",
  errorText: "密码错误,请重试"
})

清除解锁记忆

如果需要重新测试密码功能:

  1. 打开浏览器开发者工具 (F12)
  2. 进入 Application/应用程序 标签
  3. 找到 Local Storage
  4. 删除 unlocked-pages

或在控制台执行:

localStorage.removeItem('unlocked-pages')
location.reload()

技术选型说明

为什么使用前端验证?

由于博客部署在 Cloudflare Pages(静态托管),无法使用后端服务器进行真正的密码验证。前端验证是静态网站的唯一可行方案。

为什么使用 Base64 编码?

  1. 简单高效: Base64 编码/解码速度快,不影响页面加载
  2. 浏览器原生支持: 使用 btoa()atob() 函数,无需引入第三方库
  3. 足够的”礼貌性”: 能阻止 99% 的普通访客,满足个人博客需求

为什么不使用更强的加密?

  1. 静态站点限制: 无法使用后端验证,前端加密本质上都可被破解
  2. 性能考虑: 复杂加密算法会影响页面加载速度
  3. 适用场景定位: 个人博客不需要商业级别的安全防护

升级可能性

如果未来需要更高的安全性,可以考虑:

方案 A: AES 内容加密

  • 文章内容在构建时用 AES 加密
  • 密码不存储在页面中
  • 用户输入密码后浏览器解密内容
  • 安全性: 中等(能防 95% 的技术人员)

方案 B: Cloudflare Workers

  • 利用边缘计算进行服务端验证
  • 密码不存储在前端
  • 安全性: 高(接近真正的后端验证)
  • 成本: 需要学习和配置 Workers

示例文件

测试文章: content/测试密码保护.md

---
title: 测试密码保护功能
password: "test123"
date: 2025-11-25
tags:
  - 测试
---
 
这是一篇受密码保护的文章。

访问 /测试密码保护,输入密码 test123 即可查看内容。

相关文件

新增文件

  • quartz/components/PasswordProtection.tsx - 主组件(84 行)
  • quartz/components/scripts/password.inline.ts - 客户端脚本(95 行)
  • quartz/components/styles/password.scss - 样式文件(134 行)

修改文件

  • quartz/components/index.ts - 添加组件导出(+2 行)
  • quartz.layout.ts - 注册组件到布局(+1 行)

文档文件

  • content/测试密码保护.md - 测试文章
  • 博客密码保护功能说明.md - 功能使用文档

提交信息

实现博客文章密码保护功能

- 新增 PasswordProtection 组件支持单篇文章密码保护
- 使用 Base64 编码进行前端密码验证
- localStorage 记忆已解锁文章
- 支持亮暗主题自动切换
- 密码错误时显示抖动动画反馈

总结

这个密码保护功能为 Quartz 博客提供了内容分级访问能力,适合个人博客、知识库等场景。虽然安全等级不高,但足以满足”礼貌性拦截”的需求,让不懂技术的访客知道”这是私密内容”。

对于真正需要保密的内容,建议不要发布到公开博客,或使用 Notion/飞书等有真正权限控制的平台。