Contents

PocketBase Docker 部署与 JS 扩展实现博客点赞计数器

使用 Docker 部署 PocketBase 作为博客后端,通过其内置的 JSVM 扩展能力实现点赞计数器的服务端校验,解决客户端 localStorage 防重复点赞的局限性。本文涵盖 Docker 配置、数据库设计、JS 钩子编写及 Hugo 前端集成的完整流程。

目录


1. 背景与方案选型

1.1 问题分析

博客原有的点赞功能存在以下问题:

  • 防重复点赞完全依赖浏览器 localStorage,清除缓存即可重复点赞
  • 点赞数存储在单条记录的 count 字段,任何人可通过 API 直接 PATCH 篡改
  • 无服务端校验机制

1.2 方案对比

方案优点缺点
Node.js + JSON 文件简单轻量需额外维护进程,无数据库能力
Supabase / Firebase功能丰富对博客场景过重,需注册外部服务
PocketBase单二进制部署,内置 JS 扩展,SQLite 存储社区相对较小

最终选择 PocketBase:单容器部署,内置 JSVM 支持钩子扩展,SQLite 文件持久化,对个人博客场景刚好合适。

1.3 最终架构

┌─────────────┐      ┌──────────────────────┐
  Hugo 博客   │─────▶│  PocketBase (Docker)  
  前端 JS      API    - BlogLikes 集合     
└─────────────┘        - JS 钩子校验        
                       - SQLite 持久化       
                     └──────────────────────┘

2. Docker 部署 PocketBase

2.1 目录结构

pocketbase/
├── docker-compose.yml    # Docker 编排配置
├── .env                  # 环境变量(不提交到 Git)
├── pb_data/              # 数据库、日志、文件存储(自动生成)
├── pb_hooks/             # JS 扩展脚本
│   └── BlogLikes.pb.js   # 点赞钩子
└── pb_migrations/        # 数据库迁移文件(可选)

2.2 docker-compose.yml

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:${VERSION}
    container_name: ${CONTAINER_NAME}
    deploy:
      resources:
        limits:
          cpus: ${CPUS}
          memory: ${MEMORY_LIMIT}
    restart: always
    ports:
      - ${HOST_IP}:${WEB_HTTP_PORT}:8090
    environment:
      - PB_ADMIN_EMAIL=${PB_ADMIN_EMAIL}
      - PB_ADMIN_PASSWORD=${PB_ADMIN_PASSWORD}
      - TZ=Asia/Shanghai
    volumes:
      - ${APP_PATH}/pb_data:/pb_data
      - ${APP_PATH}/pb_hooks:/pb_hooks
      - ${APP_PATH}/pb_migrations:/pb_migrations
    networks:
      - your_network

networks:
  your_network:
    external: true

2.3 环境变量 (.env)

VERSION=latest
CONTAINER_NAME=pocketbase
HOST_IP=0.0.0.0
WEB_HTTP_PORT=8090
CPUS=0
MEMORY_LIMIT=0MB
APP_PATH=/your/server/path/pocketbase
PB_ADMIN_EMAIL=admin@your-domain.com
PB_ADMIN_PASSWORD=your-strong-password

2.4 启动服务

# 创建目录
mkdir -p /your/server/path/pocketbase/{pb_data,pb_hooks,pb_migrations}

# 启动
docker compose up -d

# 查看日志
docker logs -f pocketbase

启动后访问 http://your-server:8090/_/ 进入管理后台。

2.5 PocketBase JSVM 说明

PocketBase 内置的 JS 引擎是 goja(Go 实现的 ECMAScript 5.1+),不是 Node.js

特性Node.jsPocketBase JSVM
ES 版本ES2022+ES5.1 + 部分 ES6+
import/export
Node.js API
npm 包❌(需 bundler 打包)
fetch✅(内置)

对于博客点赞这种简单场景,纯 JS 即可满足,无需引入 npm 包。


3. 数据库表设计

3.1 BlogLikes 集合

在 PocketBase 管理后台创建 BlogLikes 集合,字段如下:

字段类型必填说明
urlURL文章完整链接
countNumber点赞数(整数,最小 0)
createdAutodate创建时间
updatedAutodate更新时间

3.2 访问规则

规则设置说明
List rule""公开可读(查询点赞数)
View rule""公开可读
Create rule""公开可写(由钩子校验)
Update rule""公开可写(由钩子校验)
Delete rulenull禁止删除

3.3 设计思路

采用一个 URL 一条记录的模型:

  • 每篇文章对应一条 BlogLikes 记录
  • count 字段存储累计点赞数
  • 通过 JS 钩子保证 count 只能 +1,防止篡改
  • 客户端 localStorage 记录用户是否已点赞(体验优化)

4. JS 扩展钩子

钩子文件放在 pb_hooks/ 目录下,文件名以 .pb.js 结尾。

4.1 创建前校验

// pb_hooks/BlogLikes.pb.js

onRecordBeforeCreateRequest((e) => {
  const url = e.record.get("url");
  if (!url) {
    throw new BadRequestError("url is required");
  }

  const count = e.record.getFloat("count");
  if (count !== 0 && count !== 1) {
    // 自动修正为 1(首次创建即点赞)
    e.record.set("count", 1);
  }
}, "BlogLikes");

作用:确保 url 必填,count 初始值只能是 0 或 1。

4.2 更新前校验

onRecordBeforeUpdateRequest((e) => {
  const dao = $app.dao();

  // 获取数据库中当前记录
  const original = dao.findRecordById("BlogLikes", e.record.id);
  const oldCount = original.getFloat("count");
  const newCount = e.record.getFloat("count");

  // 校验:count 只能增加 1
  if (newCount !== oldCount + 1) {
    throw new BadRequestError(
      "count can only be incremented by 1 (expected " +
        (oldCount + 1) +
        ", got " +
        newCount +
        ")",
    );
  }

  // 校验:不允许修改其他字段
  if (e.record.get("url") !== original.get("url")) {
    throw new BadRequestError("url cannot be modified");
  }
}, "BlogLikes");

作用

  • count 只能 +1,不能跳跃增加(防止直接 PATCH 为任意值)
  • 不允许修改 url 字段
  • 即使绕过前端直接调用 API,也无法篡改数据

4.3 钩子生效机制

  • 修改 pb_hooks/ 中的文件后,PocketBase 自动重新加载,无需重启容器
  • 日志可通过 docker logs -f pocketbase 查看

5. Hugo 前端集成

5.1 配置 (hugo.toml)

[params.page.like]
  enable = true
  apiUrl = "https://your-pocketbase-domain.com"

5.2 点赞按钮模板

核心逻辑:

  1. 页面加载:查询 BlogLikes 记录获取 count
  2. 读取 localStorage:判断当前用户是否已点赞
  3. 点击点赞
    • 已点赞 → 不处理
    • 未点赞且无记录 → POST 创建记录(count=1)
    • 未点赞且有记录 → PATCH 更新 count+1
  4. 乐观更新:先更新 UI,失败则回滚
{{- $like := .Site.Params.page.like | default dict -}} {{- if $like.enable -}}
<button class="like-btn" id="like-btn" onclick="toggleLike()" aria-label="点赞">
  <i class="far fa-thumbs-up like-icon"></i>
  <span class="like-count" id="like-count">0</span>
</button>
<script>
  (function () {
    var apiUrl = "{{ $like.apiUrl }}";
    var path = "{{ .Permalink }}";
    var storageKey = "liked:" + path;
    var btn = document.getElementById("like-btn");
    var countEl = document.getElementById("like-count");
    var icon = btn ? btn.querySelector(".like-icon") : null;
    if (!btn || !countEl || !icon) return;

    var liked = localStorage.getItem(storageKey) === "1";
    var recordId = null;
    var currentCount = 0;

    function render(count) {
      currentCount = count;
      countEl.textContent = count;
      if (liked) {
        icon.className = "fas fa-thumbs-up like-icon";
        btn.classList.add("liked");
      } else {
        icon.className = "far fa-thumbs-up like-icon";
        btn.classList.remove("liked");
      }
    }

    // 查询 BlogLikes 记录
    function fetchLike() {
      var filter = '(url="' + path + '")';
      fetch(
        apiUrl +
          "/api/collections/BlogLikes/records?filter=" +
          encodeURIComponent(filter),
      )
        .then(function (r) {
          return r.json();
        })
        .then(function (data) {
          if (data.items && data.items.length > 0) {
            recordId = data.items[0].id;
            render(data.items[0].count || 0);
          } else {
            render(0);
          }
        })
        .catch(function () {
          render(0);
        });
    }

    fetchLike();

    // 点赞(只增不减)
    window.toggleLike = function () {
      if (liked) return;
      liked = true;
      localStorage.setItem(storageKey, "1");
      render(currentCount + 1); // 乐观更新

      if (recordId) {
        // 已有记录 → count +1
        fetch(apiUrl + "/api/collections/BlogLikes/records/" + recordId, {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ count: currentCount }),
        })
          .then(function (r) {
            return r.json();
          })
          .then(function (data) {
            render(data.count || currentCount);
          })
          .catch(function () {
            liked = false;
            localStorage.removeItem(storageKey);
            render(currentCount);
          });
      } else {
        // 首次点赞 → 创建记录
        fetch(apiUrl + "/api/collections/BlogLikes/records", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ url: path, count: 1 }),
        })
          .then(function (r) {
            return r.json();
          })
          .then(function (data) {
            recordId = data.id;
            render(data.count || 1);
          })
          .catch(function () {
            liked = false;
            localStorage.removeItem(storageKey);
            render(0);
          });
      }
    };
  })();
</script>
{{- end -}}

6. 部署与验证

6.1 部署清单

  • PocketBase 容器正常运行
  • pb_hooks/BlogLikes.pb.js 已上传到服务器
  • BlogLikes 集合已创建,访问规则已配置
  • Hugo 配置中 apiUrl 指向 PocketBase 地址
  • CORS 设置允许博客域名跨域访问

6.2 验证步骤

# 1. 健康检查
curl https://your-pocketbase-domain.com/api/health

# 2. 查询点赞记录
curl "https://your-pocketbase-domain.com/api/collections/BlogLikes/records?filter=(url='https://your-blog.com/posts/test/')"

# 3. 创建点赞记录
curl -X POST https://your-pocketbase-domain.com/api/collections/BlogLikes/records \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-blog.com/posts/test/","count":1}'

# 4. 验证钩子:尝试篡改 count(应返回 400)
curl -X PATCH https://your-pocketbase-domain.com/api/collections/BlogLikes/records/RECORD_ID \
  -H "Content-Type: application/json" \
  -d '{"count":999}'

7. 常见问题

7.1 CORS 跨域错误

PocketBase 管理后台 → Settings → API Rules → 允许你的博客域名。

7.2 URL 域名验证失败

如果博客 baseURLwww(如 https://www.your-blog.com),需要在 BlogLikes.url 字段的 onlyDomains 中同时添加:

  • your-blog.com
  • www.your-blog.com

7.3 钩子不生效

  • 确认文件名以 .pb.js 结尾
  • 查看容器日志:docker logs -f pocketbase
  • 修改钩子文件后 PocketBase 自动重载,无需重启

7.4 数据持久化

所有数据存储在 pb_data/ 目录中的 SQLite 文件。备份该目录即可完整备份数据。


总结

通过 PocketBase 的 JS 扩展能力,我们用极低的运维成本实现了:

  • 服务端防篡改:钩子校验 count 只能 +1
  • 数据持久化:SQLite 文件存储,Docker volume 挂载
  • 零额外依赖:单容器部署,无需 Node.js 或其他运行时

对于个人博客场景,PocketBase 是一个轻量且够用的 BaaS 选择。

0 次查看