<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="/atom.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://blog.skyone.dev/</id>
    <title>Skyone Blog</title>
    <updated>2026-06-15T11:20:20.347Z</updated>
    <generator>Next.js</generator>
    <author>
        <name>Skyone</name>
        <email>master@skyone.dev</email>
        <uri>https://blog.skyone.dev/about/</uri>
    </author>
    <link rel="alternate" href="https://blog.skyone.dev/"/>
    <link rel="self" href="https://blog.skyone.dev/atom.xml"/>
    <icon>https://blog.skyone.dev/favicon.ico</icon>
    <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    <entry>
        <title type="html"><![CDATA[使用 mTLS 保护自建服务]]></title>
        <id>https://blog.skyone.dev/2026/self-hosted-services-with-mtls/</id>
        <link href="https://blog.skyone.dev/2026/self-hosted-services-with-mtls/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/e5f7868d_8b9b90ae.webp" type="image/webp"/>
        <updated>2026-03-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[飞牛OS 路径穿越漏洞敲响警钟：仅靠强密码无法防御软件漏洞。本文详解如何用 mTLS（双向 TLS 认证）保护自建 NAS、Web 服务、API 等，包含反向代理配置（以 Traefik 为例）、OpenSSL 生成证书、浏览器/App 导入证书的指南。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2026/self-hosted-services-with-mtls/">阅读原文</a>以获取最佳阅读体验。</p><p>飞牛OS 路径穿越漏洞敲响警钟：仅靠强密码无法防御软件漏洞。</p>
<p>本文详解如何用 mTLS（双向 TLS 认证）保护自建 NAS、Web 服务、API 等，包含反向代理配置（以 Traefik 为例）、OpenSSL 生成证书、浏览器/App 导入证书的指南。</p>
<!-- readmore -->
<h2 id="引子-强密码-安全">引子：强密码==安全？</h2>
<p>相信各位都有所耳闻，最近，国产NAS系统飞牛OS爆出了一个高危漏洞：</p>
<blockquote>
<p>2026年初，多位用户和安全社区相继披露：飞牛OS的某些版本存在严重的<strong>路径穿越（Path Traversal）0day漏洞</strong>。攻击者无需任何身份验证，只需构造特定的URL请求，就能直接读取NAS设备上的<strong>任意文件</strong>——从系统配置文件、用户存储的照片视频、私钥证书，到家庭相册、财务表格、个人隐私文档……全部暴露在黑客眼前。简单来说，你的NAS瞬间变成了一个“无需密码的公开网盘”，黑客想看什么就能看什么，想下载什么就能下载什么。</p>
<p>这个漏洞影响范围极广，尤其是那些将飞牛NAS通过公网端口、官方内网穿透（fn connect）等方式暴露到互联网上的用户，几乎处于完全裸奔状态。社区反馈显示，大量设备在漏洞曝光前已被批量扫描和利用，甚至出现数据被窃取、设备被植入后门、沦为僵尸网络的情况。飞牛官方随后通过静默推送修复补丁（例如1.1.15及更高版本）试图阻断攻击链，但早期缺乏及时公告、漏洞细节披露迟缓，也引发了不少用户对厂商安全响应态度的质疑。</p>
</blockquote>
<p>这件事给我们敲响了警钟：<strong>仅仅依赖“强密码 + 登录认证”并不一定安全</strong>。</p>
<p>为了方便访问，自建服务（NAS、个人云盘、家庭服务器、内部管理后台等）大多跑在公网上~~（当然你自建VPN也可以）~~。软件本身再怎么加固账号密码、再怎么限制弱口令、再怎么开启两步验证，一旦程序逻辑出现哪怕一个低级但致命的漏洞（如路径穿越、命令注入、认证绕过），攻击者就能完全绕过表层的“密码防线”，直接拿到最高权限的数据访问能力。飞牛OS的这次事件，正是最典型的“认证再强也挡不住代码漏洞”的案例。</p>
<p>密码可以改，密钥可以重置，但如果攻击者已经提前把你的所有文件都拷贝走，一切防护都成了马后炮。</p>
<p>那么，面对这类“软件自身漏洞导致的越权访问”，我们还能做些什么？有没有一种机制，能在<strong>应用层认证之外</strong>再加一道更强硬、更底层的防护墙，让即便软件本身出现严重漏洞，黑客也很难真正拿到有效数据？</p>
<p>答案就是本文要重点探讨的：<strong>mTLS（Mutual TLS，双向证书认证）</strong>。</p>
<h2 id="mtls-简介">mTLS 简介</h2>
<p><strong>Mutual TLS（简称 mTLS，双向 TLS 认证）</strong>，也叫<strong>双向证书认证</strong>，是 TLS 协议的一种扩展认证模式。</p>
<blockquote>
<p>默认情况下，TLS 协议只通过 X.509 证书向客户端证明<strong>服务器</strong>的身份，而客户端到服务器的身份验证则交给应用层处理（比如用户名/密码、Token 等）。不过 TLS 本身也支持通过客户端侧的 X.509 证书来实现客户端身份认证。只不过因为需要提前为每个客户端分发证书、管理证书生命周期，而且对普通终端用户来说体验较差（不像输入密码那么简单），所以在面向消费者的互联网应用中几乎从不使用这种方式。</p>
<p>因此，**mTLS（Mutual TLS）<strong>更多出现在</strong>企业到企业（B2B）**场景中：客户端数量有限、类型相对统一（大多是程序、脚本、微服务、内部工具等）、运维负担可控，同时对安全性的要求远高于消费级环境。在这类场景下，mTLS 能让通信双方在 TLS 握手阶段就完成双向强身份验证——服务器验证客户端证书，客户端也验证服务器证书——从而建立起真正的“双方互信”通道。</p>
<p>——来自<a href="https://en.wikipedia.org/wiki/Mutual_authentication#mTLS">维基百科 - Mutual authentication</a></p>
</blockquote>
<p>简单一句话总结就是：</p>
<p><strong>mTLS = 标准 TLS（服务器证书认证） + 强制要求客户端也提供并验证有效的 X.509 证书</strong>。</p>
<p>其工作原理如图所示：</p>
<p><img src="https://blog.skyone.dev/_next/static/media/a446cb85_122d8518.webp" alt="mTLS 是如何工作的" width="2470" height="966" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/a446cb85_122d8518.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/a446cb85_122d8518.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/a446cb85_122d8518.png&#x22;}]"></p>
<p>这种机制把身份验证从“应用层”下沉到了“传输层加密协议”本身，极大降低了应用代码出现漏洞时被绕过的风险。正如飞牛OS事件所暴露的：即使应用层认证逻辑被路径穿越或命令注入击穿，只要连接本身没有通过 mTLS 建立，黑客就能直接发起请求；反之，如果连接必须持有有效客户端证书才能建立，攻击者即便知道了 URL 和漏洞，没有提供正确的客户端证书，连 TLS 握手都完不成，何谈漏洞利用？</p>
<p>本文不会过多纠结于 mTLS 的密码学细节或理论模型，而是聚焦于最实操的部分：</p>
<ul>
<li>如何便捷的为自建服务配置 mTLS</li>
<li>如何生成、管理、签发客户端证书（我写了个简单的一键式脚本）</li>
<li>以 Immich 和 BitWarden 为例看看套上 mTLS 后应用是否能正常运行</li>
</ul>
<h2 id="为自建服务配置-mtls">为自建服务配置 mTLS</h2>
<h3 id="生成自签名根证书">生成自签名根证书</h3>
<p>在 mTLS 环境中，我们需要一个<strong>根证书（Root CA）</strong> 来签发服务器证书和客户端证书。这个根证书是整个信任链的起点，自签名即可（因为我们自己控制信任，不需要公网 CA）。</p>
<p>这一步非常简单，并且只需要执行一次，使用 OpenSSL 生成自签名的根 CA 证书和私钥：</p>
<pre><code class="language-bash">openssl req -new -x509 -nodes -sha256 -days 3650 -newkey rsa:4096 \
  -keyout ca.key -out ca.crt \
  -subj <span class="pl-s"><span class="pl-pds">"</span>/C=US/O=OrganizationName/CN=CommonName<span class="pl-pds">"</span></span>
</code></pre>
<p>解释一下参数：</p>
<ul>
<li><code>-nodes</code>：<strong>no DES</strong> 的缩写，不对私钥进行加密（不设置密码）</li>
<li><code>-days 3650</code>：CA证书有效期设为 3650 天</li>
<li><code>-subj</code>：这里直接指定证书的主题，只是用来标识。可以选的字段有：
<ul>
<li><code>C=</code>：国家（Country）</li>
<li><code>ST=</code>：省/州（State/Province）</li>
<li><code>L=</code>：城市（Locality）</li>
<li><code>O=</code>：组织（Organization）</li>
<li><code>CN=</code>：通用名称（Common Name）这个必须有，并且Chrome选择证书时只会显示这一项</li>
</ul>
</li>
</ul>
<p>执行后，你会得到两个文件：</p>
<ul>
<li><code>ca.key</code>：根 CA 的私钥</li>
<li><code>ca.crt</code>：根 CA 的公钥证书</li>
</ul>
<p>这两个文件务必好好保存，以后签子证书要用到。</p>
<h3 id="配置反向代理">配置反向代理</h3>
<p>这里主要以 Traefik 为例，如果你使用 Nginx，参考 <a href="https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_client_certificate">Nginx - Module ngx_http_ssl_module</a>。如果你对Traefik不了解，可以看看我的另一篇文章：<a href="https://blog.skyone.dev/2023/traefik-docker-gateway/">使用 Traefik 作为 Docker 的反向代理</a></p>
<p>首先，需要在动态配置文件中定义 mTLS 策略，创建或编辑 <code>/etc/traefik/dynamic.yml</code>（或你使用的动态配置文件），添加如下键值对：</p>
<pre><code class="language-yaml"><span class="pl-ent">tls</span>:
  <span class="pl-ent">options</span>:
    <span class="pl-c"># 自定义一个名字，例如 mtls</span>
    <span class="pl-ent">mtls</span>:
      <span class="pl-ent">clientAuth</span>:
        <span class="pl-ent">caFiles</span>:
          <span class="pl-c"># 前面生成的根 CA 证书路径（容器内路径）</span>
          - <span class="pl-s">/etc/traefik/ca/ca.crt</span>
        <span class="pl-c"># 表示强制要求客户端提供证书且必须验证通过</span>
        <span class="pl-ent">clientAuthType</span>: <span class="pl-s">RequireAndVerifyClientCert</span>
</code></pre>
<p>然后在 <code>entrypoint</code> 或 <code>router</code> 上启用该 mTLS 策略即可，Traefik 的配置非常灵活，你可以选择在 <code>entrypoint</code> 上启用 mTLS，这样其下的全部 <code>router</code> 都会应用此规则。相反，如果你只希望部分域名启用 mTLS，只需在其对应的 <code>router</code> 下配置 mTLS。</p>
<ol>
<li>
<p>全局强制所有 HTTPS 流量都走 mTLS</p>
<pre><code class="language-yaml"><span class="pl-ent">entryPoints</span>:
  <span class="pl-ent">websecure</span>:
    <span class="pl-ent">address</span>: <span class="pl-s"><span class="pl-pds">"</span>:443<span class="pl-pds">"</span></span>
    <span class="pl-ent">http</span>:
      <span class="pl-ent">tls</span>:
        <span class="pl-c"># mtls 是签名 tls 配置的名称</span>
        <span class="pl-c"># file 是动态配置文件的名称</span>
        <span class="pl-ent">options</span>: <span class="pl-s">mtls@file</span>
</code></pre>
</li>
<li>
<p>只对特定服务启用 mTLS</p>
<pre><code class="language-yaml"><span class="pl-ent">http</span>:
  <span class="pl-ent">routers</span>:
    <span class="pl-ent">jellyfin-admin</span>:
      <span class="pl-ent">rule</span>: <span class="pl-s"><span class="pl-pds">"</span>Host(`admin.mydomain.com`)<span class="pl-pds">"</span></span>
      <span class="pl-ent">service</span>: <span class="pl-s">jellyfin</span>
      <span class="pl-ent">entryPoints</span>:
        - <span class="pl-s">websecure</span>
      <span class="pl-ent">tls</span>:
        <span class="pl-c"># 只在此 router 启用 mTLS</span>
        <span class="pl-ent">options</span>: <span class="pl-s">mtls@file</span>
    <span class="pl-ent">nextcloud</span>:
      <span class="pl-ent">rule</span>: <span class="pl-s"><span class="pl-pds">"</span>Host(`cloud.mydomain.com`)<span class="pl-pds">"</span></span>
      <span class="pl-ent">service</span>: <span class="pl-s">nextcloud</span>
      <span class="pl-ent">entryPoints</span>:
        - <span class="pl-s">websecure</span>
      <span class="pl-ent">tls</span>:
        <span class="pl-c"># 同样启用</span>
        <span class="pl-ent">options</span>: <span class="pl-s">mtls@file</span>
</code></pre>
</li>
</ol>
<p>这样服务端的配置就完成啦~</p>
<h3 id="生成客户端子证书">生成客户端子证书</h3>
<p>客户端证书（也叫子证书、Leaf Client Cert）是由我们刚刚生成的根 CA（ca.crt / ca.key） 签发的，用于证明客户端的身份。它与根 CA 的关系是信任链： 根 CA 是信任锚点 → 签发客户端证书 → Traefik 只信任由这个根 CA 签发的证书 → 实现 mTLS 双向强认证。</p>
<p>考虑到生成证书的命令参数过多，非常麻烦，我写了一个脚本 make-client.sh，建议直接放到存放 ca.crt 和 ca.key 的目录下运行（例如 ~/ca/），修改脚本开头的几个变量，每次为一个新设备运行一次即可。</p>
<pre><code class="language-bash"><span class="pl-c">#!/usr/bin/env bash</span>

organization_name=<span class="pl-s"><span class="pl-pds">"</span>Skyone<span class="pl-pds">"</span></span>       <span class="pl-c"># 你的组织/个人的标识</span>
client_name=<span class="pl-s"><span class="pl-pds">"</span>Windows 10 laptop<span class="pl-pds">"</span></span>  <span class="pl-c"># 设备/用途的描述性名称</span>
client_name_file=<span class="pl-s"><span class="pl-pds">"</span>win10laptop<span class="pl-pds">"</span></span>   <span class="pl-c"># 保存的文件名（简短且无空格）</span>

<span class="pl-c1">set</span> -e

<span class="pl-c"># 创建客户端私钥（RSA 4096）</span>
openssl genrsa -out client.key 4096

<span class="pl-c"># 创建客户端的证书请求 (CSR)</span>
openssl req -new -sha256 \
  -key client.key -out client.csr \
  -subj <span class="pl-s"><span class="pl-pds">"</span>/C=US/O=<span class="pl-smi">$organization_name</span>/CN=<span class="pl-smi">$organization_name</span> <span class="pl-smi">$client_name</span> mTLS Client<span class="pl-pds">"</span></span>

<span class="pl-c"># 用 CA 给客户端证书签名, 730 天（约 2 年）</span>
openssl x509 -req -sha256 -days 730 \
  -in client.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out client.crt

<span class="pl-c"># 导出 PKCS#12/.p12 证书 （不带证书链）</span>
<span class="pl-c"># 便于 Windows / macOS / iOS / Android 导入</span>
openssl pkcs12 -export \
  -inkey client.key -in client.crt \
  -out <span class="pl-s"><span class="pl-pds">"</span>client-<span class="pl-smi">$client_name_file</span>.p12<span class="pl-pds">"</span></span> -name <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$client_name_file</span><span class="pl-pds">"</span></span>

rm client.csr client.key client.crt
</code></pre>
<p>运行时会提示你输入导出密码，这是导入到系统密钥链/浏览器时用的密码，建议设置一个临时好记的密码，导入完设备后就可以删除 .p12 文件了。</p>
<h3 id="导入客户端证书">导入客户端证书</h3>
<p>我们刚刚通过 <code>make-client.sh</code> 生成了 PKCS#12 格式的客户端证书文件（<code>.p12</code>），里面同时包含了私钥和证书本身。现在需要将这个 <code>.p12</code> 文件安全拷贝到目标设备上，然后导入系统/浏览器的证书存储中。导入成功后，访问 mTLS 保护的服务时，浏览器或客户端工具就会自动（或手动选择）使用这个证书完成双向认证。</p>
<h4 id="windows-设备">Windows 设备</h4>
<ol>
<li>将 <code>.p12</code> 文件拷贝到 Windows 电脑（U 盘、局域网共享、加密邮件等方式，完成后立即删除源文件）。</li>
<li><strong>双击</strong> <code>.p12</code> 文件，系统会启动证书导入向导。</li>
<li>在“证书存储位置”页面，选择 <strong>当前用户</strong> → <strong>下一步</strong>。</li>
<li>输入导入时设置的<strong>导出密码</strong>（脚本运行时提示输入的那个密码）。</li>
<li>存储位置选择 <strong>个人</strong>（Personal / My） → <strong>下一步</strong> → <strong>完成</strong>。</li>
<li>导入成功后，可通过 <code>certmgr.msc</code>（Win + R 输入）打开证书管理器，在“个人 → 证书”下看到刚导入的证书（名称通常是你设置的 <code>$organization_name $client_name mTLS Client</code>）。</li>
</ol>
<h4 id="macos-设备">macOS 设备</h4>
<ol>
<li>双击 <code>.p12</code> 文件，系统会自动打开“钥匙串访问”应用。</li>
<li>输入导出密码，钥匙串会提示选择存储位置（推荐“登录”钥匙串）。</li>
<li>导入后，在钥匙串访问中搜索证书名称，可在“我的证书”分类下找到。</li>
</ol>
<h4 id="ios-ipados">iOS / iPadOS</h4>
<ol>
<li>进入 <strong>设置 → 通用 → VPN 与设备管理 → 已下载的描述文件</strong>（或直接搜索“证书”）。</li>
<li>安装描述文件，输入导出密码。</li>
<li>安装完成后，证书会出现在 <strong>设置 → 通用 → 关于本机 → 证书信任设置</strong>（部分版本）或直接在 Safari 使用时自动弹出选择。</li>
</ol>
<h4 id="android-设备">Android 设备</h4>
<p>不同厂商 ROM 路径略有差异（小米/华为/One UI 等可能名字不同），以Pixel手机为例：</p>
<ol>
<li>进入 设置→安全与隐私→更多安全和隐私设置 → 加密与凭据（或直接搜索“证书”）。</li>
<li>选择“安装证书”→ 找到 <code>.p12</code> 文件，输入导出密码。</li>
<li>安装完成后，证书会出现在“用户证书”列表中。</li>
</ol>
<h3 id="使用浏览器测试">使用浏览器测试</h3>
<p>访问被 mTLS 保护的站点，浏览器会弹出“选择证书”，直接选择刚导入的证书（通常显示你设置的 CN 名称，如 “Skyone Windows 10 laptop mTLS Client”），点击确定。</p>
<p>通过认证后，一切操作都不会有影响。因为 mTLS 是在 TLS 握手时实现的，而 HTTP 协议位于 TLS 内，应用程序根本感受不到 mTLS 的存在，后端程序不需要任何修改。</p>
<p><img src="https://blog.skyone.dev/_next/static/media/889b5d2f_24502d94.webp" alt="浏览器选择客户端证书" width="588" height="452" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/889b5d2f_24502d94.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/889b5d2f_24502d94.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/889b5d2f_24502d94.png&#x22;}]"></p>
<h2 id="一些特定应用的说明">一些特定应用的说明</h2>
<p>启用 mTLS 后，并非所有客户端和服务都会“自动适配”——这取决于应用本身如何处理 TLS 连接和客户端证书。以下我遇见过的常见自建服务场景的实际表现与应对方式，供参考。</p>
<h3 id="浏览器访问的-web-应用">浏览器访问的 Web 应用</h3>
<p>几乎所有基于浏览器的 Web 应用（包括但不限于我测试过的： Jellyfin、Immich、Nextcloud、Home Assistant、Photoprism、FileBrowser、Portainer、Traefik Dashboard、Vaultwarden、Bitwarden Web 等） 都会照常工作，就像 mTLS 不存在一样，服务端不需要任何配置。</p>
<p>现代浏览器（Chromium based、Firefox、Safari）在 TLS 握手阶段都能自动处理客户端证书。</p>
<p>诸如 Bitwarden 的浏览器插件也是如此，因为它们本身也就是一个 Web 页面。</p>
<h3 id="原生-app">原生 App</h3>
<p>移动端 App 的证书处理能力差异很大，主要取决于其底层网络库：</p>
<ul>
<li>基于 Android 原生 <code>HttpURLConnection</code> 或 <code>OkHttp</code> 的 App 都能自动调用 Android 系统级证书，无需额外配置。</li>
<li>自带独立 HTTP 客户端的 App（尤其是基于 Flutter、React Native 的应用）不走系统 TLS 证书存储，需要 App 开发者显式支持证书导入。例如基于 Flutter 开发的 Immich  在 App 的“设置” → “高级” 里有 “SSL 客户端证书”的导入选项。</li>
<li>基于 Web 的套壳 App 实际上大多使用的是 WebView，和浏览器访问一样。</li>
</ul>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用TinyAuth和Traefik实现简单的认证代理]]></title>
        <id>https://blog.skyone.dev/2025/tinyauth-traefik/</id>
        <link href="https://blog.skyone.dev/2025/tinyauth-traefik/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/7a6de1d9_1622e567.webp" type="image/webp"/>
        <updated>2025-08-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[使用 TinyAuth 和 Traefik 实现简单的认证代理。你是否遇到过需要为不支持认证的服务添加认证的情况？TinyAuth 是一个轻量级的认证代理，可以帮助你为这些服务添加基本的认证。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2025/tinyauth-traefik/">阅读原文</a>以获取最佳阅读体验。</p><p>你是否遇到过需要为不支持认证的服务添加认证的情况？TinyAuth 是一个轻量级的认证代理，可以帮助你为这些服务添加基本的认证。本文将介绍如何使用 TinyAuth 和 Traefik 实现简单的认证代理。</p>
<p>TinyAuth 支持包括 GitHub OAuth、简单密码认证在内的多种认证方式，这里我使用 Pocket ID 为例。</p>
<!-- readmore -->
<h2 id="准备工作">准备工作</h2>
<p>在开始之前，请确保你已经正确配置了 Traefik，并且可以通过 Traefik 访问你的服务。如果你还没有配置 Traefik，可以参考 <a href="https://blog.skyone.dev/2023/traefik-docker-gateway/">使用 Traefik 作为 Docker 的反向代理</a> 进行配置。</p>
<p>此外，我这里使用 Pocket ID 作为认证方式，你可以自行选择其他认证方式，参考 <a href="https://tinyauth.app/docs/guides/pocket-id">TinyAuth 的文档</a>。</p>
<p>现在假设有一个 <code>echo</code> 服务，需要添加认证，其域名是 <code>echo.skyone.dev</code>，我们需要在 <code>.skyone.dev</code> 下的任意子域名上部署 TinyAuth（与 <code>echo</code> 服务的域名在同一个域下），这里我使用 <code>auth.skyone.dev</code>。</p>
<h2 id="在-pocket-id-中注册应用">在 Pocket ID 中注册应用</h2>
<p>首先，你需要访问 Pocket ID 的管理面板，在 OIDC Clients 标签页中点击 Add OIDC Client。会出现一个新菜单，提示你提供一些信息。我们只需要设置其中的两个字段：</p>
<ol>
<li><strong>Client ID</strong>: 这是你的应用的唯一标识符，可以随意设置。</li>
<li><strong>Redirect URI</strong>: 这是 TinyAuth 的回调地址，格式为 <code>https://tinyauth.domain/api/oauth/callback/generic</code> 。例如，我的回调地址是 <code>https://auth.skyone.dev/api/oauth/callback/generic</code> 。</li>
</ol>
<p><img src="https://blog.skyone.dev/_next/static/media/e7c04708_406e2442.webp" alt="创建 Pocket ID 应用" width="1736" height="797" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e7c04708_406e2442.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e7c04708_406e2442.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e7c04708_406e2442.png&#x22;}]"></p>
<p>其他的选项都可以保持默认。创建完成后，你会得到一个 Client ID 和 Client Secret，这两个值稍后会用到。</p>
<p><img src="https://blog.skyone.dev/_next/static/media/49b6a505_2b22b27b.webp" alt="创建 Pocket ID 应用成功" width="1714" height="866" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/49b6a505_2b22b27b.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/49b6a505_2b22b27b.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/49b6a505_2b22b27b.png&#x22;}]"></p>
<h2 id="部署-tinyauth">部署 TinyAuth</h2>
<p>接下来，我们需要配置 TinyAuth。首先，创建一个名为 <code>tinyauth</code> 的 Docker Compose 文件，内容如下：</p>
<pre><code class="language-yaml"><span class="pl-ent">name</span>: <span class="pl-s">tinyauth</span>

<span class="pl-ent">services</span>:
  <span class="pl-ent">tinyauth</span>:
    <span class="pl-ent">image</span>: <span class="pl-s">ghcr.io/steveiliop56/tinyauth:v3</span>
    <span class="pl-ent">container_name</span>: <span class="pl-s">tinyauth</span>
    <span class="pl-ent">restart</span>: <span class="pl-s">unless-stopped</span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">proxy</span>
    <span class="pl-ent">env_file</span>:
      - <span class="pl-s">docker.env</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s">/var/run/docker.sock:/var/run/docker.sock:ro</span>
    <span class="pl-ent">labels</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.enable=true<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.docker.network=proxy<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.tinyauth.rule=Host(`tinyauth.skyone.dev`)<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.tinyauth.entrypoints=websecure<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.tinyauth.service=tinyauth<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.services.tinyauth.loadBalancer.server.port=3000<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.middlewares.tinyauth.forwardAuth.address=http://tinyauth:3000/api/auth/traefik<span class="pl-pds">"</span></span>

<span class="pl-ent">networks</span>:
  <span class="pl-ent">proxy</span>:
    <span class="pl-ent">external</span>: <span class="pl-c1">true</span>
</code></pre>
<p>这里需要修改的部分是 <code>tinyauth.skyone.dev</code>，将其替换为你自己的域名。</p>
<p>这里 labels 的最后一行为  Traefik 指定了一个中间件 <code>tinyauth</code>，这个中间件会在请求到达 <code>tinyauth</code> 服务之前进行认证，<code>http://tinyauth:3000</code> 是 TinyAuth 在容器内部的地址。</p>
<p>接下来，创建一个名为 <code>docker.env</code> 的环境变量文件，内容如下：</p>
<pre><code class="language-env"># --- Required Environment Variables ---
TINY_AUTH_DOMAIN="tinyauth.example.com"
POCKET_ID_DOMAIN="pocket-id.example.com"

POCKET_ID_NAME="Pocket ID"
SECRET=some-random-32-chars-string
GENERIC_CLIENT_ID="your-pocket-id-client-id"
GENERIC_CLIENT_SECRET="your-pocket-id-client-secret"

# --- DO NOT EDIT BELOW THIS LINE ---
APP_URL="https://${TINY_AUTH_DOMAIN}"
GENERIC_AUTH_URL="https://${POCKET_ID_DOMAIN}/authorize"
GENERIC_TOKEN_URL="https://${POCKET_ID_DOMAIN}/api/oidc/token"
GENERIC_USER_URL="https://${POCKET_ID_DOMAIN}/api/oidc/userinfo"
GENERIC_SCOPES="openid email profile groups"
GENERIC_NAME="${POCKET_ID_NAME}"
</code></pre>
<p>注释已经写得很清楚了，这里需要修改的部分是：</p>
<ul>
<li><code>TINY_AUTH_DOMAIN</code>: TinyAuth 的域名。</li>
<li><code>POCKET_ID_DOMAIN</code>: Pocket ID 的域名。</li>
<li><code>GENERIC_CLIENT_ID</code>: 在 Pocket ID 创建应用时获得的 Client ID。</li>
<li><code>GENERIC_CLIENT_SECRET</code>: 在 Pocket ID 创建应用时获得的 Client Secret。</li>
<li><code>POCKET_ID_NAME</code>: Pocket ID 的名称，可以随意设置，不会影响功能。</li>
<li><code>SECRET</code>: 使用 <code>openssl rand -hex 16</code> 生成一个随机字符串，用于加密和签名。</li>
</ul>
<p>编辑好 <code>docker.env</code> 后，运行 <code>docker compose up -d</code> 即可启动。</p>
<h2 id="为-echo-服务启用认证代理">为 echo 服务启用认证代理</h2>
<p>接下来，我们需要为 <code>echo</code> 服务添加 Traefik 的 labels，以便使用 TinyAuth 进行认证。<code>echo</code> 服务的 Docker Compose 文件如下：</p>
<pre><code class="language-yaml"><span class="pl-ent">name</span>: <span class="pl-s">echo</span>

<span class="pl-ent">services</span>:
  <span class="pl-ent">echo</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">echo</span>
    <span class="pl-ent">image</span>: <span class="pl-s">luotianyi/echo:latest</span>
    <span class="pl-ent">restart</span>: <span class="pl-s">unless-stopped</span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">proxy</span>
    <span class="pl-ent">labels</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>tinyauth.domain=echo.skyone.dev<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.enable=true<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.echo.entrypoints=websecure<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.echo.rule=Host(`echo.skyone.dev`)<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.echo.service=echo<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.services.echo.loadbalancer.server.port=5000<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.echo.middlewares=tinyauth<span class="pl-pds">"</span></span>

<span class="pl-ent">networks</span>:
  <span class="pl-ent">proxy</span>:
    <span class="pl-ent">external</span>: <span class="pl-c1">true</span>
</code></pre>
<p>只需要注意两行：</p>
<ul>
<li><code>tinyauth.domain=echo.skyone.host</code>: 这里的域名需要与 echo 服务的域名一致。</li>
<li><code>traefik.http.routers.echo.middlewares=tinyauth</code>: 这行表示使用 TinyAuth 进行认证，这个中间件来自 <code>tinyauth</code> 容器的 labels。</li>
</ul>
<p>你可以将这两行添加到你的任意需要认证的服务中，不需要别的操作即可实现认证。</p>
<h2 id="测试认证">测试认证</h2>
<p>现在，你可以访问 <code>https://echo.skyone.dev</code>，会被重定向到 TinyAuth 的登录页面。登录后，你将被重定向回 <code>echo</code> 服务。</p>
<p><img src="https://blog.skyone.dev/_next/static/media/199c68a3_59141585.webp" alt="登录页面" width="1130" height="693" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/199c68a3_59141585.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/199c68a3_59141585.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/199c68a3_59141585.png&#x22;}]"></p>
<p><img src="https://blog.skyone.dev/_next/static/media/cf6a2faf_f0497bad.webp" alt="重定向到 Pocket ID" width="1129" height="693" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/cf6a2faf_f0497bad.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/cf6a2faf_f0497bad.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/cf6a2faf_f0497bad.png&#x22;}]"></p>
<p><img src="https://blog.skyone.dev/_next/static/media/e1e77fd9_d03b2cf6.webp" alt="登录成功" width="1134" height="696" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e1e77fd9_d03b2cf6.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e1e77fd9_d03b2cf6.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/e1e77fd9_d03b2cf6.png&#x22;}]"></p>
<p><img src="https://blog.skyone.dev/_next/static/media/f10a3df9_14d7245d.webp" alt="重定向到 echo 服务" width="925" height="505" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/f10a3df9_14d7245d.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/f10a3df9_14d7245d.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/f10a3df9_14d7245d.png&#x22;}]"></p>
<p>注意，这里 TinyAuth 生成的 Cookie 位于 <code>.skyone.dev</code> 域名下，这就是 TinyAuth 和 <code>echo</code> 服务需要在同一个域名下的原因。</p>















































<table><thead><tr><th>TinyAuth 域名</th><th>服务域名</th><th>是否能共享 Cookie</th><th>说明</th></tr></thead><tbody><tr><td><code>auth.skyone.dev</code></td><td><code>echo.skyone.dev</code></td><td>✅ 可以</td><td>同属于 <code>skyone.dev</code>，Cookie 有效</td></tr><tr><td><code>auth.skyone.dev</code></td><td><code>grafana.skyone.dev</code></td><td>✅ 可以</td><td>也在 <code>skyone.dev</code> 下，Cookie 有效</td></tr><tr><td><code>auth.skyone.dev</code></td><td><code>echo.other.skyone.dev</code></td><td>✅ 可以</td><td>虽然多一层子域，但仍在 <code>skyone.dev</code> 下，Cookie 有效</td></tr><tr><td><code>auth.skyone.dev</code></td><td><code>echo.other.dev</code></td><td>❌ 不可以</td><td>根域名不同（<code>skyone.dev</code> ≠ <code>other.dev</code>）</td></tr><tr><td><code>auth1.skyone.dev</code></td><td><code>auth2.skyone.dev</code></td><td>✅ 可以</td><td>都在 <code>skyone.dev</code> 下，Cookie 有效</td></tr><tr><td><code>auth.other.skyone.dev</code></td><td><code>echo.skyone.dev</code></td><td>❌ 不可以</td><td><code>.other.skyone.dev</code> 不包括 <code>echo.skyone.dev</code></td></tr></tbody></table>
<h2 id="权限管理">权限管理</h2>
<p>TinyAuth + Pocket ID 的组合可以实现简单的权限管理。你可以在 Pocket ID 的管理面板中为不同的用户分配不同的权限。</p>
<p>例如，我希望 echo 服务只能被 <code>admin</code> 组的用户访问，可以在 Pocket ID 的管理面板中创建一个 <code>admin</code> 组，并将用户添加到该组中。然后在 echo 服务的 labels 中添加以下行：</p>
<pre><code class="language-yaml">- <span class="pl-s"><span class="pl-pds">"</span>tinyauth.oauth.groups=admin<span class="pl-pds">"</span></span>
</code></pre>
<p>完成，当用户访问 <code>echo</code> 服务时，TinyAuth 会检查用户是否属于 <code>admin</code> 组，如果不属于，则会被拒绝访问。</p>
<p>同样的，也可以通过用户的邮箱来限制访问，例如：</p>
<pre><code class="language-yaml">- <span class="pl-s"><span class="pl-pds">"</span>tinyauth.oauth.whitelist=user1@example.com,/@regex<span class="pl-cce">\\</span>.com$/,user2@example.com<span class="pl-pds">"</span></span>
</code></pre>
<p>可以看到，支持正则表达式（注意转义），还是很不错的。</p>
<p>通过这种方式，TinyAuth 不仅能提供统一的登录入口，还能与 Pocket ID 的用户和组管理结合，实现精细化的访问控制。</p>
<p>【完】</p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用Cloudflare Warp隐藏Misskey的IP地址]]></title>
        <id>https://blog.skyone.dev/2025/misskey-cloudflare-warp/</id>
        <link href="https://blog.skyone.dev/2025/misskey-cloudflare-warp/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/780c3487_e58731fa.webp" type="image/webp"/>
        <updated>2025-08-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[使用Cloudflare Warp隐藏Misskey的IP地址，避免IP泄露风险。使用容器运行 Cloudflare Warp 服务。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2025/misskey-cloudflare-warp/">阅读原文</a>以获取最佳阅读体验。</p><p>我一直使用 Cloudflare tunnel 来隐藏 Misskey 的 IP 地址，但是昨天晚上忽然想到，由于 misskey 是基于 ActivityPub
协议的，在服务器与其他服务器之间的通信中，IP 地址是公开的，如果攻击者构造了一个恶意的 ActivityPub 实现，马上就能拿到 Misskey
源站的 IP 地址。</p>
<p>想要避免这种情况，只能为 Misskey 的源站添加代理，而 Cloudflare Warp 就是一个不错的选择。本文根据大佬们的经验完成了 Misskey
的 Cloudflare Warp 配置。</p>
<!-- readmore -->
<h2 id="cloudflare-warp-简介">Cloudflare Warp 简介</h2>
<p>Cloudflare Warp 是 Cloudflare 提供的一个 VPN 服务，旨在提供更快、更安全的互联网连接。它通过 Cloudflare
的全球网络来加密和优化用户的网络流量，从而提高访问速度并保护用户隐私<sup><a href="#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup>。</p>
<p>Warp 是限速的，为了获取不限量的 Warp+（速度更快），我们需要绑定 Cloudflare Zero Trust 的账号<sup><a href="#user-content-fn-2" id="user-content-fnref-2" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup>。</p>
<h2 id="准备工作">准备工作</h2>
<p>如果你决定不使用 Warp+，可以跳过这一步。</p>
<p>注册 Cloudflare 账号，如果你还没有 Cloudflare 账号，注册一个。</p>
<p>在 Cloudflare Zero Trust 中创建一个团队，登录 Cloudflare
后，访问 <a href="https://one.dash.cloudflare.com/">Cloudflare Zero Trust</a> 并创建一个新团队。</p>
<p>在 Cloudflare Zero Trust 配置 Warp 为 Proxy 模式</p>
<p>找到 设置 -> WARP 客户端 -> 配置文件设置，如图所示</p>
<p><img src="https://blog.skyone.dev/_next/static/media/d4c7d036_e5644f3e.webp" alt="Cloudflare Zero Trust 配置 Warp" width="1368" height="695" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/d4c7d036_e5644f3e.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/d4c7d036_e5644f3e.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/d4c7d036_e5644f3e.png&#x22;}]"></p>
<p>将服务模式改为“代理模式”，然后点击“保存更改”。</p>
<p><img src="https://blog.skyone.dev/_next/static/media/0677647d_9050d9a5.webp" alt="Cloudflare Zero Trust 配置 Warp" width="721" height="364" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/0677647d_9050d9a5.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/0677647d_9050d9a5.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/0677647d_9050d9a5.png&#x22;}]"></p>
<p>在 网络 -> Tunnels 中，点击“添加隧道”，创建一个 Warp 隧道，拿到 Warp 令牌（<code>warp-cli connector new</code> 后面的那一串字符串）。</p>
<h2 id="构建-warp-容器">构建 Warp 容器</h2>
<p>确保你的服务器上安装了 Docker 和 Docker Compose。</p>
<p>下面是我的 <code>docker-compose.yml</code> 文件内容<sup><a href="#user-content-fn-3" id="user-content-fnref-3" data-footnote-ref="" aria-describedby="footnote-label">3</a></sup>：</p>
<pre><code class="language-yaml"><span class="pl-ent">services</span>:
  <span class="pl-ent">cloudlare-warp</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">cloudflare-warp</span>
    <span class="pl-ent">image</span>: <span class="pl-s">luotianyi/cloudflare-warp:latest</span>
    <span class="pl-ent">restart</span>: <span class="pl-s">unless-stopped</span>
    <span class="pl-ent">build</span>:
      <span class="pl-ent">context</span>: <span class="pl-s">build</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>./data:/app<span class="pl-pds">"</span></span>
    <span class="pl-ent">environment</span>:
      <span class="pl-ent">WARP_TOKEN</span>: <span class="pl-s">ChangeMe</span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">proxy</span>

<span class="pl-ent">networks</span>:
  <span class="pl-ent">proxy</span>:
    <span class="pl-ent">external</span>: <span class="pl-c1">true</span>
</code></pre>
<p>将 <code>WARP_TOKEN</code> 替换为你在 Cloudflare Zero Trust 中获取的 Warp 令牌。</p>
<p>下面的 Dockerfile 放到 <code>build</code> 目录下：</p>
<pre><code class="language-dockerfile">FROM debian:bookworm-slim

WORKDIR /app
RUN apt-get update &#x26;&#x26; \
    apt-get install -y --no-install-recommends \
        ca-certificates \
        curl \
        gnupg \
        lsb-release &#x26;&#x26; \
    rm -rf /var/lib/apt/lists/*
RUN curl https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg &#x26;&#x26; \
    echo "deb [arch=amd64 signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/cloudflare-client.list &#x26;&#x26; \
    apt-get update &#x26;&#x26; \
    apt-get install cloudflare-warp -y --no-install-recommends &#x26;&#x26; \
    rm -rf /var/lib/apt/lists/* &#x26;&#x26; \
    curl -L https://github.com/go-gost/gost/releases/download/v3.2.3/gost_3.2.3_linux_amd64.tar.gz -o gost.tar.gz &#x26;&#x26; \
    tar -xzf gost.tar.gz -C /usr/local/bin/ &#x26;&#x26; \
    rm gost.tar.gz /usr/local/bin/LICENSE /usr/local/bin/README.md /usr/local/bin/README_en.md
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

CMD ["/entrypoint.sh"]
VOLUME ["/app"]
EXPOSE 1080/tcp
</code></pre>
<p>下面是 <code>entrypoint.sh</code> <sup><a href="#user-content-fn-4" id="user-content-fnref-4" data-footnote-ref="" aria-describedby="footnote-label">4</a></sup>，同样放在 <code>build</code> 目录下：</p>
<pre><code class="language-bash"><span class="pl-c">#!/usr/bin/env sh</span>

nohup /usr/bin/warp-svc <span class="pl-k">></span> /app/warp.log <span class="pl-k">&#x26;</span>

sleep 3

<span class="pl-k">if</span> [ <span class="pl-k">-n</span> <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$WARP_TOKEN</span><span class="pl-pds">"</span></span> ]<span class="pl-k">;</span> <span class="pl-k">then</span>
    <span class="pl-c1">echo</span> <span class="pl-s"><span class="pl-pds">"</span>Using WARP token from environment variable<span class="pl-pds">"</span></span>
    warp-cli --accept-tos connector new <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$WARP_TOKEN</span><span class="pl-pds">"</span></span>
    warp-cli --accept-tos connect
<span class="pl-k">else</span>
    <span class="pl-c1">echo</span> <span class="pl-s"><span class="pl-pds">"</span>No WARP token provided, using default connector<span class="pl-pds">"</span></span>
    warp-cli --accept-tos registration new
    warp-cli --accept-tos mode proxy
    warp-cli --accept-tos connect
<span class="pl-k">fi</span>

gost -L=http://:1080 -F=socks5://127.0.0.1:40000
</code></pre>
<p>解释一下这个容器的功能，它会后台启动 Cloudflare Warp 服务，然后使用 <code>warp-cli</code> 命令连接到 Cloudflare 的 Warp 网络。由于
Warp 只支持 socks5 协议，所以我们使用 <code>gost</code> 将其转换为 HTTP 代理，监听在 1080 端口。</p>
<p>实际上这个容器并不完善，因为使用了 <code>nohup</code> 命令来后台运行 <code>warp-svc</code>，在关闭容器时必然要等到超时强制
kill。不是很优雅的做法，但凑合用吧，不想折腾 s6-overlay 了。</p>
<p>启动容器 <code>docker-compose up -d</code>，然后让我们测试一下：</p>
<pre><code class="language-shell">curl -x http://<span class="pl-k">&#x3C;</span>warp-container-ip<span class="pl-k">></span>:1080 https://ipinfo.io
</code></pre>
<p>应该会告诉你你的 IP 属于 Cloudflare Warp。</p>
<h2 id="配置-misskey-使用-warp-代理">配置 Misskey 使用 Warp 代理</h2>
<p>这一步非常简单，只需要在 Misskey 的配置文件中添加以下内容：</p>
<pre><code class="language-yaml"><span class="pl-ent">proxy</span>: <span class="pl-s">http://cloudflare-warp:1080</span>
</code></pre>
<p>如果你修改了容器名，这里自行同步修改。</p>
<p>然后重启 Misskey 容器即可 <code>docker restart misskey</code>。打开浏览器，看看你的 Misskey 能不能获取其他服务器的信息吧。</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-1">
<p><a href="https://milu.ink/492.html">使用 Cloudflare WARP 保护 Sharkey / Misskey 源站</a> <a href="#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-2">
<p><a href="https://www.fireline.fun/article/page-31">Linux  vps上 使用 Cloudflare ZeroTrust WARP</a> <a href="#user-content-fnref-2" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-3">
<p><a href="https://github.com/TunMax/canal">GitHub (TunMax/canal)</a> <a href="#user-content-fnref-3" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-4">
<p><a href="https://github.com/cmj2002/warp-docker">GitHub (cmj2002/warp-docker)</a> <a href="#user-content-fnref-4" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[近期概况（2025年7月）]]></title>
        <id>https://blog.skyone.dev/2025/recent-status-july/</id>
        <link href="https://blog.skyone.dev/2025/recent-status-july/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/0581b7e3_cecad87c.webp" type="image/webp"/>
        <updated>2025-08-05T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[最近工作比较忙，项目在赶进度，确实是没有多少时间来写博客。虽然如此，最近还是有一些事情可以分享的，那么就开一个新的系列「随笔」吧，不定期记录最近做的有趣的事。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2025/recent-status-july/">阅读原文</a>以获取最佳阅读体验。</p><p>如你所见，博客已经有一段时间没有更新了，今天抽空来除除草，也让大家这个网站还在持续更新。</p>
<p>最近工作比较忙，项目在赶进度，确实是没有多少时间来写博客。虽然如此，最近还是有一些事情可以分享的，那么就开一个新的系列「随笔」吧，不定期<ruby><rb><del>
记录最近做的有趣的事</del></rb><rp>(</rp><rt>实际上就是水文</rt><rp>(</rp></ruby>。</p>
<!-- readmore -->
<h2 id="n100-小主机">N100 小主机</h2>
<p>最近我买了一台 N100 的小主机，替换了我那工作了5年的树莓派4B。N100 是英特尔的低功耗处理器，性能比树莓派4B 强很多，而且同样功耗也很低。</p>
<p><img src="https://blog.skyone.dev/_next/static/media/74552e0f_a7b9228f.webp" alt="Home Server" width="1024" height="576" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/74552e0f_a7b9228f.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/74552e0f_a7b9228f.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/74552e0f_a7b9228f.png&#x22;}]"></p>
<center>可以看到颜值还是很高的~</center>
<p>目前配了 16G 内存 + 1T SSD 固态，运行着 Docker 和一些自建服务。外网访问用的是 WireGuard 组网 + 另外购买的 frp 服务。</p>
<h2 id="密码管理器">密码管理器</h2>
<p>对于密码管理，最开始我是直接记在C盘下的一个文件夹里的，后来觉得不安全，恰好那时候开始给 Git 提交上了 GPG 签名，就采用 GPG
加密的方式存储密码，这个方案一直到现在都是可用的。</p>
<p>但是这样做有两个显著的缺点：</p>
<ol>
<li>每次忘记密码都要去解密文件，比较麻烦。</li>
<li>完全没有同步功能，密码只能存储在本地。最开始我就一台电脑，没有什么问题，去年又买了台台式机，经常为了查密码而切换电脑，比较麻烦。</li>
</ol>
<p>于是，我调研了一下密码管理器，发现有很多不错的开源项目。</p>



































<table><thead><tr><th>功能</th><th>1Password</th><th>Bitwarden</th><th>KeePassXC</th></tr></thead><tbody><tr><td>开源</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>云同步</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>端到端加密</td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td>跨平台</td><td>✅</td><td>✅</td><td>✅</td></tr></tbody></table>
<p>存密码的东西当然是优先用开源的啦，我最开始试的是 KeePassXC + Syncthing，确实没啥问题，但手机上每次密码变更都要手动打开 Syncthing 同步，还是比较麻烦。</p>
<p>这个月我试着将 KeePassXC 换成了 Bitwarden 的 Rust 实现 vaultwarden，使用 Docker 在自己的服务器上部署了一份，目前用起来还不错。compose 文件在 <a href="https://git.skyone.host/skyone-wzw/compose/src/branch/master/vaultwarden">skyone-wzw/compose</a>。</p>
<p>Bitwarden 的 Rust 实现 vaultwarden 轻量级很多，运行起来资源占用很小。它的更倾向于个人或小团队使用，没有 OSS 等大型组织的支持，由于其完全开源（包括在 Bitwarden 里需要购买许可证的功能），现在它在GitHub 上的 Star 已经超过原本的 Bitwarden 项目了。</p>
<h2 id="immich-私人相册">Immich 私人相册</h2>
<p>最近我还部署了一个私人相册，使用的是 <a href="https://immich.app/">Immich</a>，这是一个开源的照片和视频备份解决方案，支持自动备份手机上的照片和视频。</p>
<blockquote color="warning" class="quote-alert">
<p class="quote-alert-title"><svg viewBox="0 0 16 16" version="1.1" width="16" height="16"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>警告</p>
<p>需要注意的是，Immich 目前正处于积极开发状态，并未稳定，可能出现破坏性变更，不建议将其作为唯一的备份方案。</p>
</blockquote>
<p>Immich 比较吃配置，不太可能在云服务器上运行，我将其部署在上面提到的家里的 x86 小主机上。</p>
<p>实际用下来，网页端完全没问题，很好看，很像 Google Photos。而手机端一开始也挺不错的，支持按相册分类自动上传，上传后会自动加入云端对于的相册。然而，当我在另一部手机上也安装了 Immich 并登录同一个账号时，发现手机端的相册列表会变成「全部照片」，而不是之前的「相册」分类，并且会有一些 Bug，例如已经上传的照片会重复上传（虽然最后会自动合并），这让我有点不爽。不过毕竟是开发中，期待后续的改进。</p>
<h2 id="misskey">Misskey</h2>
<p>自从今年3月部署了 Misskey 之后，我就一直在用它作为个人的主要社交网络。欢迎大家关注我的 Misskey 账号 <a href="https://social.akk.moe/@skyone">@skyone@social.akk.moe</a>！</p>
<h2 id="结语">结语</h2>
<p>最近的情况就是这样，希望能在接下来的时间里有更多的时间来写博客和分享有趣的事情。</p>
<p>封面图片来自 <a href="https://www.pixiv.net/artworks/126375185">Pixiv 126375185</a></p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[Btrfs快照方案]]></title>
        <id>https://blog.skyone.dev/2025/btrfs-snapshot/</id>
        <link href="https://blog.skyone.dev/2025/btrfs-snapshot/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/ca8b24aa_997c9074.webp" type="image/webp"/>
        <updated>2025-04-05T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[分享我在 ArchLinux 中使用 Btrfs 快照替代虚拟机测试环境的实践经验，包括快照的创建、恢复方式（含非 LiveCD 场景）、分区方案及自动化脚本，让系统实验更安心更高效。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2025/btrfs-snapshot/">阅读原文</a>以获取最佳阅读体验。</p><p>去年从 ext4 文件系统切换到了 btrfs，当时主要看中的是快照功能。最开始使用的是 timeshift，能用，但是可定制性不够，遂尝试
直接手动创建快照。</p>
<p>自从用了快照，最显著的变化是：折腾各种危险的东西再也不需要使用虚拟机，直接host开搞，最坏不过回滚一下快照，重新生成一下
GRUB，一个健康的系统就又回来了。</p>
<p>当然，快照大多了，就开始考虑怎么自动化，于是简单写了个脚本，试了一下还不错，放在本文后面了。</p>
<!-- readmore -->
<p>我使用的 ArchLinux 使用了如下的分区方案：</p>
<pre><code>╭────────────┬────────┬───────┬────────┬────────────────╮
│ Mounted on │ Size   │ Type  │ Volume | Filesystem     │
├────────────┼────────┼───────┼────────┼────────────────┤
│ /          │ 937.6G │ btrfs │ @      | /dev/nvme0n1p4 │
│ /boot      │ 920.7M │ ext4  │        | /dev/nvme0n1p2 │
│ /boot/efi  │ 475.1M │ vfat  │        | /dev/nvme0n1p1 │
│ /home      │ 937.6G │ btrfs │ @home  | /dev/nvme0n1p4 │
╰────────────┴────────┴───────┴────────┴────────────────╯
</code></pre>
<p>我将快照保存在 Btrfs 根分区的 <code>.snapshots</code> 目录，就像这样：</p>
<pre><code>[/]
├─ [@]
├─ [@home]
╰─ .snapshots
   ├─ [@-20250101] (ro)
   ╰─ [@home-20250101] (ro)
</code></pre>
<p>其中子卷使用 <code>[]</code> 表示。</p>
<p>日常使用不挂载 <code>[/]</code> 子卷，也就是说，无论怎么玩都不会破坏快照。</p>
<blockquote>
<p>请注意！建议在 <code>fstab</code> 中不要指定 <code>subvolid</code> ，而是使用 <code>subvol</code> 直接指定子卷名称，这样恢复快照会更方便，下面的所有例子都以此为前提。</p>
</blockquote>
<h2 id="create-snapshot">创建快照</h2>
<p>当需要创建快照时，先挂载 <code>[/]</code> 子卷</p>
<pre><code class="language-shell">mount -o compress=zstd:3,subvol=/ /dev/nvme0n1p3 /mnt
</code></pre>
<p>创建只读快照</p>
<pre><code class="language-shell">btrfs subvolume snapshot -r /mnt/@ /mnt/.snapshots/@-<span class="pl-s"><span class="pl-pds">`</span>date +<span class="pl-pds">"</span>%Y%m%d<span class="pl-pds">"`</span></span>
btrfs subvolume snapshot -r /mnt/@ /mnt/.snapshots/@home-<span class="pl-s"><span class="pl-pds">`</span>date +<span class="pl-pds">"</span>%Y%m%d<span class="pl-pds">"`</span></span>
</code></pre>
<p>取消挂载 <code>[/]</code></p>
<pre><code class="language-shell">umount /mnt
</code></pre>
<h2 id="restore-snapshot">恢复快照</h2>
<p>可以通过修改 <code>fstab</code> 实现只重启两次即可恢复快照，不需要进入LiveCD模式。</p>
<p>具体思路是创建一个快照到 <code>@-restore</code> <code>@home-restore</code></p>
<pre><code class="language-shell">mount -o compress=zstd:3,subvol=/ /dev/nvme0n1p3 /mnt
btrfs subvolume snapshot /mnt/.snapshots/@-yyyymmdd /mnt/@-restore
btrfs subvolume snapshot /mnt/.snapshots/@home-yyyymmdd /mnt/@home-restore
</code></pre>
<p>编辑 <code>/etc/fstab</code> ，把 <code>@</code> 子卷和 <code>@home</code> 子卷改成 <code>@-restore</code> 和 <code>@home-restore</code>。</p>
<blockquote>
<p>这里修改 <code>/etc/fstab</code> 的作用是提示 grub 根分区的位置，而真正起作用的是 <code>/mnt/@-restore/etc/fstab</code></p>
</blockquote>
<pre><code class="language-shell">cp /etc/fstab /mnt/@-restore/etc/fstab
grub-mkconfig -o /boot/grub/grub.cfg
</code></pre>
<p>重启电脑。</p>
<p>重启之后，再把两个子卷的名字改回去：</p>
<pre><code class="language-shell">mount -o compress=zstd:3,subvol=/ /dev/nvme0n1p3 /mnt
btrfs subvolume delete /mnt/@
btrfs subvolume delete /mnt/@home
btrfs subvolume snapshot /mnt/.snapshots/@-yyyymmdd /mnt/@
btrfs subvolume snapshot /mnt/.snapshots/@-yyyymmdd /mnt/@home
</code></pre>
<p>编辑 <code>/etc/fstab</code> ，把 <code>@-restore</code> 子卷和 <code>@home-restore</code> 子卷改回 <code>@</code> 和 <code>@</code>。</p>
<pre><code class="language-shell">grub-mkconfig -o /boot/grub/grub.cfg
</code></pre>
<p>再次重启电脑。</p>
<p>重启之后，把临时的 <code>restore</code> 分区删了</p>
<pre><code class="language-shell">mount -o compress=zstd:3,subvol=/ /dev/nvme0n1p3 /mnt
btrfs subvolume delete /mnt/@-restore
btrfs subvolume delete /mnt/@home-restore
umount /mnt
</code></pre>
<p>结束</p>
<h2 id="restore-snapshot-livecd">恢复快照（LiveCD）</h2>
<p>如果你的系统已经没办法引导了，也可以使用LiveCD恢复快照，这个过程甚至比上面的要简单。</p>
<p>进入LiveCD环境，修改 <code>@</code> 和 <code>@home</code> 就结束了，什么配置都不用改。</p>
<pre><code class="language-shell">mount -o compress=zstd:3,subvol=/ /dev/nvme0n1p3 /mnt
btrfs subvolume delete /mnt/@
btrfs subvolume delete /mnt/@home
btrfs subvolume snapshot /mnt/.snapshots/@-yyyymmdd /mnt/@
btrfs subvolume snapshot /mnt/.snapshots/@-yyyymmdd /mnt/@home
umount /mnt
</code></pre>
<p>重启即可。</p>
<h2 id="snapshot-script">快照脚本</h2>
<p>自动创建快照，创建快照前会删除 Pacman 缓存，如果你不使用ArchLinux，请自行修改 <code>make_snapshot</code> 函数第2行为对应的包管理器</p>
<pre><code class="language-shell"><span class="pl-c">#!/usr/bin/env sh</span>

<span class="pl-c1">set</span> -e

<span class="pl-en">run</span>() {
    <span class="pl-k">if</span> [ <span class="pl-k">-z</span> <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$is_fake</span><span class="pl-pds">"</span></span> ] <span class="pl-k">||</span> [ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$is_fake</span><span class="pl-pds">"</span></span> <span class="pl-k">=</span> <span class="pl-c1">false</span> ]<span class="pl-k">;</span> <span class="pl-k">then</span>
        <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>sh: \033[1;32m<span class="pl-smi">$*</span>\033[0m<span class="pl-pds">"</span></span>
        sudo <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$@</span><span class="pl-pds">"</span></span> <span class="pl-k">></span> /dev/null
    <span class="pl-k">else</span>
        <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>> \033[1;32m<span class="pl-smi">$*</span>\033[0m<span class="pl-pds">"</span></span>
    <span class="pl-k">fi</span>
}

<span class="pl-en">make_snapshot</span>() {
    NOW_DATE=<span class="pl-s"><span class="pl-pds">$(</span>date +<span class="pl-pds">"</span>%Y%m%d<span class="pl-pds">")</span></span>
    run pacman -Sc --noconfirm
    run mount -o compress=zstd:3,subvol=/ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$DEVICE</span><span class="pl-pds">"</span></span> /mnt
    
    SNAPSHOT_FROM=<span class="pl-s"><span class="pl-pds">"</span>/mnt/@<span class="pl-pds">"</span></span>
    SNAPSHOT_TO=<span class="pl-s"><span class="pl-pds">"</span>/mnt/.snapshots/@-<span class="pl-smi">$NOW_DATE</span><span class="pl-pds">"</span></span>
    SNAPSHOT_NAME=<span class="pl-s"><span class="pl-pds">"</span>.snapshots/@-<span class="pl-smi">$NOW_DATE</span><span class="pl-pds">"</span></span>
    <span class="pl-k">if</span> [ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$is_fake</span><span class="pl-pds">"</span></span> <span class="pl-k">=</span> <span class="pl-c1">false</span> ] <span class="pl-k">&#x26;&#x26;</span> sudo btrfs subvolume list /mnt -o <span class="pl-k">|</span> grep -q <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_NAME</span><span class="pl-pds">"</span></span><span class="pl-k">;</span> <span class="pl-k">then</span>
        run btrfs subvolume delete <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_TO</span><span class="pl-pds">"</span></span>
    <span class="pl-k">fi</span>
    run btrfs subvolume snapshot -r <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_FROM</span><span class="pl-pds">"</span></span> <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_TO</span><span class="pl-pds">"</span></span>

    SNAPSHOT_FROM=<span class="pl-s"><span class="pl-pds">"</span>/mnt/@home<span class="pl-pds">"</span></span>
    SNAPSHOT_TO=<span class="pl-s"><span class="pl-pds">"</span>/mnt/.snapshots/@home-<span class="pl-smi">$NOW_DATE</span><span class="pl-pds">"</span></span>
    SNAPSHOT_NAME=<span class="pl-s"><span class="pl-pds">"</span>.snapshots/@home-<span class="pl-smi">$NOW_DATE</span><span class="pl-pds">"</span></span>
    <span class="pl-k">if</span> [ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$is_fake</span><span class="pl-pds">"</span></span> <span class="pl-k">=</span> <span class="pl-c1">false</span> ] <span class="pl-k">&#x26;&#x26;</span> sudo btrfs subvolume list /mnt -o <span class="pl-k">|</span> grep -q <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_NAME</span><span class="pl-pds">"</span></span><span class="pl-k">;</span> <span class="pl-k">then</span>
        run btrfs subvolume delete <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_TO</span><span class="pl-pds">"</span></span>
    <span class="pl-k">fi</span>
    run btrfs subvolume snapshot -r <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_FROM</span><span class="pl-pds">"</span></span> <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$SNAPSHOT_TO</span><span class="pl-pds">"</span></span>

    run umount /mnt
}

DEVICE=<span class="pl-s"><span class="pl-pds">$(</span>findmnt -n -o SOURCE / <span class="pl-k">|</span> sed <span class="pl-pds">'</span>s/\[.*\]//<span class="pl-pds">')</span></span>

<span class="pl-c1">echo</span> <span class="pl-s"><span class="pl-pds">"</span>List all devices<span class="pl-pds">"</span></span>
df -h <span class="pl-k">|</span> grep nvme
<span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>Selected device: \033[1;32m<span class="pl-smi">$DEVICE</span>\033[0m<span class="pl-pds">"</span></span>
<span class="pl-c1">echo</span>
<span class="pl-c1">echo</span> <span class="pl-s"><span class="pl-pds">"</span>Following commands will be executed:<span class="pl-pds">"</span></span>
is_fake=true
make_snapshot

<span class="pl-c1">read</span> -r -p <span class="pl-s"><span class="pl-pds">"</span>Are you sure? [y/N] <span class="pl-pds">"</span></span> input
input=<span class="pl-smi">${input,,}</span>
<span class="pl-c1">echo</span>

<span class="pl-k">if</span> [ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$input</span><span class="pl-pds">"</span></span> <span class="pl-k">=</span> <span class="pl-s"><span class="pl-pds">"</span>y<span class="pl-pds">"</span></span> ]<span class="pl-k">;</span> <span class="pl-k">then</span>
    is_fake=false
    make_snapshot
<span class="pl-k">fi</span>
</code></pre>
<p>列出全部快照，会自动检查 <code>@</code> 和 <code>@home</code> 的快照是否成对出现</p>
<pre><code class="language-shell"><span class="pl-c">#!/usr/bin/env sh</span>

<span class="pl-c1">set</span> -e

<span class="pl-en">run</span>() {
    <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>sh: \033[1;32m<span class="pl-smi">$*</span>\033[0m<span class="pl-pds">"</span></span>
    sudo <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$@</span><span class="pl-pds">"</span></span>
}

<span class="pl-en">list_snapshots</span>() {
    run mount -o compress=zstd:3,subvol=/ <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$DEVICE</span><span class="pl-pds">"</span></span> /mnt
    snapshot_dates=<span class="pl-s"><span class="pl-pds">$(</span>ls /mnt/.snapshots <span class="pl-k">|</span> grep <span class="pl-pds">'</span>@-<span class="pl-pds">'</span> <span class="pl-k">|</span> sed <span class="pl-pds">'</span>s/@-//<span class="pl-pds">'</span> <span class="pl-k">|</span> sort -u<span class="pl-pds">)</span></span>
    
    <span class="pl-k">if</span> [ <span class="pl-k">-z</span> <span class="pl-s"><span class="pl-pds">"</span><span class="pl-smi">$snapshot_dates</span><span class="pl-pds">"</span></span> ]<span class="pl-k">;</span> <span class="pl-k">then</span>
        <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>\033[1;31mNo snapshots found.\033[0m<span class="pl-pds">"</span></span>
        run umount /mnt
        <span class="pl-k">return</span>
    <span class="pl-k">fi</span>

    <span class="pl-c"># Check if each date has a corresponding home snapshot</span>
    <span class="pl-k">for</span> <span class="pl-smi">date</span> <span class="pl-k">in</span> <span class="pl-smi">$snapshot_dates</span><span class="pl-k">;</span> <span class="pl-k">do</span>
        <span class="pl-k">if</span> <span class="pl-k">!</span> ls /mnt/.snapshots <span class="pl-k">|</span> grep -q <span class="pl-s"><span class="pl-pds">"</span>@home-<span class="pl-smi">$date</span><span class="pl-pds">"</span></span><span class="pl-k">;</span> <span class="pl-k">then</span>
            <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>\033[1;33mWarning: No home snapshot for date <span class="pl-smi">$date</span>!\033[0m<span class="pl-pds">"</span></span>
        <span class="pl-k">fi</span>
    <span class="pl-k">done</span>

    <span class="pl-c"># Display the list of dates</span>
    <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>\n\033[1;32mSnapshots found for the following dates:\033[0m<span class="pl-pds">"</span></span>
    <span class="pl-k">for</span> <span class="pl-smi">date</span> <span class="pl-k">in</span> <span class="pl-smi">$snapshot_dates</span><span class="pl-k">;</span> <span class="pl-k">do</span>
        <span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>\033[1;36m<span class="pl-smi">$date</span>\033[0m<span class="pl-pds">"</span></span>
    <span class="pl-k">done</span>

    run umount /mnt
}

DEVICE=<span class="pl-s"><span class="pl-pds">$(</span>findmnt -n -o SOURCE /home <span class="pl-k">|</span> sed <span class="pl-pds">'</span>s/\[.*\]//<span class="pl-pds">')</span></span>

<span class="pl-c1">echo</span> <span class="pl-s"><span class="pl-pds">"</span>List all devices<span class="pl-pds">"</span></span>
df -h <span class="pl-k">|</span> grep nvme
<span class="pl-c1">echo</span> -e <span class="pl-s"><span class="pl-pds">"</span>Selected device: \033[1;32m<span class="pl-smi">$DEVICE</span>\033[0m<span class="pl-pds">"</span></span>
<span class="pl-c1">echo</span>

list_snapshots
</code></pre>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用 Rust 实现自己的评论系统]]></title>
        <id>https://blog.skyone.dev/2025/rust-comment-server/</id>
        <link href="https://blog.skyone.dev/2025/rust-comment-server/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/a26bac62_36afea58.webp" type="image/webp"/>
        <updated>2025-01-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[最近用 Rust 重写了博客的评论系统，这里分享一下实现过程以及一些对 Rust 的看法。主要使用了 tokio、sqlx、tera、axum 等 crate。包含遇到的一些坑和我的解决方案。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2025/rust-comment-server/">阅读原文</a>以获取最佳阅读体验。</p><p>最近发现博客的评论系统后端有点问题，占用内存过于的大了（基于 Node.js + PostgreSQL，RES 内存占用 200MB+55MB），这不正常。而且我在国内的唯一一个服务器只有 2G 内存，很容易就被干满了。所以决定使用一个编译型语言重写一下。</p>
<p>最开始选的其实是 Golang，因为被网上传的 Rust 入门太难吓住了😂，试了一下发现 Golang 的 ORM 实在是太难用了，而且我非常讨厌 Golang 的语法，所以还是决定用 Rust 了。</p>
<!-- readmore -->
<h2 id="pick-crates">挑选各种 crate</h2>
<p>不得不说，Rust 确实有被吹的资格，在编译型无 GC 语言中，cargo这个包管理感觉能排进第一梯队。</p>
<p>另外，Rust 的很大基本功能也是在 crate 中实现，例如 异步运行时、随机数、加密、数据库等，都没有一个官方的实现，而是由社区维护的 crate 提供。</p>
<p>这么做可以说是有优点也有缺点，优点是社区可以更快的迭代，缺点是可能产生很多重复的工作，以及各个库之间需要手动糊在一起，不能统一接口。</p>
<p>最终我选择了以下 crate：</p>
<ul>
<li>
<p>异步运行时：<code>tokio</code></p>
<p>这个没什么好说的，就俩选择，就挑了一个感觉更火的。</p>
</li>
<li>
<p>数据库：<code>sqlx</code></p>
<p>干简单活嘛，SQL 一共就没几句，用这个就足够了，不需要 ORM。但这个库有点坑，后面提到。</p>
</li>
<li>
<p>序列号：<code>serde</code></p>
<p>这个也没什么好说的，基本上以经成为既定标准了。</p>
</li>
<li>
<p>HTTP 服务器：<code>axum</code></p>
<p>据说这个与 <code>tokio</code> 配合的很好，而且写起来符合我的习惯（或者说很像 <code>express</code>）。</p>
</li>
<li>
<p>模板引擎：<code>tera</code></p>
<p>据说用的是 <code>jinja2</code> 的语法，很出名。</p>
</li>
</ul>
<p>嘛，主要就这些了，后面一些小工具陆陆续续加了点，提到的话再说。</p>
<h2 id="pitfalls">一点小坑</h2>
<p>由于是第一次写 Rust，很多东西都是边学边用，所以遇到了不少坑，这里简单记录一下。</p>
<h3 id="sqlx-pitfalls"><code>sqlx</code> 的坑</h3>
<p>首先来拷打这个库，这个库的文档写地有点不清不楚，对 <code>sqlx::query!</code> 的在线模式的描述不够清楚，导致调试运行没问题，release 编译不通过。。。</p>
<p>在线模式指的是，在项目文件夹创建一个 <code>.env</code> 文件，里面配置 <code>DATABASE_URL</code> 环境变量，<code>sqlx::query!</code> 会读取这个环境变量，以提供实时的 SQL 语句检查和类型生成。这个 <code>DATABASE_URL</code> 的格式是 <code>sqlite:C:/path/to/db.sqlite</code>。</p>
<p>然而，这个 <code>.env</code> 文件不能添加到 git 里！这会导致 CI 里编译不通过！因为 CI 里并没有调试用的数据库，而如果想要生成这个数据库需要 <code>cargo install sqlx-cli</code>，编译一次需要十几分钟。。。</p>
<p>后来，在一个文档的拐角找的了：运行 <code>cargo sqlx prepare</code> 会生成一个 <code>.sqlx</code> 文件夹，把这个文件夹加到 git 里，在 CI 里编译的时候，sqlx 会读取这个文件夹里的配置以生成类型。</p>
<p>此外，<code>SqliteConnectOptions::new().filename(database_path)</code> 中的 <code>database_path</code> 并不是上面的 <code>DATABASE_URL</code> 那样的格式，而是数据库文件的路径 <code>C:/path/to/db.sqlite</code> 或相对路径 <code>./db.sqlite</code>。试了半天才发现这个坑。</p>
<h3 id="tera-pitfalls"><code>tera</code> 的坑</h3>
<p>我想要实现最终只有一个可执行程序，不需要别的资源文件，因此必须把模板文件编译进编译产物里。<code>tera</code> 的文档里没有内设这个功能，但是可以提供 <code>include_dir!</code> 这个宏配合 <code>tera</code> 的手动加载模板的功能实现。类似这样：</p>
<pre><code class="language-rust">#[cfg(not(debug_assertions))]
<span class="pl-k">static</span> <span class="pl-c1">EMBED_TEMPLATE</span><span class="pl-k">:</span> <span class="pl-en">include_dir</span><span class="pl-k">::</span><span class="pl-en">Dir</span> <span class="pl-k">=</span> <span class="pl-en">include_dir!</span>(<span class="pl-s"><span class="pl-pds">"</span>templates<span class="pl-pds">"</span></span>);

#[cfg(not(debug_assertions))]
<span class="pl-k">pub</span> <span class="pl-k">fn</span> <span class="pl-en">make_template</span>() <span class="pl-k">-></span> <span class="pl-en">Tera</span> {
    <span class="pl-k">let</span> <span class="pl-k">mut</span> <span class="pl-smi">tera</span> <span class="pl-k">=</span> <span class="pl-en">Tera</span><span class="pl-k">::</span><span class="pl-en">default</span>();
    <span class="pl-k">for</span> <span class="pl-smi">file</span> <span class="pl-k">in</span> <span class="pl-c1">EMBED_TEMPLATE</span><span class="pl-k">.</span><span class="pl-en">files</span>() {
        <span class="pl-k">let</span> <span class="pl-smi">name</span> <span class="pl-k">=</span> <span class="pl-k">match</span> <span class="pl-smi">file</span><span class="pl-k">.</span><span class="pl-en">path</span>()<span class="pl-k">.</span><span class="pl-en">to_str</span>() {
            <span class="pl-en">Some</span>(<span class="pl-smi">name</span>) <span class="pl-k">=></span> <span class="pl-smi">name</span>,
            <span class="pl-en">None</span> <span class="pl-k">=></span> <span class="pl-k">continue</span>,
        };
        <span class="pl-k">let</span> <span class="pl-smi">content</span> <span class="pl-k">=</span> <span class="pl-k">match</span> <span class="pl-smi">file</span><span class="pl-k">.</span><span class="pl-en">contents_utf8</span>() {
            <span class="pl-en">Some</span>(<span class="pl-smi">content</span>) <span class="pl-k">=></span> <span class="pl-smi">content</span>,
            <span class="pl-en">None</span> <span class="pl-k">=></span> <span class="pl-k">continue</span>,
        };
        <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Err</span>(<span class="pl-smi">_</span>) <span class="pl-k">=</span> <span class="pl-smi">tera</span><span class="pl-k">.</span><span class="pl-en">add_raw_template</span>(<span class="pl-smi">name</span>, <span class="pl-smi">content</span>) {
            <span class="pl-en">std</span><span class="pl-k">::</span><span class="pl-en">process</span><span class="pl-k">::</span><span class="pl-en">exit</span>(<span class="pl-c1">1</span>);
        }
    }
    <span class="pl-smi">tera</span>
}

#[cfg(debug_assertions)]
<span class="pl-k">pub</span> <span class="pl-k">fn</span> <span class="pl-en">make_template</span>() <span class="pl-k">-></span> <span class="pl-en">Tera</span> {
    <span class="pl-en">Tera</span><span class="pl-k">::</span><span class="pl-en">new</span>(<span class="pl-s"><span class="pl-pds">"</span>templates/**/*<span class="pl-pds">"</span></span>)<span class="pl-k">.</span><span class="pl-en">expect</span>(<span class="pl-s"><span class="pl-pds">"</span>Failed to load templates<span class="pl-pds">"</span></span>)
}
</code></pre>
<p>通过 <code>cfg</code> 宏来判断是否在 debug 模式下，如果是则从文件夹加载模板，否则从编译产物加载模板。兼具了开发和生产的需求。</p>
<h3 id="embed-static-assets">嵌入静态资源</h3>
<p>嵌入静态资源其实和嵌入模板差不多，只是需要让 <code>axum</code> 和 <code>include_dir</code> 配合。通过一个 <code>/static/{*path}</code> 路由来处理静态资源：</p>
<pre><code class="language-rust"><span class="pl-k">static</span> <span class="pl-c1">EMBED_STATIC</span><span class="pl-k">:</span> <span class="pl-en">include_dir</span><span class="pl-k">::</span><span class="pl-en">Dir</span> <span class="pl-k">=</span> <span class="pl-en">include_dir!</span>(<span class="pl-s"><span class="pl-pds">"</span>static<span class="pl-pds">"</span></span>);

<span class="pl-k">pub</span> <span class="pl-k">async</span> <span class="pl-k">fn</span> <span class="pl-en">static_path</span>(<span class="pl-en">Path</span>(<span class="pl-smi">path</span>)<span class="pl-k">:</span> <span class="pl-en">Path</span>&#x3C;<span class="pl-en">String</span>>) <span class="pl-k">-></span> <span class="pl-k">impl</span> <span class="pl-en">IntoResponse</span> {
    <span class="pl-k">let</span> <span class="pl-smi">path</span> <span class="pl-k">=</span> <span class="pl-smi">path</span><span class="pl-k">.</span><span class="pl-en">trim_start_matches</span>(<span class="pl-s">'/'</span>);
    <span class="pl-k">let</span> <span class="pl-smi">mime_type</span> <span class="pl-k">=</span> <span class="pl-en">mime_guess</span><span class="pl-k">::</span><span class="pl-en">from_path</span>(<span class="pl-smi">path</span>)<span class="pl-k">.</span><span class="pl-en">first_or_text_plain</span>();

    <span class="pl-k">match</span> <span class="pl-c1">EMBED_STATIC</span><span class="pl-k">.</span><span class="pl-en">get_file</span>(<span class="pl-smi">path</span>) {
        <span class="pl-en">None</span> <span class="pl-k">=></span> (<span class="pl-en">StatusCode</span><span class="pl-k">::</span><span class="pl-c1">NOT_FOUND</span>, <span class="pl-s"><span class="pl-pds">"</span>Not Found<span class="pl-pds">"</span></span>)<span class="pl-k">.</span><span class="pl-en">into_response</span>(),
        <span class="pl-en">Some</span>(<span class="pl-smi">file</span>) <span class="pl-k">=></span> {
            <span class="pl-k">let</span> <span class="pl-smi">body</span> <span class="pl-k">=</span> <span class="pl-smi">file</span><span class="pl-k">.</span><span class="pl-en">contents</span>();
            <span class="pl-k">let</span> <span class="pl-k">mut</span> <span class="pl-smi">header</span> <span class="pl-k">=</span> <span class="pl-en">HeaderMap</span><span class="pl-k">::</span><span class="pl-en">new</span>();
            <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Ok</span>(<span class="pl-smi">mine_type</span>) <span class="pl-k">=</span> <span class="pl-smi">mime_type</span><span class="pl-k">.</span><span class="pl-en">to_string</span>()<span class="pl-k">.</span><span class="pl-en">parse</span>() {
                <span class="pl-smi">header</span><span class="pl-k">.</span><span class="pl-en">insert</span>(<span class="pl-en">http</span><span class="pl-k">::</span><span class="pl-en">header</span><span class="pl-k">::</span><span class="pl-c1">CONTENT_TYPE</span>, <span class="pl-smi">mine_type</span>);
            }
            #[cfg(not(debug_assertions))]
            <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Ok</span>(<span class="pl-smi">cache_control</span>) <span class="pl-k">=</span> <span class="pl-s"><span class="pl-pds">"</span>public, max-age=86400<span class="pl-pds">"</span></span><span class="pl-k">.</span><span class="pl-en">parse</span>() {
                <span class="pl-smi">header</span><span class="pl-k">.</span><span class="pl-en">insert</span>(<span class="pl-en">http</span><span class="pl-k">::</span><span class="pl-en">header</span><span class="pl-k">::</span><span class="pl-c1">CACHE_CONTROL</span>, <span class="pl-smi">cache_control</span>);
            }
            (<span class="pl-en">StatusCode</span><span class="pl-k">::</span><span class="pl-c1">OK</span>, <span class="pl-smi">header</span>, <span class="pl-smi">body</span>)<span class="pl-k">.</span><span class="pl-en">into_response</span>()
        }
    }
}
</code></pre>
<h3 id="admin-auth">管理后台的身份认证</h3>
<p>管理后台的身份认证我选择使用 <code>HTTP Basic Auth</code>，但是没找到合适的中间件，所幸这个功能并不复杂，也就自己试着写了一个，核心思想就是用 <code>axum</code> 提供的 <code>axum::middleware::from_fn_with_state</code> 简化编写中间件的复杂度，和写 header 其实差不多。</p>
<pre><code class="language-rust">#[derive(<span class="pl-en">Clone</span>)]
<span class="pl-k">pub</span> <span class="pl-k">struct</span> <span class="pl-en">BasicAuthorize</span> {
    <span class="pl-smi">name</span><span class="pl-k">:</span> <span class="pl-en">String</span>,
    <span class="pl-smi">password</span><span class="pl-k">:</span> <span class="pl-en">String</span>,
}

<span class="pl-k">impl</span> <span class="pl-en">BasicAuthorize</span> {
    <span class="pl-k">pub</span> <span class="pl-k">fn</span> <span class="pl-en">new</span>(<span class="pl-smi">name</span><span class="pl-k">:</span> <span class="pl-en">String</span>, <span class="pl-smi">password</span><span class="pl-k">:</span> <span class="pl-en">String</span>) <span class="pl-k">-></span> <span class="pl-en">BasicAuthorize</span> {
        <span class="pl-en">BasicAuthorize</span> { <span class="pl-smi">name</span>, <span class="pl-smi">password</span> }
    }

    <span class="pl-k">pub</span> <span class="pl-k">async</span> <span class="pl-k">fn</span> <span class="pl-en">handler</span>(
        <span class="pl-en">State</span>(<span class="pl-smi">state</span>)<span class="pl-k">:</span> <span class="pl-en">State</span>&#x3C;<span class="pl-en">BasicAuthorize</span>>,
        <span class="pl-smi">request</span><span class="pl-k">:</span> <span class="pl-en">Request</span>,
        <span class="pl-smi">next</span><span class="pl-k">:</span> <span class="pl-en">Next</span>,
    ) <span class="pl-k">-></span> <span class="pl-k">impl</span> <span class="pl-en">IntoResponse</span> {
        <span class="pl-k">let</span> <span class="pl-smi">auth</span> <span class="pl-k">=</span> <span class="pl-smi">request</span><span class="pl-k">.</span><span class="pl-en">headers</span>()<span class="pl-k">.</span><span class="pl-en">get</span>(<span class="pl-s"><span class="pl-pds">"</span>authorization<span class="pl-pds">"</span></span>);
        <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Some</span>(<span class="pl-smi">token</span>) <span class="pl-k">=</span> <span class="pl-smi">auth</span> {
            <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Ok</span>(<span class="pl-smi">token</span>) <span class="pl-k">=</span> <span class="pl-smi">token</span><span class="pl-k">.</span><span class="pl-en">to_str</span>() {
                <span class="pl-k">if</span> <span class="pl-smi">token</span><span class="pl-k">.</span><span class="pl-en">starts_with</span>(<span class="pl-s"><span class="pl-pds">"</span>Basic <span class="pl-pds">"</span></span>) {
                    <span class="pl-k">let</span> <span class="pl-smi">token</span> <span class="pl-k">=</span> <span class="pl-smi">token</span><span class="pl-k">.</span><span class="pl-en">trim_start_matches</span>(<span class="pl-s"><span class="pl-pds">"</span>Basic <span class="pl-pds">"</span></span>);
                    <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Ok</span>(<span class="pl-smi">token</span>) <span class="pl-k">=</span> <span class="pl-en">base64</span><span class="pl-k">::</span><span class="pl-en">prelude</span><span class="pl-k">::</span><span class="pl-c1">BASE64_STANDARD</span><span class="pl-k">.</span><span class="pl-en">decode</span>(<span class="pl-smi">token</span><span class="pl-k">.</span><span class="pl-en">as_bytes</span>()) {
                        <span class="pl-k">if</span> <span class="pl-k">let</span> <span class="pl-en">Ok</span>(<span class="pl-smi">token</span>) <span class="pl-k">=</span> <span class="pl-en">String</span><span class="pl-k">::</span><span class="pl-en">from_utf8</span>(<span class="pl-smi">token</span>) {
                            <span class="pl-k">let</span> <span class="pl-smi">parts</span><span class="pl-k">:</span> <span class="pl-en">Vec</span>&#x3C;<span class="pl-k">&#x26;</span><span class="pl-en">str</span>> <span class="pl-k">=</span> <span class="pl-smi">token</span><span class="pl-k">.</span><span class="pl-en">splitn</span>(<span class="pl-c1">2</span>, <span class="pl-s">':'</span>)<span class="pl-k">.</span><span class="pl-en">collect</span>();
                            <span class="pl-k">if</span> <span class="pl-smi">parts</span><span class="pl-k">.</span><span class="pl-en">len</span>() <span class="pl-k">==</span> <span class="pl-c1">2</span> {
                                <span class="pl-k">if</span> <span class="pl-smi">parts</span>[<span class="pl-c1">0</span>] <span class="pl-k">==</span> <span class="pl-smi">state</span><span class="pl-k">.</span>name <span class="pl-k">&#x26;&#x26;</span> <span class="pl-smi">parts</span>[<span class="pl-c1">1</span>] <span class="pl-k">==</span> <span class="pl-smi">state</span><span class="pl-k">.</span>password {
                                    <span class="pl-k">return</span> <span class="pl-smi">next</span><span class="pl-k">.</span><span class="pl-en">run</span>(<span class="pl-smi">request</span>)<span class="pl-k">.await</span>;
                                }
                            }
                        }
                    }
                }
            }
        }

        <span class="pl-k">let</span> <span class="pl-k">mut</span> <span class="pl-smi">headers</span> <span class="pl-k">=</span> <span class="pl-en">HeaderMap</span><span class="pl-k">::</span><span class="pl-en">new</span>();
        <span class="pl-smi">headers</span><span class="pl-k">.</span><span class="pl-en">insert</span>(<span class="pl-s"><span class="pl-pds">"</span>WWW-Authenticate<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>Basic<span class="pl-pds">"</span></span><span class="pl-k">.</span><span class="pl-en">parse</span>()<span class="pl-k">.</span><span class="pl-en">unwrap</span>());
        (<span class="pl-en">http</span><span class="pl-k">::</span><span class="pl-en">StatusCode</span><span class="pl-k">::</span><span class="pl-c1">UNAUTHORIZED</span>, <span class="pl-smi">headers</span>, <span class="pl-s"><span class="pl-pds">"</span>Unauthorized<span class="pl-pds">"</span></span>)<span class="pl-k">.</span><span class="pl-en">into_response</span>()
    }
}
</code></pre>
<p>然后和通过 <code>axum::middleware::from_fn_with_state</code> 注册这个中间件：</p>
<pre><code class="language-rust"><span class="pl-k">let</span> <span class="pl-smi">auth</span> <span class="pl-k">=</span> <span class="pl-en">auth</span><span class="pl-k">::</span><span class="pl-en">BasicAuthorize</span><span class="pl-k">::</span><span class="pl-en">new</span>(<span class="pl-smi">admin_username</span>, <span class="pl-smi">admin_password</span>);
<span class="pl-k">let</span> <span class="pl-smi">private_api</span> <span class="pl-k">=</span> <span class="pl-en">Router</span><span class="pl-k">::</span><span class="pl-en">new</span>()
    <span class="pl-k">.</span><span class="pl-en">nest</span>(<span class="pl-s"><span class="pl-pds">"</span>/api/admin<span class="pl-pds">"</span></span>, <span class="pl-en">admin</span><span class="pl-k">::</span><span class="pl-en">admin_routes</span>())
    <span class="pl-k">.</span><span class="pl-en">layer</span>(<span class="pl-en">axum</span><span class="pl-k">::</span><span class="pl-en">middleware</span><span class="pl-k">::</span><span class="pl-en">from_fn_with_state</span>(<span class="pl-smi">auth</span><span class="pl-k">.</span><span class="pl-en">clone</span>(), <span class="pl-en">auth</span><span class="pl-k">::</span><span class="pl-en">BasicAuthorize</span><span class="pl-k">::</span><span class="pl-smi">handler</span>));
</code></pre>
<p>还是挺直观的。</p>
<h2 id="conclusion">总结</h2>
<p>首先讲讲个人感受，一句话：Rust 的学习难度不过如此嘛~。从0开始写一个简单的服务我只用了 2 天（业余时间）。</p>
<p>可以看出，Rust 确实融合了很多语言的特性，但这都不是事，因为：</p>
<ul>
<li>Rust 的所有权说的复杂，其实和 C++ 的智能指针差不多，只是 Rust 是强制性的。由于之前写过 V8 的拓展，对 RAII 也有些熟悉，所以这个没什么难度，只是需要适应思维的转变。</li>
<li>Rust 的异步和 Kotlin 的协程差不多，看两眼就猜的出来。恰巧之前大量用过 Kotlin Coroutine，这一点基本没废什么时间。</li>
<li>Rust 的模式匹配和函数式编程，但我写过 Haskell，满满的熟悉感。</li>
<li>Rust 的 <code>impl</code> 和 <code>trait</code> 说实话让我想到了 Kotlin 的 Extension Function，只是 Rust 用 <code>trait</code> 替代了 <code>interface</code>。此外这和 Golang 的 <code>interface</code> 也有点像。我本身也是不太喜欢完全的 OOP，所以 Rust 的这种设计我还挺喜欢的。</li>
</ul>
<p>总的来说，这次写这个服务还是挺顺利的，Rust 的文档和社区都很好，遇到问题基本上都能在网上找到答案。但是 Rust 的生态还是不够完善，很多库都是半成品，需要自己去糊在一起，这一点和 Node.js 的生态差距还是很大的。</p>
<p>通过这次对 comment-server 的重构，内存占用从 200MB+55MB 降到了 10MB，可以说是非常成功了。而且 Rust 的编译产物也非常小，只有 17MB （在内嵌模板和静态资源的情况下）。</p>
<blockquote>
<p>偏点题，评论系统的前端使用 <code>React</code> + <code>Material-UI</code>。管理面板使用前面提到的 <code>Tera</code> 模板渲染，使用 <code>TailwindCSS</code> + <code>daisyui</code>。</p>
<p>以前一直觉得 React 更好，这次发现拿模板渲染真的快，4 个页面一小时就写好了。</p>
<p>通知系统用的是 TelegramBot ，简单方便。后续计划实现基于 Email 的回复通知。</p>
</blockquote>
<p>【完】感谢阅读！</p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用 Docker 部署 PeerTube]]></title>
        <id>https://blog.skyone.dev/2024/docker-peertube/</id>
        <link href="https://blog.skyone.dev/2024/docker-peertube/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/5a303396_8c3eb1be.webp" type="image/webp"/>
        <updated>2024-12-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[使用 Docker 部署 PeerTube。PeerTube 是一个自由开源的去中心化视频分享平台，它使用 WebTorrent 技术来实现 P2P 视频流传输。本来想用 PeerTube 来搭建一个视频分享平台，但是发现 PeerTube 的 Docker 部署文档有很多缺失的细节，所以写了这篇文章来记录一下 PeerTube 的 Docker 部署过程。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2024/docker-peertube/">阅读原文</a>以获取最佳阅读体验。</p><p>PeerTube 是一个自由开源的去中心化视频分享平台，它使用 WebTorrent 技术来实现 P2P 视频流传输。本来想用 PeerTube 来搭建一个视频分享平台，但是发现 PeerTube 的 Docker 部署文档有很多缺失的细节，所以写了这篇文章来记录一下 PeerTube 的 Docker 部署过程。</p>
<p>我已经搭建好了一个示例，大家可用访问 <a href="https://video.akk.moe">https://video.akk.moe</a> 查看效果。</p>
<!-- readmore -->
<p>再贴一个 PeerTube 的嵌入式标签：</p>
<iframe src="https://video.akk.moe/videos/embed/919c749a-6b91-4947-bc7c-0d91b7b3e946" title="Suger Rush" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"></iframe>
<h2 id="docker-compose">Docker Compose</h2>
<p>我的 Docker Compose 文件是根据官方的 <a href="https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/docker-compose.yml">docker-compose.yml</a> 修改而来的，官方的配置非常不实用
包含了很多不必要的配置，比如官方自定义了一个 nginx 容器，实在没必要；而且还用到了 PostFix 邮箱服务，一般人都不会用到。</p>
<p>下面是我的 Docker Compose 文件：</p>
<pre><code class="language-yaml"><span class="pl-ent">services</span>:
  <span class="pl-ent">nginx</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">peertube-nginx</span>
    <span class="pl-ent">image</span>: <span class="pl-s">nginx:alpine</span>
    <span class="pl-ent">restart</span>: <span class="pl-s"><span class="pl-pds">"</span>unless-stopped<span class="pl-pds">"</span></span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">proxy</span>
      - <span class="pl-s">peertube</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>./nginx:/etc/nginx/conf.d<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>./logs:/var/log/nginx<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>./data:/var/www/peertube/storage<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>assets:/var/www/peertube/peertube-latest/client/dist:ro<span class="pl-pds">"</span></span>
    <span class="pl-ent">labels</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.enable=true<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.docker.network=proxy<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.peertube.rule=Host(`video.example.com`)<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.routers.peertube.entrypoints=web<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>traefik.http.services.peertube.loadbalancer.server.port=9000<span class="pl-pds">"</span></span>

  <span class="pl-ent">peertube</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">peertube</span>
    <span class="pl-ent">image</span>: <span class="pl-s">chocobozzz/peertube:production-bookworm</span>
    <span class="pl-ent">restart</span>: <span class="pl-s"><span class="pl-pds">"</span>unless-stopped<span class="pl-pds">"</span></span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">peertube</span>
    <span class="pl-ent">env_file</span>:
      - <span class="pl-s">docker.env</span>
    <span class="pl-c">#ports:</span>
    <span class="pl-c">#  - "1935:1935" # Comment if you don't want to use the live feature</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>assets:/app/client/dist<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>./data:/data<span class="pl-pds">"</span></span>
      - <span class="pl-s"><span class="pl-pds">"</span>./config:/config<span class="pl-pds">"</span></span>
    <span class="pl-ent">depends_on</span>:
      - <span class="pl-s">database</span>
      - <span class="pl-s">redis</span>

  <span class="pl-ent">database</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">peertube-database</span>
    <span class="pl-ent">image</span>: <span class="pl-s">postgres:13-alpine</span>
    <span class="pl-ent">restart</span>: <span class="pl-s"><span class="pl-pds">"</span>unless-stopped<span class="pl-pds">"</span></span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">peertube</span>
    <span class="pl-ent">env_file</span>:
      - <span class="pl-s">docker.env</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>./database:/var/lib/postgresql/data<span class="pl-pds">"</span></span>

  <span class="pl-ent">redis</span>:
    <span class="pl-ent">container_name</span>: <span class="pl-s">peertube-redis</span>
    <span class="pl-ent">image</span>: <span class="pl-s">redis:6-alpine</span>
    <span class="pl-ent">restart</span>: <span class="pl-s"><span class="pl-pds">"</span>unless-stopped<span class="pl-pds">"</span></span>
    <span class="pl-ent">networks</span>:
      - <span class="pl-s">peertube</span>
    <span class="pl-ent">volumes</span>:
      - <span class="pl-s"><span class="pl-pds">"</span>./redis:/data<span class="pl-pds">"</span></span>

<span class="pl-ent">networks</span>:
  <span class="pl-ent">proxy</span>:
    <span class="pl-ent">external</span>: <span class="pl-c1">true</span>
  <span class="pl-ent">peertube</span>:
    <span class="pl-ent">name</span>: <span class="pl-s">peertube</span>

<span class="pl-ent">volumes</span>:
  <span class="pl-ent">assets</span>:
    <span class="pl-ent">name</span>: <span class="pl-s">peertube-assets</span>
</code></pre>
<p>主要修改了：</p>
<ul>
<li>使用 nginx 容器代理，不再使用官方的 chocobozzz/peertube-webserver 容器，这样可用方便自定义 nginx 配置。</li>
<li>去掉了 PostFix 邮箱服务，直接使用外部 SMTP 服务。</li>
<li>去掉了 certbot 容器，直接使用 Traefik 的 Let's Encrypt 功能。</li>
<li>使用了 Traefik 代理，具体请看<a href="https://blog.skyone.dev/2023/traefik-docker-gateway">使用 Traefik 作为 Docker 的反向代理</a>，当然你也可以使用其他代理。</li>
</ul>
<p>要使用的话，只需要将 <code>video.example.com</code> 替换成你的域名。</p>
<p>下面是对应的 <code>docker.env</code> 文件：</p>
<pre><code class="language-ini"><span class="pl-c"># Postgres configuration</span>
<span class="pl-k">POSTGRES_USER</span>=peertube
<span class="pl-k">POSTGRES_PASSWORD</span>=
<span class="pl-k">POSTGRES_DB</span>=peertube

<span class="pl-c"># PeerTube configuration</span>
<span class="pl-k">PEERTUBE_DB_NAME</span>=${POSTGRES_DB}
<span class="pl-k">PEERTUBE_DB_USERNAME</span>=${POSTGRES_USER}
<span class="pl-k">PEERTUBE_DB_PASSWORD</span>=${POSTGRES_PASSWORD}
<span class="pl-k">PEERTUBE_DB_HOSTNAME</span>=peertube-database
<span class="pl-k">PEERTUBE_REDIS_HOSTNAME</span>=peertube-redis

<span class="pl-k">PEERTUBE_WEBSERVER_HOSTNAME</span>=
<span class="pl-k">PEERTUBE_TRUST_PROXY</span>=[<span class="pl-s"><span class="pl-pds">"</span>127.0.0.1/8<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>loopback<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>10.0.0.0/8<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>172.16.0.0/12<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>192.168.0.0/16<span class="pl-pds">"</span></span>]

<span class="pl-k">PEERTUBE_SECRET</span>=

<span class="pl-c"># E-mail configuration</span>
<span class="pl-k">PEERTUBE_SMTP_USERNAME</span>=
<span class="pl-k">PEERTUBE_SMTP_PASSWORD</span>=
<span class="pl-k">PEERTUBE_SMTP_HOSTNAME</span>=smtp.office365.com
<span class="pl-k">PEERTUBE_SMTP_PORT</span>=587
<span class="pl-k">PEERTUBE_SMTP_FROM</span>=
<span class="pl-k">PEERTUBE_SMTP_TLS</span>=false
<span class="pl-k">PEERTUBE_SMTP_DISABLE_STARTTLS</span>=false
<span class="pl-k">PEERTUBE_ADMIN_EMAIL</span>=

<span class="pl-k">PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC</span>=<span class="pl-s"><span class="pl-pds">"</span>public-read<span class="pl-pds">"</span></span>
<span class="pl-k">PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE</span>=<span class="pl-s"><span class="pl-pds">"</span>private<span class="pl-pds">"</span></span>
<span class="pl-k">PEERTUBE_WEBSERVER_HTTPS</span>=true
<span class="pl-c">#PEERTUBE_LOG_LEVEL=info</span>
</code></pre>
<p>要修改的项目有：</p>
<ul>
<li><code>POSTGRES_PASSWORD</code>：Postgres 数据库密码。</li>
<li><code>PEERTUBE_SECRET</code>：PeerTube 密钥，可以使用 <code>openssl rand -hex 32</code> 生成。</li>
<li>一些 SMTP 邮箱配置。</li>
<li><code>PEERTUBE_TRUST_PROXY</code>：可信任的代理 IP 地址，需要添加你的 docker daemon 的地址池，一般我给出的这个就够用了。</li>
<li><code>PEERTUBE_WEBSERVER_HOSTNAME</code>：PeerTube 的域名。</li>
<li><code>PEERTUBE_ADMIN_EMAIL</code>：管理员邮箱。PeerTube 会自动创建一个名为 <code>root</code> 的账户，使用此邮箱，一般不建议使用这个账户传视频，因此此邮箱可以随便填（只要邮箱的域名不会被有心人控制）。</li>
</ul>
<p>这里默认你将 <code>docker.env</code> 和 <code>docker-compose.yml</code> 放在了 <code>/app/path</code> 目录下。</p>
<h2 id="nginx-配置">Nginx 配置</h2>
<p>Nginx 的配置同样是从官方的 chocobozzz/peertube-webserver 中抄来的，做了如下修改：</p>
<ul>
<li>去掉了 certbot 相关的 SSL 证书配置。</li>
<li>监听 <code>9000</code> 端口，由于专门用来代理 PeerTube，所以不需要填 server_name。</li>
</ul>
<pre><code class="language-nginx"><span class="pl-c1">upstream</span> backend <span class="pl-c1">{</span>
    <span class="pl-c1">server</span> peertube:<span class="pl-c1">9000;</span>
<span class="pl-c1">}</span>

<span class="pl-c1">server</span> <span class="pl-c1">{</span>
    <span class="pl-c1">listen</span> <span class="pl-c1">9000;</span>

    <span class="pl-c1">access_log</span> /var/log/nginx/peertube.access.log<span class="pl-c1">;</span> # reduce I/0 with buffer=<span class="pl-c1">10m</span> flush=<span class="pl-c1">5m</span>
    <span class="pl-c1">error_log</span>  /var/log/nginx/peertube.error.log<span class="pl-c1">;</span>

<span class="pl-c">    ##</span>
<span class="pl-c">    # Application</span>
<span class="pl-c">    ##</span>

    <span class="pl-c1">location</span> @api <span class="pl-c1">{</span>
        <span class="pl-c1">proxy_set_header</span> X-Forwarded-For   <span class="pl-v">$proxy_add_x_forwarded_for</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span> Host              <span class="pl-v">$host</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span> X-Real-IP         <span class="pl-v">$remote_addr</span><span class="pl-c1">;</span>

        <span class="pl-c1">client_max_body_size</span>  <span class="pl-c1">100k;</span> # default is <span class="pl-c1">1M</span>

        <span class="pl-c1">proxy_connect_timeout</span> <span class="pl-c1">10m;</span>
        <span class="pl-c1">proxy_send_timeout</span>    <span class="pl-c1">10m;</span>
        <span class="pl-c1">proxy_read_timeout</span>    <span class="pl-c1">10m;</span>
        <span class="pl-c1">send_timeout</span>          <span class="pl-c1">10m;</span>

        <span class="pl-c1">proxy_pass</span> <span class="pl-c1">http</span>://backend<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> / <span class="pl-c1">{</span>
        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/api/v1/videos/<span class="pl-c1">(</span>upload-resumable|<span class="pl-c1">(</span>[^/]+/source/replace-resumable<span class="pl-c1">))</span>$ <span class="pl-c1">{</span>
        <span class="pl-c1">client_max_body_size</span>    0<span class="pl-c1">;</span>
        <span class="pl-c1">proxy_request_buffering</span> off<span class="pl-c1">;</span>

        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/api/v1/users/[^/]+/imports/import-resumable$ <span class="pl-c1">{</span>
        <span class="pl-c1">client_max_body_size</span>    0<span class="pl-c1">;</span>
        <span class="pl-c1">proxy_request_buffering</span> off<span class="pl-c1">;</span>

        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/api/v1/videos/<span class="pl-c1">(</span>upload|<span class="pl-c1">(</span>[^/]+/studio/edit<span class="pl-c1">))</span>$ <span class="pl-c1">{</span>
        <span class="pl-c1">limit_except</span> POST HEAD <span class="pl-c1">{</span> <span class="pl-c1">deny</span> all<span class="pl-c1">;</span> <span class="pl-c1">}</span>

<span class="pl-c">        # This is the maximum upload size, which roughly matches the maximum size of a video file.</span>
<span class="pl-c">        # Note that temporary space is needed equal to the total size of all concurrent uploads.</span>
<span class="pl-c">        # This data gets stored in /var/lib/nginx by default, so you may want to put this directory</span>
<span class="pl-c">        # on a dedicated filesystem.</span>
        <span class="pl-c1">client_max_body_size</span>                      <span class="pl-c1">12G;</span> # default is <span class="pl-c1">1M</span>
        <span class="pl-c1">add_header</span>            X-File-Maximum-Size <span class="pl-c1">8G</span> always<span class="pl-c1">;</span> # inform backend of the <span class="pl-c1">set</span> value in bytes before mime-encoding <span class="pl-c1">(</span>x * 1.4 >= <span class="pl-c1">client_max_body_size)</span>

        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/api/v1/runners/jobs/[^/]+/<span class="pl-c1">(</span>update|success<span class="pl-c1">)</span>$ <span class="pl-c1">{</span>
        <span class="pl-c1">client_max_body_size</span>                      <span class="pl-c1">12G;</span> # default is <span class="pl-c1">1M</span>
        <span class="pl-c1">add_header</span>            X-File-Maximum-Size <span class="pl-c1">8G</span> always<span class="pl-c1">;</span> # inform backend of the <span class="pl-c1">set</span> value in bytes before mime-encoding <span class="pl-c1">(</span>x * 1.4 >= <span class="pl-c1">client_max_body_size)</span>

        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/api/v1/<span class="pl-c1">(</span>videos|video-playlists|video-channels|users/me<span class="pl-c1">)</span> <span class="pl-c1">{</span>
        <span class="pl-c1">client_max_body_size</span>                      <span class="pl-c1">6M;</span> # default is <span class="pl-c1">1M</span>
        <span class="pl-c1">add_header</span>            X-File-Maximum-Size <span class="pl-c1">4M</span> always<span class="pl-c1">;</span> # inform backend of the <span class="pl-c1">set</span> value in bytes before mime-encoding <span class="pl-c1">(</span>x * 1.4 >= <span class="pl-c1">client_max_body_size)</span>

        <span class="pl-c1">try_files</span> /dev/null @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

<span class="pl-c">    ##</span>
<span class="pl-c">    # Websocket</span>
<span class="pl-c">    ##</span>

    <span class="pl-c1">location</span> @api_websocket <span class="pl-c1">{</span>
        <span class="pl-c1">proxy_http_version</span> 1.1<span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span>   X-Forwarded-For   <span class="pl-v">$proxy_add_x_forwarded_for</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span>   Host              <span class="pl-v">$host</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span>   X-Real-IP         <span class="pl-v">$remote_addr</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span>   Upgrade           <span class="pl-v">$http_upgrade</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span>   Connection        <span class="pl-s">"upgrade"</span><span class="pl-c1">;</span>

        <span class="pl-c1">proxy_pass</span> <span class="pl-c1">http</span>://backend<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> /socket.io <span class="pl-c1">{</span>
        <span class="pl-c1">try_files</span> /dev/null @api_websocket<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> /tracker/socket <span class="pl-c1">{</span>
<span class="pl-c">        # Peers send a message to the tracker every 15 minutes</span>
<span class="pl-c">        # Don't close the websocket before then</span>
        <span class="pl-c1">proxy_read_timeout</span> <span class="pl-c1">15m;</span> # default is <span class="pl-c1">60s</span>

        <span class="pl-c1">try_files</span> /dev/null @api_websocket<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

<span class="pl-c">    # Plugin websocket routes</span>
    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/plugins/[^/]+<span class="pl-c1">(</span>/[^/]+<span class="pl-c1">)</span>?/ws/ <span class="pl-c1">{</span>
        <span class="pl-c1">try_files</span> /dev/null @api_websocket<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

<span class="pl-c">    ##</span>
<span class="pl-c">    # Performance optimizations</span>
<span class="pl-c">    # For extra performance please refer to https://github.com/denji/nginx-tuning</span>
<span class="pl-c">    ##</span>

    <span class="pl-c1">root</span> /var/www/peertube/storage<span class="pl-c1">;</span>

<span class="pl-c">    # Enable compression for JS/CSS/HTML, for improved client load times.</span>
<span class="pl-c">    # It might be nice to compress JSON/XML as returned by the API, but</span>
<span class="pl-c">    # leaving that out to protect against potential BREACH attack.</span>
    <span class="pl-c1">gzip</span>              on<span class="pl-c1">;</span>
    <span class="pl-c1">gzip_vary</span>         on<span class="pl-c1">;</span>
    <span class="pl-c1">gzip_types</span>        # text/html is always compressed by HttpGzipModule
                      text/css
                      application/javascript
                      font/truetype
                      font/opentype
                      application/vnd.ms-fontobject
                      image/svg+xml<span class="pl-c1">;</span>
    <span class="pl-c1">gzip_min_length</span>   <span class="pl-c1">1000;</span> # default is <span class="pl-c1">20</span> bytes
    <span class="pl-c1">gzip_buffers</span>      <span class="pl-c1">16</span> <span class="pl-c1">8k;</span>
    <span class="pl-c1">gzip_comp_level</span>   2<span class="pl-c1">;</span> # default is 1

    <span class="pl-c1">client_body_timeout</span>       <span class="pl-c1">30s;</span> # default is <span class="pl-c1">60</span>
    <span class="pl-c1">client_header_timeout</span>     <span class="pl-c1">10s;</span> # default is <span class="pl-c1">60</span>
    <span class="pl-c1">send_timeout</span>              <span class="pl-c1">10s;</span> # default is <span class="pl-c1">60</span>
    <span class="pl-c1">keepalive_timeout</span>         <span class="pl-c1">10s;</span> # default is <span class="pl-c1">75</span>
    <span class="pl-c1">resolver_timeout</span>          <span class="pl-c1">10s;</span> # default is <span class="pl-c1">30</span>
    <span class="pl-c1">reset_timedout_connection</span> on<span class="pl-c1">;</span>
    <span class="pl-c1">proxy_ignore_client_abort</span> on<span class="pl-c1">;</span>

    <span class="pl-c1">tcp_nopush</span>                on<span class="pl-c1">;</span> # send headers in one piece
    <span class="pl-c1">tcp_nodelay</span>               on<span class="pl-c1">;</span> # don't buffer data sent, good <span class="pl-k">for</span> small data bursts in real time

<span class="pl-c">    # If you have a small /var/lib partition, it could be interesting to store temp nginx uploads in a different place</span>
<span class="pl-c">    # See https://nginx.org/en/docs/http/ngx_http_core_module.html#client_body_temp_path</span>
<span class="pl-c">    #client_body_temp_path /var/www/peertube/storage/nginx/;</span>

<span class="pl-c">    # Bypass PeerTube for performance reasons. Optional.</span>
<span class="pl-c">    # Should be consistent with client-overrides assets list in client.ts server controller</span>
    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/client/<span class="pl-c1">(</span>assets/images/<span class="pl-c1">(</span>icons/icon-<span class="pl-c1">36x36</span><span class="pl-s"><span class="pl-sr">\.png|icons/icon-48x48\.png|icons/icon-72x72\.png|icons/icon-96x96\.png|icons/icon-144x144\.png|icons/icon-192x192\.png|icons/icon-512x512\.png|logo\.svg|favicon\.png|default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$ </span></span><span class="pl-c1">{</span>
        <span class="pl-c1">add_header</span> Cache-Control <span class="pl-s">"public, max-age=31536000, immutable"</span><span class="pl-c1">;</span> # Cache 1 year

        <span class="pl-c1">root</span> /var/www/peertube<span class="pl-c1">;</span>

        <span class="pl-c1">try_files</span> /storage/client-overrides/<span class="pl-v">$1</span> /peertube-latest/client/dist/<span class="pl-v">$1</span> @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

<span class="pl-c">    # Bypass PeerTube for performance reasons. Optional.</span>
    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/client/<span class="pl-c1">(</span>.*<span class="pl-s"><span class="pl-sr">\.(js|css|png|svg|woff2|otf|ttf|woff|eot))$ </span></span><span class="pl-c1">{</span>
        <span class="pl-c1">add_header</span> Cache-Control <span class="pl-s">"public, max-age=31536000, immutable"</span><span class="pl-c1">;</span> # Cache 1 year

        <span class="pl-c1">alias</span> /var/www/peertube/peertube-latest/client/dist/<span class="pl-v">$1</span><span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^<span class="pl-c1">(</span>/static/<span class="pl-c1">(</span>webseed|web-videos|streaming-playlists/<span class="pl-c1">hls)</span>/private/<span class="pl-c1">)</span>|^/download <span class="pl-c1">{</span>
<span class="pl-c">        # We can't rate limit a try_files directive, so we need to duplicate @api</span>

        <span class="pl-c1">proxy_set_header</span> X-Forwarded-For   <span class="pl-v">$proxy_add_x_forwarded_for</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span> Host              <span class="pl-v">$host</span><span class="pl-c1">;</span>
        <span class="pl-c1">proxy_set_header</span> X-Real-IP         <span class="pl-v">$remote_addr</span><span class="pl-c1">;</span>

        <span class="pl-c1">proxy_limit_rate</span> <span class="pl-c1">5M;</span>

        <span class="pl-c1">proxy_pass</span> <span class="pl-c1">http</span>://backend<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>

<span class="pl-c">    # Bypass PeerTube for performance reasons. Optional.</span>
    <span class="pl-c1">location</span> <span class="pl-s"><span class="pl-sr">~</span></span> ^/static/<span class="pl-c1">(</span>webseed|web-videos|redundancy|streaming-playlists<span class="pl-c1">)</span>/ <span class="pl-c1">{</span>
        <span class="pl-c1">limit_rate_after</span>            <span class="pl-c1">5M;</span>

        <span class="pl-c1">set</span> <span class="pl-v">$peertube_limit_rate</span>  <span class="pl-c1">5M;</span>

<span class="pl-c">        # Use this line with nginx >= 1.17.0</span>
        <span class="pl-c1">limit_rate</span> <span class="pl-v">$peertube_limit_rate</span><span class="pl-c1">;</span>
<span class="pl-c">        # Or this line with nginx &#x3C; 1.17.0</span>
<span class="pl-c">        # set $limit_rate $peertube_limit_rate;</span>

        <span class="pl-k">if</span> <span class="pl-c1">(</span><span class="pl-v">$request_method</span> = <span class="pl-s">'OPTIONS'</span><span class="pl-c1">)</span> <span class="pl-c1">{</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Origin  <span class="pl-s">'*'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Methods <span class="pl-s">'GET, OPTIONS'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Headers <span class="pl-s">'Range,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Access-Control-Max-Age       <span class="pl-c1">1728000;</span> # Preflight request can be cached <span class="pl-c1">20</span> days
          <span class="pl-c1">add_header</span> Content-Type                 <span class="pl-s">'text/plain charset=UTF-8'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Content-Length               0<span class="pl-c1">;</span>
          <span class="pl-k">return</span> <span class="pl-c1">204;</span>
        <span class="pl-c1">}</span>

        <span class="pl-k">if</span> <span class="pl-c1">(</span><span class="pl-v">$request_method</span> = <span class="pl-s">'GET'</span><span class="pl-c1">)</span> <span class="pl-c1">{</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Origin  <span class="pl-s">'*'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Methods <span class="pl-s">'GET, OPTIONS'</span><span class="pl-c1">;</span>
          <span class="pl-c1">add_header</span> Access-Control-Allow-Headers <span class="pl-s">'Range,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'</span><span class="pl-c1">;</span>
        <span class="pl-c1">}</span>

<span class="pl-c">        # Enabling the sendfile directive eliminates the step of copying the data into the buffer</span>
<span class="pl-c">        # and enables direct copying data from one file descriptor to another.</span>
        <span class="pl-c1">sendfile</span> on<span class="pl-c1">;</span>
        <span class="pl-c1">sendfile_max_chunk</span> <span class="pl-c1">1M;</span> # prevent one fast connection from entirely occupying the worker process. should be > <span class="pl-c1">800k</span>.
        <span class="pl-c1">aio</span> threads<span class="pl-c1">;</span>

<span class="pl-c">        # web-videos is the name of the directory mapped to the `storage.web_videos` key in your PeerTube configuration</span>
        <span class="pl-c1">rewrite</span> ^/static/webseed/<span class="pl-c1">(</span>.*<span class="pl-c1">)</span>$ /web-videos/<span class="pl-v">$1</span> <span class="pl-c1">break;</span>
        <span class="pl-c1">rewrite</span> ^/static/<span class="pl-c1">(</span>.*<span class="pl-c1">)</span>$         /<span class="pl-v">$1</span>        <span class="pl-c1">break;</span>

        <span class="pl-c1">try_files</span> <span class="pl-v">$uri</span> @api<span class="pl-c1">;</span>
    <span class="pl-c1">}</span>
<span class="pl-c1">}</span>
</code></pre>
<p>一个字都不用改，所以直接复制粘贴即可，放到 <code>/app/path/nginx/peertube.conf</code>。</p>
<h2 id="登录-peertube-网页">登录 PeerTube 网页</h2>
<p>上面的配置弄完直接 <code>docker-compose up -d</code> ，这里不多说，下面讲讲怎么使用。</p>
<p>访问你的 PeerTube 网页，PeerTube 在首次启动时会自动创建一个名为 <code>root</code> 的账户，密码通过 <code>docker logs peertube | head -n 50</code> 可用找到，该账户的邮箱是你在 <code>docker.env</code> 中填的 <code>PEERTUBE_ADMIN_EMAIL</code>。</p>
<p>先用这个账户登录，在设置中修改密码，然后创建一个新账户，这个账户就是你的主账户，可以上传视频，记得将这个账户设置为管理员。</p>
<p>然后就可以退出 <code>root</code> 账户，登上新创建的账户，上传头像、设置 banner ...... 等的装饰性操作。</p>
<p>再创建一个频道，PeerTube 的视频是以频道为容器的，所以你需要先创建一个频道，然后再上传视频。</p>
<p>主页面可用使用 markdown 编写，下面是我写的一个例子，只需要将网站名字替换一下即可：</p>
<pre><code class="language-markdown">本网站使用使用 PeerTube 搭建的个人测试视频站，名为 <span class="pl-s">**</span>XXXTube<span class="pl-s">**</span>。

请注意，使用此网站即代表您同意 <span class="pl-s">[</span>XXXTube 隐私政策<span class="pl-s">](<span class="pl-corl">/about/peertube</span>)</span>。

This website is a personal test video site powered by PeerTube, called <span class="pl-s">**</span>AkkTube<span class="pl-s">**</span>.

Please note that by using this site you agree to the <span class="pl-s">[</span>XXXTube Privacy Policy<span class="pl-s">](<span class="pl-corl">/about/peertube</span>)</span>.

&#x3C;<span class="pl-ent">peertube-container</span> <span class="pl-e">data-layout</span>=<span class="pl-s">"col"</span> <span class="pl-e">data-title</span>=<span class="pl-s">"本地视频"</span> <span class="pl-e">data-description</span>=<span class="pl-s">"只包含在本网站上传的视频"</span>>
  &#x3C;<span class="pl-ent">peertube-videos-list</span> <span class="pl-e">data-count</span>=<span class="pl-s">"10"</span> <span class="pl-e">data-only-local</span>=<span class="pl-s">"true"</span> <span class="pl-e">data-max-rows</span>=<span class="pl-s">"4"</span>>&#x3C;/<span class="pl-ent">peertube-videos-list</span>>
&#x3C;/<span class="pl-ent">peertube-container</span>>
</code></pre>
<p><img src="https://blog.skyone.dev/_next/static/media/2b291c9e_896729c4.webp" alt="Example 01" width="1016" height="608" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/2b291c9e_896729c4.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/2b291c9e_896729c4.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/2b291c9e_896729c4.png&#x22;}]"></p>
<p>看起来不错~</p>
<p>【完】</p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[Next.js App Router 经验总结]]></title>
        <id>https://blog.skyone.dev/2024/nextjs-app-router/</id>
        <link href="https://blog.skyone.dev/2024/nextjs-app-router/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/190ae667_ef5c515a.webp" type="image/webp"/>
        <updated>2024-10-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Next.js App Router 经验总结。写了很多 Next.js 的代码，总结了一下 Server Component 和 Client Component 的规律，希望对后来者有用。如果有错误，欢迎大佬指正！其中包含了如何区分 Server Component 和 Client Component，正确使用 Server Component，正确使用 Browser API，相互调用，以及 Server/Client Component 的使用。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2024/nextjs-app-router/">阅读原文</a>以获取最佳阅读体验。</p><p>写了很多 Next.js 的代码，总结了一下 Server Component 和 Client Component 的规律，希望对后来者有用。如果有错误，欢迎大佬指正！</p>
<!-- readmore -->
<h2 id="如何区分client-component">如何区分Client Component？</h2>
<p>我总结了以下规律，可以快速区分一个组件是否为服务端组件：</p>
<ol>
<li>默认导出（<code>export default</code>）为 <code>async</code> 函数的组件为 Server Component。</li>
<li>标记了 <code>use client;</code> 的源文件里的组件都是 Client Component。</li>
<li>其余情况下，组件既是 Server Component 也是 Client Component。也就是说，既可以在服务端运行，又可以在客户端运行。为了方便描述，下面我将这种组件称为 <code>Server/Client Component</code></li>
</ol>
<p>我们知道 Next.js 官方说的是 “在 App Router 下不标明 <code>use client</code> 的为 Server Component”，但是我强烈建议**任何不应该被发送到客户端的 Server Component 都加上 <code>async</code> ，即使该组件没有用到异步逻辑！**原因下一节提到。</p>
<h2 id="正确使用-server-component">正确使用 Server Component</h2>
<blockquote>
<p>我们都知道，Server Component 不能访问浏览器 API（如 <code>window</code> 对象），也不能使用常规 React Hook，这一点理所当然，不在这里赘述。</p>
</blockquote>
<p>顾名思义，Server Component 就是在服务端运行的组件，可以访问浏览器端不存在的Node API，例如连接数据库等。</p>
<p>这些服务端操作通常是异步的，所以 Next.js 允许我们的 Server Component 返回 Promise，而 Client Component 则不行。因此，加上 <code>async</code> 可以保证我们的组件（以及组件内部的变量）永远不会被发送到 Client。如下面的例子：</p>
<pre><code class="language-tsx"><span class="pl-k">interface</span> <span class="pl-en">User</span> {
    <span class="pl-v">name</span><span class="pl-k">:</span> <span class="pl-c1">string</span>;
    <span class="pl-v">email</span><span class="pl-k">:</span> <span class="pl-c1">string</span>;
}

<span class="pl-k">interface</span> <span class="pl-en">Props</span> {
    <span class="pl-v">user</span><span class="pl-k">:</span> <span class="pl-en">User</span>;
    <span class="pl-v">content</span><span class="pl-k">:</span> <span class="pl-c1">string</span>;
}

<span class="pl-k">async</span> <span class="pl-k">function</span> <span class="pl-en">UserComment</span>({<span class="pl-v">comment</span>}<span class="pl-k">:</span> <span class="pl-en">Props</span>) {
    <span class="pl-k">const</span> {<span class="pl-c1">user</span>, <span class="pl-c1">content</span>} <span class="pl-k">=</span> <span class="pl-smi">comment</span>;
    <span class="pl-k">return</span> (
        &#x3C;<span class="pl-ent">dir</span>>
            &#x3C;<span class="pl-ent">p</span>><span class="pl-pse">{</span><span class="pl-smi">user</span>.<span class="pl-c1">name</span><span class="pl-pse">}</span>&#x3C;/<span class="pl-ent">p</span>>
            &#x3C;<span class="pl-ent">img</span> <span class="pl-e">src</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">gravatarUrl</span> <span class="pl-k">+</span> <span class="pl-smi">tools</span>.<span class="pl-en">md5</span>(<span class="pl-smi">user</span>.<span class="pl-smi">avatar</span>)<span class="pl-pse">}</span>/>
            &#x3C;<span class="pl-ent">p</span>><span class="pl-pse">{</span><span class="pl-smi">content</span><span class="pl-pse">}</span>&#x3C;/<span class="pl-ent">p</span>>
        &#x3C;/<span class="pl-ent">dir</span>>
    );
} 
</code></pre>
<p>显然，这个函数没有用到异步逻辑，但是，我将其标为 <code>async</code> ，为什么呢？我们不能把评论者的 email 暴露出来，只是通过 email 的 md5 显示 gravatar 头像。</p>
<p>如果不添加 <code>async</code> ，而这个组件因为某种原因（后面会将）被当作 Client Component，这个组件的参数当然也会一并被发送到浏览器，评论者的邮箱就暴露了！</p>
<p>而一旦添加了 <code>async</code> ，这个组件就只能是 Server Component，里面的数据不存在泄漏到客户端的风险。</p>
<p>还有一点，上面的例子中，<code>tools.md5</code> 一般都是 Node API 实现的，显然只能在 Server Component 中运行，添加 <code>async</code> 也能帮助你排除错误。一旦你试图在 Client Component 里使用 Server Component，Next.js 就会立刻向你报错。而当你在 Client Component 里使用 Server/Client Component （既可以作为 Server Component 又可以作为 Client Component 的组件）时，Next.js 只会在运行时向你报错“我在浏览器里找不到 <code>crypto</code> 包啊”。</p>
<h2 id="正确使用-browser-api">正确使用 Browser API</h2>
<p>何时使用浏览器API？Next.js 官方告诉我们，只有在标记了 <code>"useclient"</code> 的组件里才能使用浏览器 API。然而我觉得这句话并不十分正确，就像下面的代码一定会报错：</p>
<pre><code class="language-tsx"><span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">function</span> <span class="pl-en">Demo</span>() {
    <span class="pl-c">// 找不到 window 对象</span>
    <span class="pl-c1">window</span>.<span class="pl-c1">alert</span>(<span class="pl-s"><span class="pl-pds">"</span>233<span class="pl-pds">"</span></span>)
    
    <span class="pl-k">return</span> &#x3C;<span class="pl-ent">p</span>>233&#x3C;/<span class="pl-ent">p</span>>
}
</code></pre>
<p>为什么报错？所谓 Client Component 并不是一定在浏览器里运行的，实际上，Client Component的首次渲染在服务端，Node当然找不到 <code>window.alert</code>。Next.js 的对 Client 的处理是：<strong>只运行一遍主要逻辑，不运行Hook</strong>。也就是说，<code>useEffect</code> 里的函数是确确实实在浏览器运行的，因此，这段代码应该改成：</p>
<pre><code class="language-tsx"><span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">function</span> <span class="pl-en">Demo</span>() {
    <span class="pl-en">useEffect</span>(() <span class="pl-k">=></span> {
        <span class="pl-c1">window</span>.<span class="pl-c1">alert</span>(<span class="pl-s"><span class="pl-pds">"</span>233<span class="pl-pds">"</span></span>);
    });
    
    <span class="pl-k">return</span> &#x3C;<span class="pl-ent">p</span>>233&#x3C;/<span class="pl-ent">p</span>>
}
</code></pre>
<p>我相信下面这个例子一定能帮你理解：</p>
<pre><code class="language-tsx"><span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">function</span> <span class="pl-en">Counter</span>() {
	<span class="pl-k">const</span> [<span class="pl-c1">count</span>, <span class="pl-c1">setCount</span>] <span class="pl-k">=</span> <span class="pl-en">useState</span>(<span class="pl-c1">0</span>);
    <span class="pl-k">const</span> <span class="pl-c1">handleClick</span> <span class="pl-k">=</span> <span class="pl-en">useCallback</span>(() <span class="pl-k">=></span> <span class="pl-en">setCount</span>(<span class="pl-v">prev</span> <span class="pl-k">=></span> <span class="pl-smi">prev</span> <span class="pl-k">+</span> <span class="pl-c1">1</span>), []);
    
    <span class="pl-en">useEffect</span>(() <span class="pl-k">=></span> {
        <span class="pl-c1">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">"</span>始终在浏览器终端<span class="pl-pds">"</span></span>);
    });
    
    <span class="pl-c1">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">`</span>count = ${<span class="pl-smi">count</span>}<span class="pl-pds">`</span></span>);
    
    <span class="pl-k">return</span> (
        &#x3C;<span class="pl-ent">div</span>>
            &#x3C;<span class="pl-ent">p</span>><span class="pl-pse">{</span><span class="pl-smi">count</span><span class="pl-pse">}</span>&#x3C;/<span class="pl-ent">p</span>>
            &#x3C;<span class="pl-ent">button</span> <span class="pl-e">onClick</span><span class="pl-k">=</span><span class="pl-pse">{}</span>>Click me&#x3C;/<span class="pl-ent">button</span>>
        &#x3C;/<span class="pl-ent">div</span>>
    );
}
</code></pre>
<p>直接放到 <code>/src/app/page.tsx</code> ，打开网页并点几下按钮，观察一下控制台。</p>
<p>其中服务端控制台会显示一次 <code>count = 0</code>，只会的都显示在浏览器控制台。而 <code>"始终在浏览器终端"</code> 则只会在浏览器控制台显示。</p>
<p>除了 Hook，浏览器 API 还能在一切”副作用“中使用。什么是副作用呢？比如说用户点击按钮产生的事件、监听用户鼠标移动产生的事件等等。也就是说，<code>onClick</code> 、<code>onChange</code> 等里面也可以使用浏览器 API。</p>
<h2 id="相互调用">相互调用</h2>
<p>Server Component 能不能调用 Client Component？反过来行不行？答案是：<strong>Server Component 能调用 Client Component，Client Component 不能<big>直接</big>调用 Server Component。</strong></p>
<p>还是以例子来说明，例如下面的 Server Component 能调用 Client Component：</p>
<pre><code class="language-tsx"><span class="pl-k">async</span> <span class="pl-k">function</span> <span class="pl-en">SomeServerComponent</span>() {
    <span class="pl-c">// ...</span>
    <span class="pl-k">return</span> &#x3C;<span class="pl-c1">SomeClientComponet</span> <span class="pl-e">props</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">props</span><span class="pl-pse">}</span>/>;
    <span class="pl-c">// props 不能传递 function 等不可序列化的数据</span>
}
</code></pre>
<p>下面的 Client Component 不能调用 Server Component：</p>
<pre><code class="language-tsx"><span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">function</span> <span class="pl-en">SomeClientComponent</span>() {
    <span class="pl-c">// ...</span>
    <span class="pl-k">return</span> &#x3C;<span class="pl-c1">SomeServerComponet</span>/>; <span class="pl-c">// ❌ 错误</span>
}
</code></pre>
<p>而Client Component 能使用<strong>作为参数传递的</strong>、<strong>已经实例化的</strong> Server Component：</p>
<pre><code class="language-tsx"><span class="pl-c">// SomeServerComponent.tsx</span>
<span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">async</span> <span class="pl-k">function</span> <span class="pl-en">SomeServerComponent</span>() {
    <span class="pl-k">return</span> &#x3C;<span class="pl-ent">p</span>>SomeServerComponent&#x3C;/<span class="pl-ent">p</span>>;
}

<span class="pl-c">// SomeClientComponent.tsx</span>
<span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">interface</span> <span class="pl-en">Props</span> {
    <span class="pl-v">children</span><span class="pl-k">:</span> <span class="pl-en">ReactNode</span>;
}

<span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">SomeClientComponent</span>({<span class="pl-v">children</span>}<span class="pl-k">:</span> <span class="pl-en">Props</span>) {
    <span class="pl-k">return</span> (
        &#x3C;<span class="pl-c1">DataProvider</span>>
            <span class="pl-pse">{</span><span class="pl-smi">children</span><span class="pl-pse">}</span>
        &#x3C;/<span class="pl-c1">DataProvider</span>>
    );
}

<span class="pl-c">// somepage/layout.tsx</span>
<span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">async</span> <span class="pl-k">function</span> <span class="pl-en">SomePageLayout</span>() {
    <span class="pl-k">return</span> (
    	&#x3C;<span class="pl-c1">SomeClientComponent</span>> <span class="pl-pse">{</span><span class="pl-c">/* SomePageLayout 使用 Client Component */</span><span class="pl-pse">}</span>
            &#x3C;<span class="pl-c1">SomeServerComponent</span>/>
            <span class="pl-pse">{</span><span class="pl-c">/* SomePageLayout 使用 Server Component 并将结果传递给 Client Compoent */</span><span class="pl-pse">}</span>
        &#x3C;/<span class="pl-c1">SomeClientComponent</span>>
    )
}
</code></pre>
<p>例子中 SomePageLayout 使用 Server Component 并将结果传递给 Client Compoent，因为<strong>ReactNode本身是可以序列号的</strong>，所以 SomePageLayout 作为 Server Component 可以调用 Server Component 并将其结果序列化后传递给 Client Compoent。</p>
<h2 id="server-client-component">Server/Client Component</h2>
<p>最后来讲讲我第一节定义的 <code>Server/Client Component</code>，它是既可以作为 Server Component 又可以作为 Client Component 的组件，可以实现服务端客户端的逻辑共用，但代价是什么呢？<strong><code>Server/Client Component</code> 继承了 Server Component 和 Client Component 的全部限制</strong>。</p>
<p>具体来说：<code>Server/Client Component</code> 不能使用 Browser API 和 Hook（因为可以在服务端运行），不能使用 Node API（因为可以在客户端运行）。</p>
<p>也就是说，<code>Server/Client Component</code> 只能做从数据到 dom 树的转化，不能获取数据，不能拥有状态，也不能持有包含隐私的数据。</p>
<p>正是因为这么多限制，<code>Server/Client Component</code> 可以在 Server Component 和 Client Component 中随意使用。它就是个<strong>纯函数</strong>，或者说从 data 到 ReactNode 的 Map。</p>
<p>【完】</p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用 YubiKey 解密 LUKS 分区（续）]]></title>
        <id>https://blog.skyone.dev/2024/luks-yubikey-encrypt-2/</id>
        <link href="https://blog.skyone.dev/2024/luks-yubikey-encrypt-2/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/3fc2a9b7_14f9f16d.webp" type="image/webp"/>
        <updated>2024-07-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[本文介绍如使用 Yubikey 解密 LUKS 分区。使用 systemd 的 sd-encrypt hook，并配置为可以在超时后回退到密码解密。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2024/luks-yubikey-encrypt-2/">阅读原文</a>以获取最佳阅读体验。</p><p>在上一篇文章 <a href="https://blog.skyone.dev/2024/luks-yubikey-encrypt">使用 YubiKey 解密 LUKS 分区</a> 之后我发现了两个问题：</p>
<ul>
<li>使用 Yubikey FIDO2 解密并不稳定，有时候还算要使用密码解密</li>
<li>有时候强制使用 Yubikey 解密，不支持回退到密码解密</li>
</ul>
<p>最近又查看了一下 ArchWiki<sup><a href="#user-content-fn-2" id="user-content-fnref-2" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup>，最终解决了以上两个问题。写下这篇文章以作记录。</p>
<!-- readmore -->
<h2 id="计划分盘">计划分盘</h2>
<p>由于GRUB只识别基础的文件系统，解密根分区任务位于 initramfs 之中，因此内核镜像和 initramfs 不能被加密。具体步骤是：</p>
<ul>
<li>UEFI 识别到 <code>/dev/sda1</code> 下的 <code>/EFI/arch/grubx64.efi</code> ，将控制权交给 grub</li>
<li>grub 根据 <code>/dev/sda2</code> 下的 <code>/grub.cfg</code> 显示引导选项，选择 arch 后使用 <code>/dev/sda2</code> 下的内核镜像和 initramfs 调用内核</li>
<li>initramfs 下的内核使用 initramfs 中的 <code>/etc/crypttab</code> 提示用户解密分区</li>
<li>initramfs 下的内核使用 <code>/dev/mapper/system</code> 中的 <code>/etc/fstab</code> 挂载分区，引导结束</li>
</ul>
<p>本次分区假设如下<sup><a href="#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup>：</p>
<pre><code>/dev/sda1 1GB   -> /boot/efi FAT32
/dev/sda2 500MB -> /boot     FAT32
/dev/sda3 other -> /dev/mapper/system btrfs
                   |- /
                   |- /@          -> /
                   |- /@home      -> /home
                   |- /.snapshots
</code></pre>
<p>进行之前先确保已经能使用密码解密系统，具体参考<a href="https://blog.skyone.dev/2024/luks-yubikey-encrypt">使用 YubiKey 解密 LUKS 分区</a>。</p>
<h2 id="配置-yubikey">配置 Yubikey</h2>
<p>首先安装相关依赖<sup><a href="#user-content-fn-3" id="user-content-fnref-3" data-footnote-ref="" aria-describedby="footnote-label">3</a></sup>，然后将 Yubikey 插入电脑，检查是否出现 <code>/dev/hidrawX</code> 设备，完成后将 Yubikey FIDO2 插入密钥槽：</p>
<pre><code class="language-shell">systemd-cryptenroll --fido2-device=auto /dev/sda3
</code></pre>
<p>可选参数：</p>

























<table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>/dev/sda3</code></td><td>设备路径</td></tr><tr><td><code>--fido2-device</code></td><td>设备，可用 auto，或前面的 <code>/dev/hidrawX</code></td></tr><tr><td><code>--fido2-with-client-pin</code></td><td>默认是 <code>yes</code>，若为 <code>no</code>，从而开机时只需要触摸 YubiKey 而不需输入 PIN</td></tr><tr><td><code>--fido2-credential-algorithm</code></td><td>算法，此处选择 <code>eddsa</code></td></tr></tbody></table>
<h2 id="配置-crypttab">配置 crypttab</h2>
<p>编辑 <code>/etc/crypttab.initramfs</code> ，systemd 会将这个文件打包到 initramfs 的 <code>/etc/crypttab</code></p>
<pre><code># name  device                                     password  options
system  UUID=2f9a8428-ac69-478a-88a2-4aa458565431  none      fido2-device=auto,token-timeout=30
</code></pre>
<p>意思是：使用 FIDO2 设备将 <code>/dev/sda3</code> 解密到 <code>/dev/mapper/system</code> ，如果超过 30 秒未使用 FIDO2 设备则回退到密码解密。</p>
<ul>
<li><code>fido2-device=auto</code> 指定使用 FIDO2 密钥解密</li>
<li><code>token-timeout=30</code> 超过 30 秒未使用 FIDO2 则回退到密码解密（<a href="https://github.com/poettering/systemd/blob/ccd25f41f52e72846ea7940769076094e4601ec3/man/crypttab.xml#L681">token-timeout in systemd man page</a>）</li>
</ul>
<h2 id="配置-initramfs">配置 initramfs</h2>
<p>BusyBox 模式下并不支持这些功能<sup><a href="#user-content-fn-4" id="user-content-fnref-4" data-footnote-ref="" aria-describedby="footnote-label">4</a></sup>，我们需要改用 systemd 模式。幸好这个过程并不复杂。</p>
<p>编辑 <code>/etc/mkinitramfs.conf</code> ，修改其中的 <code>HOOKS</code> （参考 <a href="https://wiki.archlinux.org/title/Dm-crypt/System_configuration#mkinitcpio">dm-crypt/System configuration#mkinitramfs</a>），要确保：</p>
<ul>
<li>如果包含 <code>keymap</code> 或 <code>consolefont</code> ，去掉，就地换成 <code>sd-vconsole</code></li>
<li>如果包含 <code>udev</code> ，就地换成 <code>systemd</code></li>
<li>如果包含 <code>encrypt</code> ，就地换成 <code>sd-encrypt</code> ，否则在  <code>sd-vconsole</code> 之后加上 <code>sd-encrypt</code></li>
</ul>
<p>重新生成 initramfs</p>
<pre><code class="language-shell">mkinitramfs -p linux
</code></pre>
<h2 id="修改内核参数">修改内核参数</h2>
<p>如果你之前配置过 luks 相关的内核参数，需要修改一下。</p>
<p>编辑 <code>/etc/default/grub</code> ，修改其中的 <code>GRUB_CMDLINE_LINUX</code> ，与 luks 相关的 <code>cryptdevice</code> 等参数全部删掉，因为这是用于 <code>encrypt</code> hook 的，而我们现在用的是 <code>sd-encrypt</code> hook，这个 hook 会自动识别 <code>/etc/crypttab</code> 中的配置（由 <code>systemd</code> 自动从 <code>/etc/crypttab.initramfs</code> 复制到 initramfs 中的 <code>/etc/crypttab</code> ）。</p>
<p>只需要保留 <code>root=/dev/mapper/system</code> 即可。例如</p>
<pre><code class="language-shell">GRUB_CMDLINE_LINUX=<span class="pl-s"><span class="pl-pds">"</span>root=/dev/mapper/system rw loglevel=3 quiet<span class="pl-pds">"</span></span>
</code></pre>
<p>这里的 <code>/dev/mapper/system</code> 不需要使用 UUID，因为这个是由我们 <code>/etc/crypttab</code> 中的 <code>name</code> 指定的，不会改变。</p>
<p>改完记得 <code>grub-mkconfig -o /boot/grub/grub.cfg</code> 。</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-2">
<p><a href="https://wiki.archlinux.org/title/Dm-crypt/System_configuration">dm-crypt/System configuration - ArchWiki</a> <a href="#user-content-fnref-2" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-1">
<p><a href="https://wiki.archlinux.org/title/Dm-crypt/Device_encryption">dm-crypt/Device encryption</a> <a href="#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-3">
<p><a href="https://blog.skyone.dev/2024/luks-yubikey-encrypt#%E5%AE%89%E8%A3%85%E4%BE%9D%E8%B5%96">安装 FIDO2 及 Yubikey 的相关依赖</a> <a href="#user-content-fnref-3" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-4">
<p><a href="https://www.binwang.me/2023-10-22-Full-Disk-Encryption-with-Yubikey.html">Linux Full Disk Encryption with Yubikey</a> <a href="#user-content-fnref-4" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[ArchLinux KDE Plasma安装及常见问题]]></title>
        <id>https://blog.skyone.dev/2024/archlinux-plasma-faq/</id>
        <link href="https://blog.skyone.dev/2024/archlinux-plasma-faq/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/d74c1156_4974e76b.webp" type="image/webp"/>
        <updated>2024-05-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[ArchLinux KDE Plasma 6 安装及常见问题解决。本文根据个人使用经验整理，包含从安装 KDE Plasma 桌面到常见问题解决的全过程。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2024/archlinux-plasma-faq/">阅读原文</a>以获取最佳阅读体验。</p><p>去年11月我写了一篇<a href="https://blog.skyone.dev/2023/archlinux-gnome">ArchLinux安装GNOME桌面</a>，也正是那时候我将电脑的主要系统从Windows转到了ArchLinux。诚然， gnome 很好，如果你需要一个不需要折腾就能用的桌面环境，那么 gnome 是一个不错的选择。</p>
<p>我一开始也是这么想的，直到……今年 KDE Plasma 6 发布了。我不得不说，KDE Plasma 6 的演示视频真的很吸引人，所以我决定尝试一下，于是我又折腾了一遍系统，这次安装的是 KDE Plasma 桌面。新的大版本发布，肯定有大量的问题，尤其是搭配 NVIDIA 和 Wayland 这两个离谱的玩意。Wayland 由于 x11 历史包袱太重，经常出现兼容问题（尤其是 Chromium），而 NVIDIA 一直以来都是 Linux 用户的痛点，Linus 骂的一点都不冤。</p>
<p>现在新系统用了也有一段时间了，是该动笔记录一下，免得下次装又到处找资料。这次文章分两部分：安装 KDE Plasma 桌面和常见问题解决。欢迎大家在评论区补充。</p>
<!-- readmore -->
<h2 id="install-kde">安装 KDE Plasma 桌面</h2>
<p>Arch Linux 的安装已经老生常谈了，见<a href="https://blog.skyone.dev/2023/install-archlinux-2023">安装 ArchLinux &#x26; Windows 双系统</a>。这里只记录安装 KDE Plasma 桌面的步骤。</p>
<p>首先，经过一系列的安装步骤，你应该已经进入了 ArchLinux 命令行，并有了一个属于 <code>wheel</code> 组的非 root 用户。接下来，我们就可以安装 KDE Plasma 桌面了。</p>
<pre><code class="language-bash">sudo pacman -S plasma-meta sddm networkmanager bluez-utils
</code></pre>
<p>官方提供了多个包，<code>plasma-meta</code> 是一个元包，包含了 KDE Plasma 桌面的所有组件基本组件。此外，你也可以选择 <code>plasma</code> 包组，它包含的软件和 <code>plasma-meta</code> 是一样的，关于元包和包组的区别，可以参考<a href="https://wiki.archlinux.org/title/Meta_package_and_package_group">ArchWiki | Meta package and package group</a>。还有两个可选的包：<code>kde-applications</code> 和 <code>kde-applications-meta</code>，它们包含了一些额外的应用程序，比如 Dolphin 文件管理器、Konsole 终端等。但是这里我选择后续自己安装需要的应用程序。</p>
<p><code>sddm</code> 是 KDE Plasma 的默认登录管理器，<code>networkmanager</code> 是网络管理器，<code>bluez-utils</code> 是蓝牙管理工具。</p>
<p>不要急着启动桌面，这时候我们还没安装终端模拟器，进去就出不来了（笑）。接下来我们安装终端模拟器、文件管理器。</p>
<pre><code class="language-bash">sudo pacman -S konsole dolphin
</code></pre>
<p><code>konsole</code> 是 KDE Plasma 的终端模拟器，<code>dolphin</code> 是 KDE Plasma 的文件管理器。</p>
<p>接下来我们启用 <code>sddm</code> 服务，这样就会直接进入 KDE Plasma 桌面了。</p>
<pre><code class="language-bash">sudo systemctl <span class="pl-c1">enable</span> --now sddm
</code></pre>
<p>在桌面环境下打开 <code>konsole</code> 终端，启动 <code>networkmanager</code> 和 <code>bluetooth</code> 服务。</p>
<pre><code class="language-bash">sudo systemctl <span class="pl-c1">enable</span> --now NetworkManager bluetooth
</code></pre>
<h2 id="config">常见配置</h2>
<h3 id="chinese-font">中文字体</h3>
<p>我推荐 <code>noto-fonts-cjk</code>，它是 Google 的开源字体，支持中日韩文。此外，<code>noto-fonts-emoji</code> 是支持 emoji 表情的字体，<code>noto-fonts-extra</code> 是支持更多语言的字体。</p>
<pre><code class="language-bash">sudo pacman -S noto-fonts-cjk noto-fonts-emoji noto-fonts-extra
</code></pre>
<p>然后进入系统设置，把语言设置为中文，注销重新登录。</p>
<h3 id="common-apps">一些常用程序推荐</h3>
<ul>
<li>浏览器：Firefox <code>firefox</code>、<code>firefox-i18n-zh-cn</code> 或 Chromium <code>chromium</code>，我选择两个都要~</li>
<li>图片查看器：Gwenview <code>gwenview</code></li>
<li>多媒体播放器：VLC <code>vlc</code></li>
<li>文本编辑器：Kate <code>kate</code></li>
<li>截屏工具：Spectacle <code>spectacle</code></li>
<li>压缩包管理器：Ark <code>ark</code></li>
<li>DNS 工具：bind <code>bind</code>，提供 <code>dig</code>, <code>nslookup</code> 等命令</li>
<li>以及其他的命令行工具就不解释了，只是提一下，<code>iwd</code>、<code>jq</code>、<code>htop</code>、<code>duf</code>、<code>bat</code>、<code>pkgfile</code>、<code>unzip</code></li>
</ul>
<h3 id="input-method">输入法</h3>
<p>最重要的当然是输入法了，我使用 <code>fcitx5</code>。</p>
<pre><code class="language-bash">sudo pacman -S fcitx5 fcitx5-chinese-addons fcitx5-qt fcitx5-gtk fcitx5-configtool
</code></pre>
<p>网上一堆教程全是基于 <code>x11</code> 的，到了 <code>wayland</code> 就不好使了。我自己试了很久，过程如下：</p>
<p>在设置的 “键盘” - “虚拟键盘” 里面选择 <code>fcitx5</code> 即可让输入法自动启动，编辑 <code>/etc/environment</code> 文件，添加：</p>
<pre><code>XMODIFIERS=@im=fcitx
SDL_IM_MODULE=fcitx
</code></pre>
<p>注意不要包含 <code>GTK_IM_MODULE</code> 和 <code>QT_IM_MODULE</code>，这是 <code>x11</code> 的设置，<code>wayland</code> 不需要，如果有的话，注释掉。</p>
<p>设置里 “语言和时间” - “输入法” 添加简体中文即可，甚至不需要注销即可使用。</p>
<p>在最新的 chromium 里，一开始出现的无法输入中文的问题已经解决了，不需要特殊处理。</p>
<h3 id="nvidia-driver">万恶的 NVIDIA 驱动</h3>
<p>尽管 Arch Linux 提供里开箱即用的 NVIDIA 驱动包，但在 <code>plasma</code> + <code>wayland</code> 仍然有问题。首先安装 <code>nvidia</code> 驱动：</p>
<pre><code class="language-bash">sudo pacman -S nvidia
</code></pre>
<p>这时一定不要重启，否则会进不去桌面。先创建 <code>/etc/modprobe.d/nvidia_drm.conf</code> 文件，添加：</p>
<pre><code>options nvidia-drm modeset=1
</code></pre>
<p>再编辑 <code>/etc/mkinitcpio.conf</code> 文件，修改 <code>HOOKS</code> 行，将 <code>kms</code> 去掉，然后重新生成 <code>initramfs</code>：</p>
<pre><code class="language-bash">sudo mkinitcpio -P
</code></pre>
<p>最后重启电脑，应该就可以进入 KDE Plasma 桌面了。</p>
<h3 id="kvm">kvm 虚拟机</h3>
<p>尽管日常生活大部分工作都可以在 Linux 下完成，但有时候还是需要 Windows 的，比如 Office 就无可替代。我使用 <code>virt-manager</code> + <code>qemu</code> + <code>kvm</code> 来运行 Windows 虚拟机。</p>
<pre><code class="language-bash">sudo pacman -S qemu-full libvirt virt-manager dnsmaq
</code></pre>
<p>其中 <code>qemu-full</code> 包含了全部的 qemu 组件，比较省事，反正也没多大；<code>libvirt</code> 是虚拟化管理工具的后端；<code>virt-manager</code> 是图形化的虚拟机管理工具；<code>dnsmaq</code> 是 DNS 服务器，用于虚拟机的网络。</p>
<p>将自己加入 <code>libvirt</code> 组，这样就不用每次都 <code>sudo</code> 了：</p>
<pre><code class="language-bash">sudo usermod -aG libvirt <span class="pl-smi">$USER</span>
</code></pre>
<p>启动 <code>libvirtd</code> 服务：</p>
<pre><code class="language-bash">sudo systemctl <span class="pl-c1">enable</span> --now libvirtd
</code></pre>
<p>之后就可以打开 <code>virt-manager</code> 创建虚拟机了，顺便写写如何在 <code>virt-manager</code> 下安装 Windows 10 LTSC。</p>
<h3 id="kvm-windows">Windows 10 LTSC 虚拟机</h3>
<p>首先下载 Windows 10 LTSC 镜像，然后打开 <code>virt-manager</code>，点击 “创建新虚拟机”，选择 “本地安装媒体”，选择下载的镜像文件。</p>
<p>选择引导为 UEFI，系统类型选择 Windows 10，内存和 CPU 根据自己的电脑配置选择，其中 CPU 的拓扑从上到下分别是 <code>sockets</code>（指的是 CPU 插槽）、<code>cores</code>（指的是 CPU 核心）、<code>threads</code>（指的是 CPU 线程），比如我的电脑是 6 核 12 线程，那么我选择 1、6、12。</p>
<p>硬盘选择 <code>qcow</code>，类型设置为 <code>virtio</code>，也就是半虚拟化的硬盘，性能非常出色。</p>
<p>网络我选择 <code>nat</code>。</p>
<p>还需要准备一个 <code>virtio</code> 驱动，下载地址：<a href="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso">Fedora VirtIO Drivers ISO</a>，在虚拟机设置里添加一个光驱，选择这个 ISO 文件。</p>
<p>整个安装过程基本和实体机一样，但在选择安装位置的时候，需要先加载 <code>virtio for win10 x64</code> 驱动，然后才能看见硬盘。</p>
<p>安装完成后还要进入刚刚挂载的 <code>virtio</code> 驱动光盘，安装 <code>virtio</code> 网卡驱动，不然网络无法使用。以及显卡驱动 <code>qxldod</code>，不然分辨率很低。安装 <code>spice</code> 驱动，可以让虚拟机和宿主机共享剪贴板。安装 <code>qeumu-guest-agent</code>，可以让虚拟机和宿主机通信。</p>
<h2 id="faq">常见问题解决</h2>
<h3 id="cannot-enter-desktop">无法进入桌面</h3>
<p>上面的<a href="#install-kde">安装 KDE Plasma 桌面</a>里提到了，安装完 NVIDIA 驱动后，一定要修改 <code>/etc/modeprobe.d/nvidia_drm.conf</code> 文件，添加：</p>
<pre><code>options nvidia-drm modeset=1
</code></pre>
<p>如果还是没有效果，按键盘 <code>Ctrl</code> + <code>Alt</code> + <code>F2</code> 进入命令行，登录后执行：</p>
<pre><code class="language-bash">cat /sys/module/nvidia_drm/parameters/modeset
</code></pre>
<p>如果返回 <code>N</code>，则说明没有生效，执行，那就换个方案，删除 <code>/etc/modeprobe.d/nvidia_drm.conf</code> 文件，然后编辑 <code>/etc/default/grub</code> 文件，修改 <code>GRUB_CMDLINE_LINUX_DEFAULT</code> 行，在引号里添加：</p>
<pre><code>nvidia-drm.modeset=1
</code></pre>
<p>然后更新 grub 配置：</p>
<pre><code class="language-bash">sudo grub-mkconfig -o /boot/grub/grub.cfg
<span class="pl-c"># 这里的路径根据自己的系统配置而定，也可能是 /boot/efi/grub/grub.cfg</span>
</code></pre>
<p>重启电脑，应该就可以进入 KDE Plasma 桌面了。</p>
<h3 id="cannot-input-chinese">无法输入中文</h3>
<p>如果实在无法输入中文，可以尝试按照 <code>x11</code> 的方式配置，尽管不推荐，但是仍然可以使用。具体来说，编辑 <code>/etc/environment</code> 文件，添加：</p>
<pre><code>GTK_IM_MODULE=fcitx
QT_IM_MODULE=fcitx
XMODIFIERS=@im=fcitx
SDL_IM_MODULE=fcitx
</code></pre>
<p>在设置里 “自动启动” 里添加 <code>fcitx5</code>，然后注销重新登录。</p>
<h3 id="gpg-block-shutdown">gpg 导致关机时间很慢</h3>
<p>经过多次测试，确认是 KDE 的 gpg GUI <code>kleopatra</code> 无法正常退出导致的。这个目前没法解决，只能等待修复，在这之前要么关机前手动退出 <code>kleopatra</code>，要么卸载 <code>kleopatra</code>。</p>
<p>卸载了 <code>kleopatra</code> git 的签名就无法验证了，需要额外安装 <code>pinentry</code>：</p>
<pre><code class="language-bash">sudo pacman -S pinentry
</code></pre>
<p>在 <code>~/.gnupg/gpg-agent.conf</code> 文件中添加：</p>
<pre><code>pinentry-program /usr/bin/pinentry-qt
</code></pre>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[CSS @scope 规则]]></title>
        <id>https://blog.skyone.dev/2024/css-scope/</id>
        <link href="https://blog.skyone.dev/2024/css-scope/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/d10e726e_5391a901.webp" type="image/webp"/>
        <updated>2024-05-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[CSS @scope 规则可以让我们精确的定位 DOM 子树中的元素，而无需编写难以覆盖的过于特定的选择器，并且不会将选择器与 DOM 结构耦合得太紧密。本文将介绍 `@scope` 的语法、描述、注意事项等内容。本文是 MDN 的翻译，并根据我自己的理解进行了适当地修改和补充。]]></summary>
        <content type="html"><![CDATA[<p>本文使用MDX编写，部分内容RSS阅读器无法处理，请<a href="https://blog.skyone.dev/2024/css-scope/">阅读原文</a>以获取最佳阅读体验。</p><blockquote color="note" class="quote-alert">
<p class="quote-alert-title"><svg viewBox="0 0 16 16" version="1.1" width="16" height="16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>备注</p>
<p></p>
<p>本文是 <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@scope">MDN | @scope</a> 的翻译，并根据我自己的理解进行了适当地修改和补充。采取与原文相同的许可证。</p>
<p>截至 2025 年 5 月，Firefox 仍然不支持 <code>@scope</code> 规则，但是 Chrome 与 Safari 已经支持。详情查看 <a href="https://caniuse.com/mdn-css_at-rules_scope">Can i use @scope</a>。</p>
</blockquote>
<p><code>@scope</code> 可以让我们精确的定位 DOM 子树中的元素，而无需编写难以覆盖的过于特定的选择器，并且不会将选择器与 DOM 结构耦合得太紧密。</p>
<p>在 JavaScript 里，可以使用 <code>CSSScopeRule</code> 访问 <code>@scope</code> 规则。</p>
<div><strong>（此部分使用MDX渲染，请查看原文）</strong></div>
<h2 id="语法">语法</h2>
<p><code>@scope</code> 包含一个或多个规则集（称为作用域样式规则），并定义将它们应用于选定元素的范围。 <code>@scope</code> 可以通过两种方式使用：</p>
<ol>
<li>
<p>像其他 at 规则一样，作为 CSS 中的独立块，可以指定规则的作用域上限和下限。</p>
<pre><code class="language-css">@scope (scope root) to (scope limit) {
  rulesets
}
</code></pre>
</li>
<li>
<p>作为内联样式（ <code>&lt;style&gt;</code> 标签内的样式）包含在 HTML 中的，在这种情况下，两个限定参数可以被省略，并且包含的规则集自动将范围限定为 <code>&lt;style&gt;</code> 元素的封闭父元素。</p>
<pre><code class="language-html">&lt;parent-element&gt;
  &lt;style&gt;
    @scope {
      rulesets
    }
  &lt;/style&gt;
&lt;/parent-element&gt;
</code></pre>
</li>
</ol>
<h2 id="描述">描述</h2>
<p>复杂的 Web 文档可能包括页眉、页脚、新闻文章、地图、媒体播放器、广告等组件。随着复杂性的增加，有效管理这些组件的样式变得更加困难，而通过限定样式的范围有助于我们管理这种复杂性。让我们考虑以下 DOM 树：</p>
<pre><code>body
└─ article.feature
   ├─ section.article-hero
   │  ├─ h2
   │  └─ img
   │
   ├─ section.article-body
   │  ├─ h3
   │  ├─ p
   │  ├─ img
   │  ├─ p
   │  └─ figure
   │     ├─ img
   │     └─ figcaption
   │
   └─ footer
      ├─ p
      └─ img
</code></pre>
<p>如果想选择带有 <code>article-body</code> 类的 <code>&lt;section&gt;</code> 内的 <code>&lt;img&gt;</code> 元素，可以执行以下操作：</p>
<ul>
<li>
<p>编写一个选择器，例如 <code>.feature &gt; .article-body &gt; img</code>。然而，它具有很高的特异性，因此很难被覆盖，并且与 DOM 结构紧密耦合。如果 DOM 结构发生变化，可能需要重写 CSS。</p>
</li>
<li>
<p>写一些不太具体的内容，例如 <code>.article-body img</code>。但是，这将选择该部分内的所有图像。我们的目的是选择 <code>&lt;section&gt;</code> 的直接子级 <code>&lt;img&gt;</code> ，但是 <code>figure</code> 内的 <code>img</code> 也符合这个 CSS 选择器。</p>
</li>
</ul>
<p>这就是 <code>@scope</code> 有用的地方。它可以定义一个精确的范围，让 CSS 选择器只作用于这个范围之内。例如，上述问题可以这么解决：</p>
<pre><code class="language-css">@scope (.article-body) to (figure) {
  img {
    border: 5px solid black;
    background-color: goldenrod;
  }
}
</code></pre>
<p>由于 <code>.article-body</code> 指定了 CSS 规则生效的上限，而 <code>&lt;figure&gt;</code> 指定了下限。CSS 规则只作用于 <code>.article-body</code> 与 <code>&lt;figure&gt;</code> 之间的元素，不会选择 <code>&lt;figure&gt;</code> 元素内的元素。</p>
<blockquote color="note" class="quote-alert">
<p class="quote-alert-title"><svg viewBox="0 0 16 16" version="1.1" width="16" height="16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>备注</p>
<p></p>
<p>这种具有上限和下限的范围通常称为<strong>环形范围</strong>。</p>
</blockquote>
<p>如果你想选择带有 <code>article-body</code> 类的 <code>&lt;section&gt;</code> 内的所有图像，也可以省略范围限制：</p>
<pre><code class="language-css">@scope (.article-body) {
  img {
    border: 5px solid black;
    background-color: goldenrod;
  }
}
</code></pre>
<p>或者，你可以采用刚刚提到的第二种写法，将 <code>@scope</code> 块内联包含在 <code>&lt;style&gt;</code> 元素内，该元素又位于 <code>&lt;section&gt;</code> 内：</p>
<pre><code class="language-html">&lt;section class=&quot;article-body&quot;&gt;
  &lt;style&gt;
    @scope {
      img {
        border: 5px solid black;
        background-color: goldenrod;
      }
    }
  &lt;/style&gt;

  &lt;!-- ... --&gt;
&lt;/section&gt;
</code></pre>
<blockquote color="important" class="quote-alert">
<p class="quote-alert-title"><svg viewBox="0 0 16 16" version="1.1" width="16" height="16"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>重要</p>
<p></p>
<p>需要注意的是，虽然 <code>@scope</code> 将选择器的应用隔离到特定的 DOM 子树，但它并不能完全隔离这些子树内应用的样式。这在继承中最为明显——子级继承的属性（例如颜色或字体系列）仍然会被继承，超出任何设置的范围限制。</p>
</blockquote>
<h3 id="scope-伪类"><code>:scope</code> 伪类</h3>
<p>在 <code>@scope</code> 块的上下文中，<code>:scope</code> 伪类代表作用域根，它提供了一种从作用域内部将样式应用到作用域根本身的简单方法：</p>
<pre><code class="language-css">@scope (.feature) {
  :scope {
    background: rebeccapurple;
    color: antiquewhite;
    font-family: sans-serif;
  }
}
</code></pre>
<p>事实上，会被 <code>:scope</code> 隐式地添加到所有作用域样内的式规则的前面。也就是说，任何 <code>@scope</code> 作用域内的 CSS 选择器前面都会被浏览器自动加上 <code>:scope</code> ，但是不计入 CSS 特异性。</p>
<p>以下的三条规则是等价的 （但特异性不相等，查看<a href="#specificity"><code>@scope</code> 的特异性</a>）：</p>
<pre><code class="language-css">@scope (.feature) {
  img { ... }

  :scope img { ... }

  &amp; img { ... }
}
</code></pre>
<h3 id="注意事项">注意事项</h3>
<ul>
<li>
<p>范围限制可以使用 <code>:scope</code> 来指定范围限制和根之间的特定关系要求。例如：</p>
<pre><code class="language-css">/* 指定下限为 .article-body 的直接 figure 子级 */
@scope (.article-body) to (:scope &gt; figure) { ... }
</code></pre>
</li>
<li>
<p>范围限制可以使用 <code>:scope</code> 引用范围根之外的元素，也就是说上下限都是单纯的 CSS 选择器，并不要求下限必须从上限开始选择。例如：</p>
<pre><code class="language-css">@scope (.article-body) to (.feature :scope figure) { ... }
</code></pre>
</li>
<li>
<p>下限必须是上限的子孙元素（原文是：Scoped style rules can&#x27;t escape the subtree）。像 <code>:scope + p</code> 这样的选择是无效的，因为该选择将位于上限的子树之外。</p>
</li>
<li>
<p>选择器的上限可以指定多个，在这种情况下将视为定义多个范围。在以下示例中，样式将应用于 <code>&lt;section&gt;</code> 内具有 <code>article-hero</code> 或 <code>article-body</code> 类的任何 <code>&lt;img&gt;</code>，而如果 <code>&lt;img&gt;</code> 嵌套在 <code>&lt;figure&gt;</code> 内，则不会被选中：</p>
<pre><code class="language-css">@scope (.article-hero, .article-body) to (figure) {
  img {
    border: 5px solid black;
    background-color: goldenrod;
  }
}
</code></pre>
</li>
</ul>
<h3 id="scope-的css特异性"><code>@scope</code> 的CSS特异性</h3>
<p>简单来说， <code>@scope</code> 不会改变其内部规则的特异性，例如：</p>
<pre><code class="language-css">@scope (.article-body) {
  /* img 的特异性是 0-0-1 */
  img { ... }
}
</code></pre>
<p>但是，如果显式地将 <code>:scope</code> 伪类添加到作用域选择器之前，则在计算其特异性时需要将其考虑在内。<code>:scope</code> 与所有常规伪类一样，具有 0-1-0 的特异性。例如：</p>
<pre><code class="language-css">@scope (.article-body) {
  /* :scope img 的特异性为 0-1-0 + 0-0-1 = 0-1-1 */
  :scope img { ... }
}
</code></pre>
<p>当在 <code>@scope</code> 块内使用 <code>&amp;</code> 选择器时，<code>&amp;</code> 代表作用域根选择器（上限选择器）；它在内部计算为包装在 <code>:is()</code> 伪类函数内的选择器。可能听不懂，直接看例子就明白了：</p>
<pre><code class="language-css">@scope (figure, #primary) {
  &amp; img { ... }
}
</code></pre>
<p><code>&amp; img</code> 等价于 <code>:is(figure, #primary) img</code>。<code>:is()</code> 选择器的特异性为它内部生效的最大的 CSS 选择器的特异性，在这个例子中是 <code>#primary</code> ，特异性为 <code>1-0-0</code> ，因此这个条规则的特异性为 <code>1-0-1</code>。</p>
<h3 id="scope-与-的不同之处"><code>:scope</code> 与 <code>&amp;</code> 的不同之处</h3>
<p><code>:scope</code> 表示匹配的作用域根（指定的是元素本身），而 <code>&amp;</code> 表示用于匹配作用域根的选择器（是选择器而不是选中的元素）。因此，可以多次应用 <code>&amp;</code>（选择器可以重复使用多次）。但是，只能使用 <code>:scope</code> 一次（作用域根不能作为自身的子元素）。</p>
<pre><code class="language-css">@scope (.feature) {
  /* .feature 内的 .feature */
  &amp; &amp; { ... }

  /* 错误 */
  :scope :scope { ... }
}
</code></pre>
<h3 id="解决-scope-冲突">解决 <code>@scope</code> 冲突</h3>
<p><code>@scope</code> 为 CSS 添加了一个新标准：范围邻近度。这表明当两个作用域具有冲突的样式时，将应用 DOM 树层次结构中到作用域根的跳跃次数最少的样式。让我们看一个例子来看看这意味着什么。</p>
<p>采用以下 HTML 片段，其中不同主题的卡片相互嵌套：</p>
<pre><code class="language-html">&lt;div class=&quot;light-theme&quot;&gt;
  &lt;p&gt;Light theme text&lt;/p&gt;
  &lt;div class=&quot;dark-theme&quot;&gt;
    &lt;p&gt;Dark theme text&lt;/p&gt;
    &lt;div class=&quot;light-theme&quot;&gt;
      &lt;p&gt;Light theme text&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>下面的 CSS 很符合直觉，但可惜是错误的：</p>
<pre><code class="language-css">.light-theme {
  background: #ccc;
}

.dark-theme {
  background: #333;
}

.light-theme p {
  color: black;
}

.dark-theme p {
  color: white;
}
</code></pre>
<p>最里面的段落的文字应该被设置为黑色，因为它位于浅色主题卡内。但是，它是 <code>.light-theme p</code> 和 <code>.dark-theme p</code> 选择器的目标。由于 <code>.dark-theme p</code> 规则在源代码中出现得较晚，因此应用了该规则，因而该段落最终被错误地设置为白色。</p>
<p>可以使用 <code>@scope</code> 解决此问题，如下所示：</p>
<pre><code class="language-css">@scope (.light-theme) {
  :scope {
    background: #ccc;
  }
  p {
    color: black;
  }
}

@scope (.dark-theme) {
  :scope {
    background: #333;
  }
  p {
    color: white;
  }
}
</code></pre>
<p>现在，最里面的段落已正确着色为黑色。这是因为它距离 <code>.light-theme</code> 作用域根仅一级 DOM 树层次结构，但距离 <code>.dark-theme</code> 作用域根两级。因此，<code>.light-theme</code> 被应用。</p>
<blockquote color="important" class="quote-alert">
<p class="quote-alert-title"><svg viewBox="0 0 16 16" version="1.1" width="16" height="16"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>重要</p>
<p></p>
<p>范围邻近性原则会覆盖源代码顺序原则，但其本身会被其他更高优先级的样式（例如 <code>!importance</code>、<code>@layers</code> 和特异性）所覆盖。</p>
</blockquote>
<h2 id="例子">例子</h2>
<h3 id="scope-内的基本样式"><code>@scope</code> 内的基本样式</h3>
<p>在此示例中，我们使用两个单独的 <code>@scope</code> 块分别将元素内的链接与 <code>.light-scheme</code> 和 <code>.dark-scheme</code> 类进行匹配。请注意 <code>:scope</code> 如何用于选择作用域根本身并为其提供样式。在此示例中，范围根是应用了类的 <code>&lt;div&gt;</code> 元素。</p>
<div><strong>（此部分使用MDX渲染，请查看原文）</strong></div>
<h3 id="指定作用域根和范围限制">指定作用域根和范围限制</h3>
<p>在此示例中，我们有一个匹配 DOM 结构的 HTML 片段。该结构代表了一个典型的文章摘要。需要注意的关键特性是 <code>&lt;img&gt;</code> 元素，它嵌套在结构的各个级别中。</p>
<p>这个示例的目的是展示如何使用作用域根和限制来样式化从层次结构的顶部开始的 <code>&lt;img&gt;</code> 元素，但仅限于（不包括）<code>&lt;figure&gt;</code> 元素内的 <code>&lt;img&gt;</code> —— 实际上创建了一个环形范围。</p>
<div><strong>（此部分使用MDX渲染，请查看原文）</strong></div>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
    <entry>
        <title type="html"><![CDATA[View Transitions API 使用记录]]></title>
        <id>https://blog.skyone.dev/2024/css-view-transitions/</id>
        <link href="https://blog.skyone.dev/2024/css-view-transitions/"/>
        <link rel="enclosure" href="https://blog.skyone.dev/_next/static/media/94af6368_64c50016.webp" type="image/webp"/>
        <updated>2024-05-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[View Transitions API 是用来简化创建页面切换动画的 API，它目前只能用于 SPA (Single Page Application) 中，也就是说，只能用于页面内的切换动画。根据设计者的说法，这个 API 的目标是让开发者能够更容易地实现页面切换动画，而不需要关心页面切换的时机，同时针对 MPA (Multi Page Application) 的支持也在计划中。本文主要介绍了 View Transitions API 的原理、基本用法和例子。]]></summary>
        <content type="html"><![CDATA[<p>RSS阅读器可能无法处理 LaTeX 数学公式、代码块高亮等高级功能，请<a href="https://blog.skyone.dev/2024/css-view-transitions/">阅读原文</a>以获取最佳阅读体验。</p><p>最近在写一个碧蓝档案学生 Pixiv 收藏数统计的网站，想要实现两个炫酷的功能：模仿碧蓝档案游戏内什亭之匣的页面切换动画和圆形扩散的明暗主题切换动画（就像 Android 版 Telegram 那样）。</p>
<p>本来已经把 SVG 和动画的关键帧画好了，忽然发现一个问题，我的网站是基于 Next.js App Router 的，但是 App Router 不支持监听 Router 事件，也就是说，<strong>我并不知道下一个页面什么时候完成加载</strong>，也就没办法选择合适的时机播放页面进入的动画。</p>
<p>一搜 Google，千篇一律全是使用 Framer Motion 或者 React Transition Group，但是我 CSS 的 @keyframes 动画已经写好了，不想再重写一遍，难道只用纯 CSS 不能实现？</p>
<p>正好昨天在 MDN 上看到了 <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API">View Transitions API</a> 的介绍，这个 API 拿来做动画是真的方便，于是去 Can I use 查了一下，发现现在只有 Chrome 支持，不过也没关系，先实现再说。</p>
<p>经过一番尝试，终于实现了两个动画效果，本文就来介绍一下 View Transitions API 的基本用法。</p>
<!-- readmore -->
<p><img src="https://blog.skyone.dev/_next/static/media/94af6368_64c50016.webp" alt="明暗切换" width="1300" height="630" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/94af6368_64c50016.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/94af6368_64c50016.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/94af6368_64c50016.png&#x22;}]"></p>
<p><img src="https://blog.skyone.dev/_next/static/media/ed0ffcfb_06be7ed0.webp" alt="可用性" width="1368" height="688" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/ed0ffcfb_06be7ed0.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/ed0ffcfb_06be7ed0.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/ed0ffcfb_06be7ed0.png&#x22;}]"></p>
<p><a href="https://pixiv.azusa.host/">示例网站</a></p>
<h2 id="view-transitions-api-快速入门">View Transitions API 快速入门</h2>
<h3 id="是什么">是什么？</h3>
<p>简单来说，View Transitions API 是用来简化创建页面切换动画的 API，它目前只能用于 SPA (Single Page Application) 中，也就是说，只能用于页面内的切换动画。根据设计者的说法，这个 API 的目标是让开发者能够更容易地实现页面切换动画，而不需要关心页面切换的时机，同时针对 MPA (Multi Page Application) 的支持也在计划中。</p>
<p>View Transitions API 提供了一个新的 JavaScript API: <code>document.startViewTransition()</code>，以及几个新的 CSS 属性:</p>
<ul>
<li><code>view-transition-name</code></li>
<li><code>::view-transition</code></li>
<li><code>::view-transition-group()</code></li>
<li><code>::view-transition-image-pair()</code></li>
<li><code>::view-transition-old()</code></li>
<li><code>::view-transition-new()</code></li>
</ul>
<p>其中 <code>document.startViewTransition()</code> 在最新的 TypeScript 中任然没有定义，我们可以使用下面的代码来定义它：</p>
<pre><code class="language-typescript"><span class="pl-k">type</span> <span class="pl-en">StartViewTransitionFunc</span> <span class="pl-k">=</span> () <span class="pl-k">=></span> <span class="pl-c1">void</span> <span class="pl-k">|</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
<span class="pl-k">type</span> <span class="pl-en">StartViewTransitionReturn</span> <span class="pl-k">=</span> {
    <span class="pl-v">updateCallbackDone</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
    <span class="pl-v">ready</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
    <span class="pl-v">finished</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
}
<span class="pl-k">type</span> <span class="pl-en">StartViewTransition</span> <span class="pl-k">=</span> (<span class="pl-v">func</span><span class="pl-k">:</span> <span class="pl-en">StartViewTransitionFunc</span>) <span class="pl-k">=></span> <span class="pl-en">StartViewTransitionReturn</span>;

<span class="pl-k">declare</span> <span class="pl-c1">global</span> {
    <span class="pl-k">interface</span> <span class="pl-en">Document</span> {
        <span class="pl-v">startViewTransition</span><span class="pl-k">?:</span> <span class="pl-en">StartViewTransition</span>;
    }
}
</code></pre>
<p>可以看到，<code>document.startViewTransition()</code> 接受一个函数作为参数，这个函数会在页面切换的时候被调用，返回一个对象，包含了三个 Promise 对象，分别表示页面切换动画的三个阶段：<code>updateCallbackDone</code>、<code>ready</code> 和 <code>finished</code>。</p>
<p>在调用 <code>document.startViewTransition()</code> 后，浏览器会立即对当前页面进行快照，并且停止全部渲染流程，这一段时间，用户看到的是当前页面的快照，而不是真实的页面。这时的页面不能响应用户的交互。<code>startViewTransition()</code> 接受的参数函数会在这个时候被调用，执行页面的更新操作（比如页面路由，但具体的操作是什么，完全由开发者自己决定）。</p>
<p>在 <code>startViewTransition()</code> 的参数函数执行完成后（如果是异步函数，会等待兑现），浏览器在后台进行一次渲染，并对新的页面进行快照，这个操作对用户是不可见的。</p>
<p>当旧的页面和新的页面的快照都准备好后，<code>updateCallbackDone</code> 的 Promise 对象会被 resolve。这时，浏览器会开始播放页面切换动画，这个动画是由开发者自己定义的 CSS 动画（也可以是 <code>document.documentElement.animate</code> 产生的动画）。</p>
<p>当动画播放完成后，<code>finished</code> 的 Promise 对象会被 resolve，这时，浏览器会停止播放动画，移除两个页面的快照，恢复页面的交互。</p>
<p>至于页面快照的位置，浏览器会在顶层（ &#x3C;body> 标签之上，&#x3C;html> 标签之下）创建一个新的层级，这个层级的 z-index 是最高的，所以页面快照会覆盖在所有的元素之上。包含以下伪元素：</p>
<p><img src="https://blog.skyone.dev/_next/static/media/4bd9dbb2_12cc8576.webp" alt="View Transition 层级" width="814" height="572" metadata="[{&#x22;minetype&#x22;:&#x22;image/avif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/4bd9dbb2_12cc8576.avif&#x22;},{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/4bd9dbb2_12cc8576.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/png&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/4bd9dbb2_12cc8576.png&#x22;}]"></p>
<pre><code class="language-text">html
|  ::view-transition
|  |  ::view-transition-group(root)
|  |  |  ::view-transition-image-pair(root)
|  |  |  |  ::view-transition-old
|  |  |  |  |  old page snapshot
|  |  |  |  ::view-transition-new
|  |  |  |  |  new page snapshot
|  |  ::view-transition-group(custom)
|  |  |  ::view-transition-image-pair(custom)
|  |  |  |  ::view-transition-old
|  |  |  |  |  old page snapshot
|  |  |  |  ::view-transition-new
|  |  |  |  |  new page snapshot
|  &#x3C;body>
......
</code></pre>
<p>可以像对普通 HTML 元素一样对这些伪元素进行样式的设置。</p>
<h3 id="如何使用">如何使用？</h3>
<p>以页面切换为例，先看看 <a href="https://pixiv.azusa.host/">碧蓝档案 Pixiv 作品排序</a> 作为例子。</p>
<p>这是一个使用 SVG 和 <code>@keyframes</code> 实现的页面切换动画，注意到，动画中一共有 7 个三角形的方块和一个背景图，由于 View Transitions API 会对设置了 <code>view-transition-name</code> 的元素进行快照，显然 <code>&#x3C;path></code> 元素不能离开 <code>&#x3C;svg></code> 元素，所以我们需要把这个 SVG 拆成 8 份分别设置动画。这样也方便我们定义每一个三角形的运动轨迹。CSS 类似下面这么写：</p>
<pre><code class="language-css">::view-transition-old(D1TranslateAnimation),
::view-transition-old(D2TranslateAnimation),
::view-transition-old(D3TranslateAnimation),
::view-transition-old(D4TranslateAnimation),
::view-transition-old(D5TranslateAnimation),
::view-transition-old(R1TranslateAnimation),
::view-transition-old(R2TranslateAnimation),
::view-transition-old(R3TranslateAnimation) {
    <span class="pl-v">--svg-width</span>: <span class="pl-c1">max</span>(<span class="pl-c1">100</span><span class="pl-k">vw</span>, <span class="pl-c1">100</span><span class="pl-k">vh</span> <span class="pl-v">/</span> <span class="pl-c1">9</span> <span class="pl-v">*</span> <span class="pl-c1">16</span>);
    <span class="pl-v">--svg-px</span>: <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-width</span>) <span class="pl-k">/</span> <span class="pl-c1">1920</span>);
    <span class="pl-c1">transform-origin</span>: <span class="pl-c1">center</span>;
    <span class="pl-c1">transform-box</span>: <span class="pl-c1">fill-box</span>;
    <span class="pl-c1">animation-duration</span>: <span class="pl-c1">610</span><span class="pl-k">ms</span>;
    <span class="pl-c1">animation-timing-function</span>: <span class="pl-c1">linear</span>;
}

::view-transition-old(D1TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: d1-translate-animation;
}

::view-transition-old(D2TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: d2-translate-animation;
}

::view-transition-old(D3TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: d3-translate-animation;
}

::view-transition-old(D4TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: d4-translate-animation;
}

::view-transition-old(D5TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: d5-translate-animation;
}

::view-transition-old(R1TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: r1-translate-animation;
}

::view-transition-old(R2TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: r2-translate-animation;
}

::view-transition-old(R3TranslateAnimation) {
    <span class="pl-c1">animation-name</span>: r3-translate-animation;
}
<span class="pl-e">.d1TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-675</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-246</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.4</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">0</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: D1TranslateAnimation;
}

<span class="pl-e">.d2TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-391</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-440</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.28</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">-67</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: D2TranslateAnimation;
}

<span class="pl-e">.d3TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">56</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-515</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.34</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">-52.5</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: D3TranslateAnimation;
}

<span class="pl-e">.d4TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">512</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-362</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.42</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">-76.4</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: D4TranslateAnimation;
}

<span class="pl-e">.d5TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">153</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-19</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.22</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">174</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: D5TranslateAnimation;
}

<span class="pl-e">.r1TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-725</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">412</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.395</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">173.3</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: R1TranslateAnimation;
}

<span class="pl-e">.r2TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">-180</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">370</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.40</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">37</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: R2TranslateAnimation;
}

<span class="pl-e">.r3TranslateAnimation</span> {
    <span class="pl-c1">transform</span>:  <span class="pl-c1">translate</span>(<span class="pl-c1">-50</span><span class="pl-k">%</span>, <span class="pl-c1">-50</span><span class="pl-k">%</span>) <span class="pl-c1">translate</span>(<span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">415</span>), <span class="pl-c1">calc</span>(<span class="pl-c1">var</span>(<span class="pl-v">--svg-px</span>) <span class="pl-k">*</span> <span class="pl-c1">450</span>)) <span class="pl-c1">scale</span>(<span class="pl-c1">0.41</span>) <span class="pl-c1">rotate</span>(<span class="pl-c1">114</span><span class="pl-k">deg</span>);
    <span class="pl-c1">view-transition-name</span>: R3TranslateAnimation;
}
</code></pre>
<p>然后在 <code>JavaScript</code> 中使用 <code>document.startViewTransition()</code> 来实现页面切换：</p>
<pre><code class="language-javascript"><span class="pl-k">const</span> <span class="pl-c1">router</span> <span class="pl-k">=</span> <span class="pl-en">useRouter</span>();
<span class="pl-k">const</span> [<span class="pl-c1">show</span>, <span class="pl-c1">setShow</span>] <span class="pl-k">=</span> <span class="pl-en">useState</span>(<span class="pl-c1">false</span>);

<span class="pl-k">const</span> <span class="pl-c1">handleRoute</span> <span class="pl-k">=</span> (<span class="pl-smi">target</span><span class="pl-k">:</span> <span class="pl-smi">string</span>) <span class="pl-k">=></span> {
    <span class="pl-en">flushSync</span>(() <span class="pl-k">=></span> {
        <span class="pl-en">setShow</span>(<span class="pl-c1">true</span>);
    });
    <span class="pl-k">const</span> <span class="pl-c1">start</span> <span class="pl-k">=</span> <span class="pl-c1">performance</span>.<span class="pl-en">now</span>();
    <span class="pl-k">const</span> <span class="pl-c1">vt</span> <span class="pl-k">=</span> <span class="pl-c1">document</span>.<span class="pl-en">startViewTransition</span>(() <span class="pl-k">=></span> {
        <span class="pl-en">flushSync</span>(() <span class="pl-k">=></span> {
            <span class="pl-smi">router</span>.<span class="pl-c1">push</span>(target);
        });
    });
    <span class="pl-smi">vt</span>.<span class="pl-smi">finished</span>.<span class="pl-c1">then</span>(() <span class="pl-k">=></span> {
        <span class="pl-c">// 这里会有问题，后面会讲到</span>
        <span class="pl-en">setShow</span>(<span class="pl-c1">false</span>);
    });
    
    <span class="pl-k">return</span> show <span class="pl-k">&#x26;&#x26;</span> (<span class="pl-k">&#x3C;>...&#x3C;/></span>)
};
</code></pre>
<p>在本地调试一点问题都没有，但是线上运行，发现如果网络条件不好，会导致新的页面还没加载完毕就开始播放动画，放完页面还没变的尴尬情况。可见 Next.js 并不保证 <code>flushSync</code> 里的路由以及渲染完成。我们只能自己撸一套轮子。。。详见：<a href="https://github.com/vercel/next.js/discussions/46300">Next.js: Add support for View Transition API #46300</a></p>
<p>最终我的解决方案和 <a href="https://github.com/vercel/next.js/discussions/46300#discussioncomment-6080867">@AaronLayton</a> 的评论类似，由于太长了就不贴出来了，有兴趣的可以看看那位老哥的代码，已经很清晰了。</p>
<p>（最后我还是放弃了 View Transitions API，因为需要兼容 FireFox，我自己搞了一套，效果基本一模一样）</p>
<h2 id="简单但完整的例子">简单但完整的例子</h2>
<p>一个简单的明暗模式切换，相对于使用 CSS 滤镜，这种方案不需要对页面中的图片等彩色元素进行特殊处理。</p>
<p>【图中奇怪的格子是录屏转 GIF 格式的问题】</p>
<p><img src="https://blog.skyone.dev/_next/static/media/8eb5c380_f805f4ec.webp" alt="example 演示" width="1222" height="972" metadata="[{&#x22;minetype&#x22;:&#x22;image/webp&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/8eb5c380_f805f4ec.webp&#x22;},{&#x22;minetype&#x22;:&#x22;image/gif&#x22;,&#x22;src&#x22;:&#x22;/_next/static/media/8eb5c380_f805f4ec.gif&#x22;}]"></p>
<p>CSS 里禁用动画，使用 JavaScript 根据点击位置动态计算圆形扩散的半径，然后使用 <code>document.documentElement.animate</code> 播放动画。</p>
<pre><code class="language-css">::view-transition-old(root),
::view-transition-new(root) {
    <span class="pl-c1">animation</span>: <span class="pl-c1">none</span>;
    <span class="pl-c1">mix-blend-mode</span>: <span class="pl-c1">normal</span>;
}
</code></pre>
<p>首先实现一个明暗模式切换的 Context，没什么好说的，就是一个简单的 Context。我使用 <code>&#x3C;body></code> 标签的 <code>class</code> 来切换明暗模式，所以需要在 <code>ColorModeProvider</code> 中设置 <code>class</code>，但后面的颜色就都自动适配了。</p>
<pre><code class="language-tsx"><span class="pl-s"><span class="pl-pds">"</span>use client<span class="pl-pds">"</span></span>;

<span class="pl-k">type</span> <span class="pl-en">ColorMode</span> <span class="pl-k">=</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span> <span class="pl-k">|</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span> <span class="pl-k">|</span> <span class="pl-s"><span class="pl-pds">"</span>system<span class="pl-pds">"</span></span>;
<span class="pl-k">type</span> <span class="pl-en">CurrentColorMode</span> <span class="pl-k">=</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span> <span class="pl-k">|</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>;

<span class="pl-k">type</span> <span class="pl-en">ColorModeContext</span> <span class="pl-k">=</span> {
    <span class="pl-v">colorMode</span><span class="pl-k">:</span> <span class="pl-en">ColorMode</span>;
    <span class="pl-v">currentColorMode</span><span class="pl-k">:</span> <span class="pl-en">CurrentColorMode</span>;
    <span class="pl-en">setColorMode</span><span class="pl-k">:</span> (<span class="pl-v">colorMode</span><span class="pl-k">:</span> <span class="pl-en">ColorMode</span>) <span class="pl-k">=></span> <span class="pl-c1">void</span>;
    <span class="pl-en">toggleColorMode</span><span class="pl-k">:</span> () <span class="pl-k">=></span> <span class="pl-c1">void</span>;
}

<span class="pl-k">const</span> <span class="pl-c1">ColorModeContext</span> <span class="pl-k">=</span> <span class="pl-en">createContext</span>&#x3C;<span class="pl-en">ColorModeContext</span>>(<span class="pl-c1">null</span><span class="pl-k">!</span>);

<span class="pl-k">interface</span> <span class="pl-en">ColorModeProviderProps</span> {
    <span class="pl-v">children</span><span class="pl-k">:</span> <span class="pl-en">ReactNode</span>;
}

<span class="pl-k">function</span> <span class="pl-en">ColorModeProvider</span>({<span class="pl-v">children</span>}<span class="pl-k">:</span> <span class="pl-en">ColorModeProviderProps</span>) {
    <span class="pl-k">const</span> [<span class="pl-c1">colorMode</span>, <span class="pl-c1">_setColorMode</span>] <span class="pl-k">=</span> <span class="pl-en">useState</span>&#x3C;<span class="pl-en">ColorMode</span>>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
    <span class="pl-k">const</span> [<span class="pl-c1">currentColorMode</span>, <span class="pl-c1">setCurrentColorMode</span>] <span class="pl-k">=</span> <span class="pl-en">useState</span>&#x3C;<span class="pl-en">CurrentColorMode</span>>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);

    <span class="pl-k">const</span> <span class="pl-en">setColorMode</span> <span class="pl-k">=</span> (<span class="pl-v">colorMode</span><span class="pl-k">:</span> <span class="pl-en">ColorMode</span>) <span class="pl-k">=></span> {
        <span class="pl-smi">localStorage</span>.<span class="pl-c1">setItem</span>(<span class="pl-s"><span class="pl-pds">"</span>pattern.mode<span class="pl-pds">"</span></span>, <span class="pl-smi">colorMode</span>);
        <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>) {
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">add</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">remove</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
        } <span class="pl-k">else</span> <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>) {
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">add</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">remove</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
        } <span class="pl-k">else</span> {
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">remove</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
            <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-smi">classList</span>.<span class="pl-c1">remove</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
        }
        <span class="pl-en">_setColorMode</span>(<span class="pl-smi">colorMode</span>);
    };
    <span class="pl-en">useEffect</span>(() <span class="pl-k">=></span> {
        <span class="pl-k">const</span> <span class="pl-c1">colorMode</span> <span class="pl-k">=</span> <span class="pl-smi">localStorage</span>.<span class="pl-c1">getItem</span>(<span class="pl-s"><span class="pl-pds">"</span>pattern.mode<span class="pl-pds">"</span></span>);
        <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>system<span class="pl-pds">"</span></span> <span class="pl-k">||</span> <span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>) {
            <span class="pl-en">setColorMode</span>(<span class="pl-smi">colorMode</span>);
        } <span class="pl-k">else</span> {
            <span class="pl-en">setColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
        }
    }, []);
    <span class="pl-en">useEffect</span>(() <span class="pl-k">=></span> {
        <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>system<span class="pl-pds">"</span></span>) {
            <span class="pl-k">const</span> <span class="pl-c1">mediaQuery</span> <span class="pl-k">=</span> <span class="pl-c1">window</span>.<span class="pl-en">matchMedia</span>(<span class="pl-s"><span class="pl-pds">"</span>(prefers-color-scheme: dark)<span class="pl-pds">"</span></span>);
            <span class="pl-k">if</span> (<span class="pl-smi">mediaQuery</span>.<span class="pl-smi">matches</span>) {
                <span class="pl-en">setCurrentColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
            } <span class="pl-k">else</span> {
                <span class="pl-en">setCurrentColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
            }
            <span class="pl-k">const</span> <span class="pl-en">listener</span> <span class="pl-k">=</span> (<span class="pl-v">e</span><span class="pl-k">:</span> <span class="pl-en">MediaQueryListEvent</span>) <span class="pl-k">=></span> {
                <span class="pl-k">if</span> (<span class="pl-smi">e</span>.<span class="pl-smi">matches</span>) {
                    <span class="pl-en">setCurrentColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
                } <span class="pl-k">else</span> {
                    <span class="pl-en">setCurrentColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
                }
            };
            <span class="pl-smi">mediaQuery</span>.<span class="pl-c1">addEventListener</span>(<span class="pl-s"><span class="pl-pds">"</span>change<span class="pl-pds">"</span></span>, <span class="pl-smi">listener</span>);
            <span class="pl-k">return</span> () <span class="pl-k">=></span> {
                <span class="pl-smi">mediaQuery</span>.<span class="pl-c1">removeEventListener</span>(<span class="pl-s"><span class="pl-pds">"</span>change<span class="pl-pds">"</span></span>, <span class="pl-smi">listener</span>);
            };
        } <span class="pl-k">else</span> {
            <span class="pl-en">setCurrentColorMode</span>(<span class="pl-smi">colorMode</span>);
        }
    }, [<span class="pl-smi">colorMode</span>]);

    <span class="pl-k">const</span> <span class="pl-en">toggleColorMode</span> <span class="pl-k">=</span> () <span class="pl-k">=></span> {
        <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>) {
            <span class="pl-en">setColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>);
        } <span class="pl-k">else</span> <span class="pl-k">if</span> (<span class="pl-smi">colorMode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>) {
            <span class="pl-en">setColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>system<span class="pl-pds">"</span></span>);
        } <span class="pl-k">else</span> {
            <span class="pl-en">setColorMode</span>(<span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>);
        }
    };

    <span class="pl-k">return</span> (
        &#x3C;<span class="pl-c1">ColorModeContext.Provider</span> <span class="pl-e">value</span><span class="pl-k">=</span><span class="pl-pse">{</span>{<span class="pl-smi">colorMode</span>, <span class="pl-smi">currentColorMode</span>, <span class="pl-smi">setColorMode</span>, <span class="pl-smi">toggleColorMode</span>}<span class="pl-pse">}</span>>
            <span class="pl-pse">{</span><span class="pl-smi">children</span><span class="pl-pse">}</span>
        &#x3C;/<span class="pl-c1">ColorModeContext.Provider</span>>
    );
}

<span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-smi">ColorModeProvider</span>;

<span class="pl-k">export</span> <span class="pl-k">function</span> <span class="pl-en">useColorMode</span>() {
    <span class="pl-k">return</span> <span class="pl-en">useContext</span>(<span class="pl-smi">ColorModeContext</span>);
}
</code></pre>
<p>然后实现一个按钮，点击按钮时，根据点击位置计算圆形扩散的半径，然后播放动画。</p>
<p>很简单，就是使用 <code>flushSync</code> 强制 React 完成同步渲染，等待新旧页面快照就绪，然后使用 <code>document.documentElement.animate</code> 播放动画。</p>
<pre><code class="language-tsx"><span class="pl-k">type</span> <span class="pl-en">StartViewTransitionFunc</span> <span class="pl-k">=</span> () <span class="pl-k">=></span> <span class="pl-c1">void</span>;
<span class="pl-k">type</span> <span class="pl-en">StartViewTransitionReturn</span> <span class="pl-k">=</span> {
    <span class="pl-v">updateCallbackDone</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
    <span class="pl-v">ready</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
    <span class="pl-v">finished</span><span class="pl-k">:</span> <span class="pl-en">Promise</span>&#x3C;<span class="pl-c1">void</span>>;
}
<span class="pl-k">type</span> <span class="pl-en">StartViewTransition</span> <span class="pl-k">=</span> (<span class="pl-v">func</span><span class="pl-k">:</span> <span class="pl-en">StartViewTransitionFunc</span>) <span class="pl-k">=></span> <span class="pl-en">StartViewTransitionReturn</span>;

<span class="pl-k">declare</span> <span class="pl-c1">global</span> {
    <span class="pl-k">interface</span> <span class="pl-en">Document</span> {
        <span class="pl-v">startViewTransition</span><span class="pl-k">:</span> <span class="pl-en">StartViewTransition</span>;
    }
}

<span class="pl-k">type</span> <span class="pl-en">ColorMode</span> <span class="pl-k">=</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span> <span class="pl-k">|</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span> <span class="pl-k">|</span> <span class="pl-s"><span class="pl-pds">"</span>system<span class="pl-pds">"</span></span>;

<span class="pl-k">function</span> <span class="pl-en">getColorModeIron</span>(<span class="pl-v">mode</span><span class="pl-k">:</span> <span class="pl-en">ColorMode</span>) {
    <span class="pl-k">if</span> (<span class="pl-smi">mode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>light<span class="pl-pds">"</span></span>) {
        <span class="pl-k">return</span> &#x3C;<span class="pl-c1">LightModeIcon</span>/>;
    } <span class="pl-k">else</span> <span class="pl-k">if</span> (<span class="pl-smi">mode</span> <span class="pl-k">===</span> <span class="pl-s"><span class="pl-pds">"</span>dark<span class="pl-pds">"</span></span>) {
        <span class="pl-k">return</span> &#x3C;<span class="pl-c1">DarkModeIcon</span>/>;
    } <span class="pl-k">else</span> {
        <span class="pl-k">return</span> &#x3C;<span class="pl-c1">SystemModeIcon</span>/>;
    }
}

<span class="pl-k">function</span> <span class="pl-en">HeaderColorToggle</span>() {
    <span class="pl-k">const</span> {<span class="pl-c1">toggleColorMode</span>, <span class="pl-c1">colorMode</span>} <span class="pl-k">=</span> <span class="pl-en">useColorMode</span>();
    
    <span class="pl-k">const</span> <span class="pl-en">listener</span><span class="pl-k">:</span> <span class="pl-en">MouseEventHandler</span> <span class="pl-k">=</span> <span class="pl-k">async</span> (<span class="pl-v">e</span>) <span class="pl-k">=></span> {
        <span class="pl-k">if</span> (<span class="pl-k">!</span><span class="pl-c1">document</span>.<span class="pl-smi">startViewTransition</span>) {
            <span class="pl-en">toggleColorMode</span>();
            <span class="pl-k">return</span>;
        }
        <span class="pl-k">const</span> <span class="pl-c1">x</span> <span class="pl-k">=</span> <span class="pl-smi">e</span>.<span class="pl-smi">clientX</span>;
        <span class="pl-k">const</span> <span class="pl-c1">y</span> <span class="pl-k">=</span> <span class="pl-smi">e</span>.<span class="pl-smi">clientY</span>;
        <span class="pl-k">const</span> <span class="pl-c1">radius</span> <span class="pl-k">=</span> <span class="pl-c1">Math</span>.<span class="pl-c1">hypot</span>(<span class="pl-c1">Math</span>.<span class="pl-c1">max</span>(<span class="pl-smi">x</span>, <span class="pl-c1">window</span>.<span class="pl-c1">innerWidth</span> <span class="pl-k">-</span> <span class="pl-smi">x</span>), <span class="pl-c1">Math</span>.<span class="pl-c1">max</span>(<span class="pl-smi">y</span>, <span class="pl-c1">window</span>.<span class="pl-c1">innerHeight</span> <span class="pl-k">-</span> <span class="pl-smi">y</span>));

        <span class="pl-k">const</span> <span class="pl-c1">vt</span> <span class="pl-k">=</span> <span class="pl-c1">document</span>.<span class="pl-en">startViewTransition</span>(() <span class="pl-k">=></span> {
            <span class="pl-en">flushSync</span>(() <span class="pl-k">=></span> {
                <span class="pl-en">toggleColorMode</span>();
            });
        });
        <span class="pl-k">await</span> <span class="pl-smi">vt</span>.<span class="pl-smi">ready</span>;
        <span class="pl-k">const</span> <span class="pl-c1">frameConfig</span> <span class="pl-k">=</span> {
            clipPath: [
                <span class="pl-s"><span class="pl-pds">`</span>circle(0 at ${<span class="pl-smi">x</span>}px ${<span class="pl-smi">y</span>}px)<span class="pl-pds">`</span></span>,
                <span class="pl-s"><span class="pl-pds">`</span>circle(${<span class="pl-smi">radius</span>}px at ${<span class="pl-smi">x</span>}px ${<span class="pl-smi">y</span>}px)<span class="pl-pds">`</span></span>,
            ],
        };
        <span class="pl-k">const</span> <span class="pl-c1">timingConfig</span> <span class="pl-k">=</span> {
            duration: <span class="pl-c1">200</span>,
            pseudoElement: <span class="pl-s"><span class="pl-pds">"</span>::view-transition-new(root)<span class="pl-pds">"</span></span>,
        };
        <span class="pl-c1">document</span>.<span class="pl-c1">documentElement</span>.<span class="pl-c1">animate</span>(<span class="pl-smi">frameConfig</span>, <span class="pl-smi">timingConfig</span>);
    };

    <span class="pl-k">return</span> (
        &#x3C;<span class="pl-ent">button</span> <span class="pl-e">onClick</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">listener</span><span class="pl-pse">}</span> <span class="pl-e">aria-label</span><span class="pl-k">=</span><span class="pl-s"><span class="pl-pds">"</span>切换颜色模式<span class="pl-pds">"</span></span> <span class="pl-e">title</span><span class="pl-k">=</span><span class="pl-s"><span class="pl-pds">"</span>切换颜色模式<span class="pl-pds">"</span></span> <span class="pl-e">type</span><span class="pl-k">=</span><span class="pl-s"><span class="pl-pds">"</span>button<span class="pl-pds">"</span></span>
                <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-en">clsx</span>(<span class="pl-s"><span class="pl-pds">"</span>px-3 py-2 shrink-0 flex rounded cursor-pointer items-center hover:bg-bg-hover hover:text-link-hover fill-current<span class="pl-pds">"</span></span>)<span class="pl-pse">}</span>>
            <span class="pl-pse">{</span><span class="pl-en">getColorModeIron</span>(<span class="pl-smi">colorMode</span>)<span class="pl-pse">}</span>
        &#x3C;/<span class="pl-ent">button</span>>
    );
}
</code></pre>
<p>【完】</p>]]></content>
        <author>
            <name>Skyone</name>
            <email>master@skyone.dev</email>
            <uri>https://blog.skyone.dev/about/</uri>
        </author>
        <rights>CC BY-NC-SA 4.0 2026, Skyone</rights>
    </entry>
</feed>