Afterglow | 余火

这首歌关于一个庆祝,庆祝什么呢,庆祝我们的遗憾,此时此刻我们是被我们过往所有的经历堆叠而成的,包括艰难的时刻,所以让我们点起火把。

———— 《当我们点起火把》 DOUDOU

开源地址

同时感谢:谷歌大善人和他的 AI Studio,Gemini-3-Flash 以及 Antigravity

UI 速览

你也可以前往 CF 部署的 web 版本体验:https://afterglow.realme.top/

功能

  • 目标打卡,同时可以设置一个主目标,在首页主要区域显示。
  • 随时补卡,无需任何前置条件
  • 心情记录,记录下每一次打卡时的小感触
  • 支持英语 / 中文切换
  • 热力图(可选),查看到目前为止的数天内打卡频率
  • 一言(可选),基于 API 的每日一言(zh/en 下 API 不同)
  • webdav 备份,用于备份、恢复数据。

使用

Afterglow 使用 React 编写,且无后端服务,是一个「纯前端」打卡 APP。可以选择以下方式使用:

数据

目前所有的数据使用本地的 IndexedDB,可以通过 webdav 进行备份 / 恢复。
后续会尝试支持 Supabase/Cloudflare 部署 API+DB

WebDAV 备份

数据很重要(嗯

所以为了有效保持自己的小习惯能被很好的记录,可以使用 webdav 进行备份。

个人使用坚果云(免费)来作为 webdav 备份(https://dav.jianguoyun.com/dav/),因为可能遇到的 CORS 问题,也支持自建一个 CORS 代理来绕过可能出现的请求失败问题。

具体而言,可以使用我在 deno 部署的项目:https://cors-proxy.heerheer.deno.net/?url=,也可以采用其他的开源自部署项目,我部署的版本代码放在文末~

未来计划

  • peer review (什么赛博监视)
  • 自部署服务端与数据库支持
  • 将数据生成分享图 / 时段总结

附录

CORS-Proxy Deno
// main.ts — Deno Deploy CORS Proxy (safe-ish) // Deploy: https://deno.com/deploy // Usage:  /?url=https%3A%2F%2Fexample.com%2Fapi const ALLOWED_ORIGINS = ["*"]; // 你也可以改成 ["https://yourdomain.com"] const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS","MKCOL","PROPFIND"];

// 目标站点白名单:只允许代理这些域名(强烈建议填写) // 例: ["api.github.com", "v1.hitokoto.cn"] const TARGET_HOST_ALLOWLIST = new Set<string>([
  "v1.hitokoto.cn",
  "thequoteshub.com",
  // "api.example.com",
]);

// 允许转发的请求头(避免转发一些危险/无意义的头) const FORWARD_REQ_HEADERS = new Set([
  "accept",
  "accept-language",
  "content-type",
  "authorization",
  "x-requested-with",
  "user-agent",
]);

// 允许暴露给浏览器端读取的响应头 const EXPOSE_HEADERS = [
  "content-type",
  "content-length",
  "date",
  "etag",
  "cache-control",
  "expires",
  "last-modified",
  "x-request-id",
];

function pickCorsOrigin(origin: string | null) {
  if (!origin) return "*";
  if (ALLOWED_ORIGINS.includes("*")) return "*";
  return ALLOWED_ORIGINS.includes(origin) ? origin : "null";
}

function isAllowedTarget(url: URL) {
  // 仅允许 http/https if (!["http:", "https:"].includes(url.protocol)) return false;
  // // 白名单校验 // return TARGET_HOST_ALLOWLIST.has(url.host); return true;
}

function buildCorsHeaders(origin: string | null) {
  const allowOrigin = pickCorsOrigin(origin);
  return new Headers({
    "access-control-allow-origin": allowOrigin,
    "access-control-allow-methods": ALLOWED_METHODS.join(", "),
    "access-control-allow-headers": "Content-Type, Authorization, X-Requested-With,Depth",
    "access-control-expose-headers": EXPOSE_HEADERS.join(", "),
    "access-control-max-age": "86400",
    "vary": "Origin",
  });
}

function filterRequestHeaders(req: Request) {
  const headers = new Headers();
  for (const [k, v] of req.headers.entries()) {
    const key = k.toLowerCase();
    if (FORWARD_REQ_HEADERS.has(key)) headers.set(k, v);
  }
  return headers;
}

Deno.serve(async (req) => {
  const url = new URL(req.url);
  const origin = req.headers.get("origin");

  // 预检请求直接返回 if (req.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: buildCorsHeaders(origin) });
  }

  if (!ALLOWED_METHODS.includes(req.method)) {
    const h = buildCorsHeaders(origin);
    return new Response("Method Not Allowed", { status: 405, headers: h });
  }

  // 读取目标 URL const targetParam = url.searchParams.get("url");
  if (!targetParam) {
    const h = buildCorsHeaders(origin);
    return new Response("Missing ?url= encoded target URL", { status: 400, headers: h });
  }

  let targetUrl: URL;
  try {
    targetUrl = new URL(decodeURIComponent(targetParam));
  } catch {
    const h = buildCorsHeaders(origin);
    return new Response("Invalid target url", { status: 400, headers: h });
  }

  if (!isAllowedTarget(targetUrl)) {
    const h = buildCorsHeaders(origin);
    return new Response("Target host not allowed", { status: 403, headers: h });
  }

  // 把当前请求的 query(除了 url)拼到目标上(可选) // 例如 /?url=...&a=1 => target?a=1 for (const [k, v] of url.searchParams.entries()) {
    if (k === "url") continue;
    targetUrl.searchParams.set(k, v);
  }

  // 构造转发请求 const forwardHeaders = filterRequestHeaders(req);

  // 如果是 GET/HEAD 不带 body,其它方法允许透传 body const hasBody = !["GET", "HEAD"].includes(req.method);
  const body = hasBody ? req.body : undefined;

  let upstreamResp: Response;
  try {
    upstreamResp = await fetch(targetUrl.toString(), {
      method: req.method,
      headers: forwardHeaders,
      body,
      redirect: "follow",
    });
  } catch (e) {
    const h = buildCorsHeaders(origin);
    return new Response(`Upstream fetch failed: ${String(e)}`, { status: 502, headers: h });
  }

  // 复制响应头(过滤掉 hop-by-hop 以及冲突的 CORS 头) const respHeaders = new Headers();
  for (const [k, v] of upstreamResp.headers.entries()) {
    const key = k.toLowerCase();
    if (key === "set-cookie") continue; // 浏览器也读不到,且有安全风险 if (key.startsWith("access-control-")) continue; // 我们自己控制 CORS
    respHeaders.set(k, v);
  }

  // 注入 CORS 头 const corsHeaders = buildCorsHeaders(origin);
  for (const [k, v] of corsHeaders.entries()) respHeaders.set(k, v);

  return new Response(upstreamResp.body, {
    status: upstreamResp.status,
    statusText: upstreamResp.statusText,
    headers: respHeaders,
  });
});

📌 转载信息
原作者:
Harmog
转载时间:
2026/1/2 21:18:21