From bac169fe89eb269ef15b725773719d29b376d023 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 20 Jun 2026 10:01:53 +1000 Subject: [PATCH] docs: add cloudflare shortlinks implementation plan --- ...20_093535-cloudflare-workers-shortlinks.md | 994 ++++++++++++++++++ 1 file changed, 994 insertions(+) create mode 100644 .hermes/plans/2026-06-20_093535-cloudflare-workers-shortlinks.md diff --git a/.hermes/plans/2026-06-20_093535-cloudflare-workers-shortlinks.md b/.hermes/plans/2026-06-20_093535-cloudflare-workers-shortlinks.md new file mode 100644 index 0000000..f126070 --- /dev/null +++ b/.hermes/plans/2026-06-20_093535-cloudflare-workers-shortlinks.md @@ -0,0 +1,994 @@ +# Heygo Cloudflare Workers Shortlinks Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 把现有 Linux/Django Links 里的短链能力迁移到 Cloudflare 全栈架构:`heygo.cc` 提供全球公共短链和管理后台,`my.heygo.cc` 提供登录用户自己的私人短链。 + +**Architecture:** 新建 Cloudflare Workers + Workers Assets 全栈应用,前端用 React + Vite + shadcn/ui,后端 Worker 用 TypeScript 路由 API 和重定向逻辑。D1 作为关系型 source of truth,KV 作为公开短链的可选读缓存;个人短链根据登录态和当前用户在 D1 中解析。 + +**Tech Stack:** TypeScript, Cloudflare Workers, Workers Assets, D1, optional KV, React, Vite, shadcn/ui, Hono 或同等轻量 Worker router, Zod, Drizzle ORM 或原生 D1 SQL, Better Auth/Auth.js with D1/Cloudflare adapter, Vitest, Playwright, Wrangler. + +--- + +## 0. 已确认背景与关键结论 + +### 0.1 现有 Linux/Django 短链能力 + +从 `/home/ai-bot/code/links` 读到的现状: + +- 项目是 Django 5.1 + DRF + SQLite/R2,短链核心在: + - `links/models.py`:`Link(alias, original_url, text, link_type, click_count, tags, task_states)` + - `links/views.py`:`redirect_to_original()` 处理 `//` 与 `///` + - `links/forms.py`:允许 `{param,default=value}` 模板 URL,并支持中文静态文本 URL 编码校验 + - `tests/test_links_api.py`:覆盖创建、重定向、未知 alias、模板参数默认值和 override +- 当前短链类型: + - 常规短链:`LINK -> original_url` + - 自定义短链:`CUSTOM -> markdown/text page` + - 带参数短链:`https://example.com/{query,default=hello}`,访问 `/alias/value/` 用 `value` 替换参数,否则使用默认值 +- 当前未找到 Cloudflare 版本,计划新建 Cloudflare app,不直接改 Django 运行逻辑。 + +### 0.2 Cloudflare 产品选择 + +结论:优先用 **Cloudflare Workers + Workers Assets**,不要从 Cloudflare Pages 起步。 + +理由: + +- Cloudflare 官方 React + Vite 文档现在推荐 `npm create cloudflare@latest -- --framework=react`,生成 React SPA + Worker API + Workers Assets。 +- Workers Assets 的静态资源请求免费且不限量;只有 Worker 脚本请求按 Workers 计费。 +- 同一个 Worker 可以同时绑定 `heygo.cc/*` 和 `my.heygo.cc/*`,根据 `Host` 分流重定向、API、SPA。 +- Pages Functions 也能做,但对这个需求没有明显优势;Workers 自定义路由和 D1/KV 绑定更直接。 + +### 0.3 数据库选择 + +结论:**D1 做 source of truth;KV 只做公开短链读缓存,不做最终一致性依赖。** + +理由: + +- 需要用户、OAuth account、session、私人短链、公共短链、推荐审核、标签、点击统计,这些是关系型数据,D1 比 KV 合适。 +- D1 是 SQLite 语义,官方适合轻量 relational data;每个 D1 DB 目前有 10GB 上限,读多写少短链足够。 +- KV 读快但 eventual consistency,写入/修改/删除后其他地区可能 60 秒或更久才可见;不能作为审核、权限、唯一性、刚创建短链是否存在的最终判断。 +- 公开短链可用 KV 缓存 `public:`,miss 后回 D1;后台保存时同步更新 KV。私人短链先不进 KV,避免登录态 + owner 维度复杂化。 + +### 0.4 登录供应商选择 + +- 首批支持:Google + GitHub。 +- Apple 登录:先预留 provider,但默认不启用。 +- 核实结果:Apple 官方页面区分免费 Apple developer account 与 **Apple Developer Program $99/year**;Apple Sign In Web 通常需要 Service ID、Team ID、Key ID、private key 等开发者门户能力。第三方实现文档也明确需要 active Apple Developer account。结论:**大概率需要 $99/year 付费 Apple Developer Program**,除非后续官方后台实际验证有免费路径;MVP 不把 Apple 作为阻塞项。 + +--- + +## 1. 产品需求定稿 + +### 1.1 域名行为 + +#### `heygo.cc` + +- `GET https://heygo.cc/` + - 只查公共短链。 + - 不要求登录。 + - 找到 `link_type=redirect`:302/307 跳转到目标 URL。 + - 找到 `link_type=custom`:渲染公开 markdown/custom page。 + - 找不到:返回公开 404 页面。 +- `GET https://heygo.cc/app/*` + - 管理后台 React SPA。 + - 未登录:显示登录页。 + - 已登录:显示个人短链管理、推荐审核状态、公共短链浏览。 +- `GET/POST https://heygo.cc/api/*` + - API 入口。 + - 公共查询 API 可匿名;创建/管理/推荐/审核必须鉴权。 +- `GET https://heygo.cc/admin/*` + - 管理员页面/API。 + - 只有 `users.role = 'admin'` 可访问。 + +#### `my.heygo.cc` + +- `GET https://my.heygo.cc/` + - 查当前登录用户自己的私人短链。 + - 未登录:返回 404 页面,正文下面显示 “Login to use your private links” 链接;不要直接跳登录,保持用户要求的 404-first 行为。 + - 已登录但 alias 不存在:返回私人 404,提供 “Create this private link” 按钮。 + - 已登录且找到:按 link 类型跳转或渲染 custom page。 +- `GET https://my.heygo.cc/` + - 未登录:404 + login link。 + - 已登录:302 到 `https://heygo.cc/app/private` 或直接展示私人管理 SPA。 + +### 1.2 短链类型 + +统一抽象为 `links` 表的一条记录: + +- `scope = public | private` +- `link_type = redirect | custom` +- `alias` +- `target_url`:redirect 类型必填 +- `content_markdown`:custom 类型必填 +- `owner_user_id`:private 必填,public 可为空或记录提交者 +- `visibility/status`:active/archived/deleted + +支持三类创建: + +1. 常规短链:`alias -> https://example.com` +2. 自定义短链:`alias -> markdown/custom page` +3. 带参数短链:`alias -> https://google.com/search?q={query,default=hello}` + +### 1.3 参数短链规则 + +保持兼容 Django 旧语法,同时补强规则: + +- 模板参数格式:`{name}` 或 `{name,default=value}`。 +- 参数名只允许 `[A-Za-z_][A-Za-z0-9_]*`。 +- 访问方式: + - `//`:填入第一个模板参数。 + - `/?query=value&lang=en`:按参数名填入。 + - query 参数优先于 path 参数。 + - 缺失参数时使用默认值;无默认值则 400/友好错误页,不跳转。 +- 替换时必须 `encodeURIComponent(value)`,但不能把整个 URL 双重编码。 +- 额外 query 是否透传:MVP 不透传未知 query,避免意外泄露;以后可加 `forward_unknown_query` 开关。 + +### 1.4 私人短链唯一性 + +重要设计:`my.heygo.cc/` 的解析依赖当前登录用户。 + +- 同一个用户内 alias 唯一。 +- 不同用户可以有相同 alias。 +- 私人短链本质是个人快捷方式,不适合公开分享;分享出去对未登录或其他用户返回 404。 + +### 1.5 推荐到公共库审核流 + +用户在个人短链列表里可单选/多选 “Recommend to public”。 + +流程: + +1. 用户选择一个或多个 private links。 +2. 对每个链接填写/确认 `proposed_alias`、说明、可选标签。 +3. 系统创建 `promotion_submissions(status='pending')`。 +4. 管理员在 `heygo.cc/admin/review` 审核: + - approve:创建 public link,或如果 alias 冲突则要求管理员改 alias。 + - reject:写入 rejection reason。 + - needs_changes:让用户修改后重提。 +5. 审核通过后,`heygo.cc/` 全球公开可访问。 +6. 原 private link 不自动删除,继续归属用户。 + +--- + +## 2. 推荐目录与文件结构 + +在现有 repo 下新增一个独立 Cloudflare app,避免直接影响 Django: + +```text +/home/ai-bot/code/links/ +├── cloudflare-shortlinks/ +│ ├── package.json +│ ├── wrangler.jsonc +│ ├── vite.config.ts +│ ├── tsconfig.json +│ ├── components.json # shadcn/ui +│ ├── migrations/ +│ │ └── 0001_init.sql +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── LoginPage.tsx +│ │ │ ├── PrivateLinksPage.tsx +│ │ │ ├── PublicLinksPage.tsx +│ │ │ ├── AdminReviewPage.tsx +│ │ │ └── NotFoundPage.tsx +│ │ ├── components/ +│ │ │ ├── LinkForm.tsx +│ │ │ ├── LinkTable.tsx +│ │ │ ├── ProviderButtons.tsx +│ │ │ └── ui/ # shadcn generated +│ │ └── lib/ +│ │ ├── api.ts +│ │ └── validators.ts +│ ├── worker/ +│ │ ├── index.ts +│ │ ├── env.ts +│ │ ├── auth.ts +│ │ ├── db.ts +│ │ ├── routes/ +│ │ │ ├── api.links.ts +│ │ │ ├── api.promotions.ts +│ │ │ ├── api.admin.ts +│ │ │ ├── auth.ts +│ │ │ └── redirect.ts +│ │ └── lib/ +│ │ ├── aliases.ts +│ │ ├── templates.ts +│ │ ├── responses.ts +│ │ └── security.ts +│ ├── scripts/ +│ │ ├── import-links-to-d1.ts +│ │ └── seed-admin.ts +│ └── tests/ +│ ├── templates.test.ts +│ ├── aliases.test.ts +│ ├── redirect.test.ts +│ └── api.links.test.ts +└── links/ + └── management/commands/ + └── export_shortlinks_jsonl.py # 从 Django 导出短链,后续任务新增 +``` + +--- + +## 3. D1 schema 初稿 + +Create: `cloudflare-shortlinks/migrations/0001_init.sql` + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + name TEXT, + image_url TEXT, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE oauth_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('google', 'github', 'apple')), + provider_account_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(provider, provider_account_id) +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE links ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('public', 'private')), + owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + alias TEXT NOT NULL, + link_type TEXT NOT NULL CHECK (link_type IN ('redirect', 'custom')), + target_url TEXT, + content_markdown TEXT, + description TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')), + click_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK ( + (scope = 'public' AND owner_user_id IS NULL) + OR (scope = 'private' AND owner_user_id IS NOT NULL) + ), + CHECK ( + (link_type = 'redirect' AND target_url IS NOT NULL) + OR (link_type = 'custom' AND content_markdown IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX links_public_alias_unique + ON links(alias) + WHERE scope = 'public' AND status != 'deleted'; + +CREATE UNIQUE INDEX links_private_owner_alias_unique + ON links(owner_user_id, alias) + WHERE scope = 'private' AND status != 'deleted'; + +CREATE INDEX links_owner_idx ON links(owner_user_id, updated_at DESC); +CREATE INDEX links_scope_updated_idx ON links(scope, updated_at DESC); + +CREATE TABLE tags ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE link_tags ( + link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (link_id, tag_id) +); + +CREATE TABLE promotion_submissions ( + id TEXT PRIMARY KEY, + private_link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE, + submitted_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + proposed_alias TEXT NOT NULL, + note TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'needs_changes')), + reviewed_by_user_id TEXT REFERENCES users(id), + rejection_reason TEXT, + public_link_id TEXT REFERENCES links(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + reviewed_at TEXT +); + +CREATE INDEX promotion_status_idx ON promotion_submissions(status, created_at DESC); + +CREATE TABLE click_daily ( + link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE, + day TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (link_id, day) +); +``` + +--- + +## 4. 分阶段实施计划 + +## Phase 1 — Cloudflare skeleton + core shortlink logic + +### Task 1: 创建 Cloudflare React + Workers app 骨架 + +**Objective:** 新建 `cloudflare-shortlinks/`,能本地启动一个 React SPA + Worker API。 + +**Files:** +- Create: `cloudflare-shortlinks/package.json` +- Create: `cloudflare-shortlinks/wrangler.jsonc` +- Create: `cloudflare-shortlinks/vite.config.ts` +- Create: `cloudflare-shortlinks/src/main.tsx` +- Create: `cloudflare-shortlinks/src/App.tsx` +- Create: `cloudflare-shortlinks/worker/index.ts` + +**Step 1: Scaffold** + +Run inside `/home/ai-bot/code/links`: + +```bash +npm create cloudflare@latest -- cloudflare-shortlinks --framework=react --typescript --git=false +``` + +Expected: creates React + Vite + Worker app. + +**Step 2: Add routing library and validation** + +```bash +cd /home/ai-bot/code/links/cloudflare-shortlinks +npm install hono zod nanoid +npm install -D vitest @cloudflare/vitest-pool-workers typescript +``` + +Expected: dependencies installed. + +**Step 3: Verify dev server** + +```bash +npm run dev +``` + +Expected: local Worker dev server starts and React page loads. + +**Step 4: Commit** + +```bash +git add cloudflare-shortlinks +git commit -m "feat: scaffold cloudflare shortlinks app" +``` + +### Task 2: 配置 D1 与初始 schema + +**Objective:** 添加 D1 binding 和第一版 schema migration。 + +**Files:** +- Modify: `cloudflare-shortlinks/wrangler.jsonc` +- Create: `cloudflare-shortlinks/migrations/0001_init.sql` + +**Step 1: Update wrangler config** + +`wrangler.jsonc` 关键配置: + +```jsonc +{ + "name": "heygo-shortlinks", + "main": "worker/index.ts", + "compatibility_date": "2026-06-20", + "assets": { + "directory": "./dist", + "not_found_handling": "single-page-application" + }, + "observability": { "enabled": true }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "heygo-shortlinks", + "database_id": "TODO_FROM_WRANGLER", + "migrations_dir": "migrations" + } + ], + "kv_namespaces": [ + { + "binding": "PUBLIC_LINK_CACHE", + "id": "TODO_FROM_WRANGLER" + } + ] +} +``` + +**Step 2: Create local migration SQL** + +Use schema from section 3. + +**Step 3: Run local migration** + +```bash +npx wrangler d1 migrations apply heygo-shortlinks --local +``` + +Expected: migration applied locally. + +**Step 4: Commit** + +```bash +git add cloudflare-shortlinks/wrangler.jsonc cloudflare-shortlinks/migrations/0001_init.sql +git commit -m "feat: add D1 schema for shortlinks" +``` + +### Task 3: 实现 alias 规范化与校验 + +**Objective:** 统一 alias 行为,避免 Django 旧逻辑里创建允许 `_`/`-` 但重定向时剥离特殊字符的问题。 + +**Files:** +- Create: `cloudflare-shortlinks/worker/lib/aliases.ts` +- Create: `cloudflare-shortlinks/tests/aliases.test.ts` + +**Step 1: Write failing tests** + +```ts +import { describe, expect, it } from 'vitest' +import { normalizeAlias, validateAlias } from '../worker/lib/aliases' + +describe('aliases', () => { + it('lowercases aliases', () => { + expect(normalizeAlias('Claude')).toBe('claude') + }) + + it('keeps hyphen and underscore instead of stripping them', () => { + expect(normalizeAlias('my_link-1')).toBe('my_link-1') + }) + + it('rejects path separators and empty aliases', () => { + expect(validateAlias('')).toEqual({ ok: false, error: 'Alias is required' }) + expect(validateAlias('a/b')).toEqual({ ok: false, error: 'Alias may contain only letters, numbers, underscore, and hyphen' }) + }) +}) +``` + +**Step 2: Run test to verify failure** + +```bash +npm test -- tests/aliases.test.ts +``` + +Expected: FAIL because functions do not exist. + +**Step 3: Implement** + +```ts +export type ValidationResult = { ok: true; value: string } | { ok: false; error: string } + +const ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,99}$/ + +export function normalizeAlias(input: string): string { + return input.trim().toLowerCase() +} + +export function validateAlias(input: string): ValidationResult { + const alias = normalizeAlias(input) + if (!alias) return { ok: false, error: 'Alias is required' } + if (!ALIAS_RE.test(alias)) { + return { ok: false, error: 'Alias may contain only letters, numbers, underscore, and hyphen' } + } + return { ok: true, value: alias } +} +``` + +**Step 4: Run test** + +```bash +npm test -- tests/aliases.test.ts +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add cloudflare-shortlinks/worker/lib/aliases.ts cloudflare-shortlinks/tests/aliases.test.ts +git commit -m "feat: validate shortlink aliases" +``` + +### Task 4: 实现模板 URL 参数替换 + +**Objective:** 兼容 `{param,default=value}` 并支持 path/query 参数。 + +**Files:** +- Create: `cloudflare-shortlinks/worker/lib/templates.ts` +- Create: `cloudflare-shortlinks/tests/templates.test.ts` + +**Step 1: Write failing tests** + +Cover: + +- default value +- path param fills first variable +- query param overrides path param +- missing required param throws typed error +- Chinese/static unicode around template param does not break URL + +**Step 2: Minimal implementation** + +Core API: + +```ts +export type TemplateInput = { + targetUrl: string + pathParam?: string + query: URLSearchParams +} + +export function resolveTemplateUrl(input: TemplateInput): string { + // parse placeholders with /\{([^{}]+)\}/g + // split first comma only + // replace each placeholder with encodeURIComponent(value) + // return final URL string +} +``` + +**Step 3: Verify** + +```bash +npm test -- tests/templates.test.ts +``` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add cloudflare-shortlinks/worker/lib/templates.ts cloudflare-shortlinks/tests/templates.test.ts +git commit -m "feat: resolve parameterized shortlink URLs" +``` + +### Task 5: 实现公共短链重定向路由 + +**Objective:** `heygo.cc/` 可从 D1 读取 public link 并跳转。 + +**Files:** +- Modify: `cloudflare-shortlinks/worker/index.ts` +- Create: `cloudflare-shortlinks/worker/routes/redirect.ts` +- Create: `cloudflare-shortlinks/worker/lib/responses.ts` +- Create: `cloudflare-shortlinks/tests/redirect.test.ts` + +**Step 1: Write tests** + +Cases: + +- public redirect found -> 302 Location +- public custom -> 200 HTML +- unknown alias on `heygo.cc` -> 404 +- parameterized alias -> resolved URL + +**Step 2: Implement route** + +Pseudo-flow: + +```ts +if (host === 'heygo.cc' && isAliasPath(pathname)) { + const link = await getPublicLink(env, alias) + if (!link) return publicNotFound(alias) + return renderOrRedirect(link, request, pathParam) +} +``` + +**Step 3: Add click count asynchronously** + +Use `ctx.waitUntil(recordClick(env.DB, link.id))`; do not block redirect on analytics failure. + +**Step 4: Verify** + +```bash +npm test -- tests/redirect.test.ts +npm run dev +curl -I 'http://localhost:8787/claude' -H 'Host: heygo.cc' +``` + +Expected: seeded alias returns 302. + +**Step 5: Commit** + +```bash +git add cloudflare-shortlinks/worker/index.ts cloudflare-shortlinks/worker/routes/redirect.ts cloudflare-shortlinks/tests/redirect.test.ts +git commit -m "feat: resolve public shortlink redirects" +``` + +## Phase 2 — Auth + private shortlinks + +### Task 6: Auth provider spike and decision checkpoint + +**Objective:** 在 Worker runtime 中验证 Better Auth/Auth.js 的 D1 + Google + GitHub 是否能本地跑通。 + +**Files:** +- Create: `cloudflare-shortlinks/docs/auth-spike.md` +- Create/Modify after decision: `cloudflare-shortlinks/worker/auth.ts` + +**Decision criteria:** + +- Must run on Cloudflare Workers runtime, not require Node `fs/net/crypto` incompatible APIs. +- Must support Google and GitHub OAuth. +- Must store users/accounts/sessions in D1 or easily adapt to existing tables. +- Must support secure HttpOnly cookie sessions under `heygo.cc` and visible to `my.heygo.cc` if cookie domain is `.heygo.cc`. + +**Preferred path:** Better Auth Cloudflare integration if spike passes. + +**Fallback path:** Auth.js D1 adapter or manual OAuth with a small edge-compatible OAuth helper. + +**Apple checkpoint:** Add Apple provider only after confirming paid Apple Developer Program is available; otherwise keep disabled. + +**Commit** + +```bash +git add cloudflare-shortlinks/docs/auth-spike.md cloudflare-shortlinks/worker/auth.ts +git commit -m "docs: record auth provider decision for workers" +``` + +### Task 7: Implement session guard + +**Objective:** Worker can identify current user for API and `my.heygo.cc` routes. + +**Files:** +- Modify: `cloudflare-shortlinks/worker/auth.ts` +- Modify: `cloudflare-shortlinks/worker/index.ts` +- Create: `cloudflare-shortlinks/tests/auth.test.ts` + +**Tests:** + +- no cookie -> `getCurrentUser()` returns null +- valid session -> returns user +- expired session -> null +- admin role -> `requireAdmin()` passes + +**Verification:** + +```bash +npm test -- tests/auth.test.ts +``` + +Expected: PASS. + +### Task 8: Implement private redirect behavior + +**Objective:** `my.heygo.cc/` behaves exactly as requested. + +**Files:** +- Modify: `cloudflare-shortlinks/worker/routes/redirect.ts` +- Modify: `cloudflare-shortlinks/worker/lib/responses.ts` +- Create/Modify: `cloudflare-shortlinks/tests/redirect.test.ts` + +**Tests:** + +- unauthenticated `my.heygo.cc/foo` -> 404 with login link +- authenticated user A `foo` -> resolves user A private link +- authenticated user B `foo` -> resolves user B private link or 404 if absent +- private alias never falls back to public link on `my.heygo.cc` + +**Verification:** + +```bash +npm test -- tests/redirect.test.ts +``` + +Expected: PASS. + +### Task 9: Implement link CRUD API + +**Objective:** 登录用户可创建/编辑/删除自己的 private links;管理员可创建 public links。 + +**Files:** +- Create: `cloudflare-shortlinks/worker/routes/api.links.ts` +- Create: `cloudflare-shortlinks/src/lib/api.ts` +- Create: `cloudflare-shortlinks/tests/api.links.test.ts` + +**API endpoints:** + +```text +GET /api/links/private +POST /api/links/private +PATCH /api/links/private/:id +DELETE /api/links/private/:id +GET /api/links/public +POST /api/admin/public-links # admin only +PATCH /api/admin/public-links/:id # admin only +DELETE /api/admin/public-links/:id # admin only / soft delete +``` + +**Validation:** + +Use Zod schemas in `worker/routes/api.links.ts` or shared `src/lib/validators.ts`: + +```ts +const linkInputSchema = z.object({ + alias: z.string().min(1).max(100), + linkType: z.enum(['redirect', 'custom']), + targetUrl: z.string().url().optional(), + contentMarkdown: z.string().optional(), + description: z.string().optional(), +}) +``` + +**Tests:** + +- create private link lowercases alias +- duplicate private alias for same user rejected +- same alias for different users allowed +- non-admin cannot create public link +- admin can create public link + +### Task 10: Build private/public management UI + +**Objective:** React UI can manage private links and browse public links. + +**Files:** +- Modify: `cloudflare-shortlinks/src/App.tsx` +- Create: `cloudflare-shortlinks/src/routes/LoginPage.tsx` +- Create: `cloudflare-shortlinks/src/routes/PrivateLinksPage.tsx` +- Create: `cloudflare-shortlinks/src/routes/PublicLinksPage.tsx` +- Create: `cloudflare-shortlinks/src/components/LinkForm.tsx` +- Create: `cloudflare-shortlinks/src/components/LinkTable.tsx` +- Create: `cloudflare-shortlinks/src/components/ProviderButtons.tsx` + +**UI requirements:** + +- shadcn Button/Input/Dialog/Table/DropdownMenu/Checkbox/Toast +- private links table supports multi-select +- create form supports: + - alias + - regular URL + - custom markdown + - parameterized URL hint and examples +- public links page is read-only for normal users +- admin sees extra actions + +**Verification:** + +```bash +npm run build +npm run dev +npx playwright test +``` + +Expected: UI flows pass. + +## Phase 3 — Promotion workflow + +### Task 11: Promotion submission API + +**Objective:** 用户可以把 private links 推荐到 public queue。 + +**Files:** +- Create: `cloudflare-shortlinks/worker/routes/api.promotions.ts` +- Create: `cloudflare-shortlinks/tests/api.promotions.test.ts` + +**Endpoints:** + +```text +POST /api/promotions +GET /api/promotions/mine +GET /api/admin/promotions?status=pending +POST /api/admin/promotions/:id/approve +POST /api/admin/promotions/:id/reject +POST /api/admin/promotions/:id/needs-changes +``` + +**Tests:** + +- user can submit own private link +- user cannot submit another user’s private link +- duplicate pending submission rejected +- admin approval creates public link +- approval alias conflict returns 409 and does not approve +- rejection stores reason + +### Task 12: Promotion UI + +**Objective:** 用户多选推荐;管理员审核。 + +**Files:** +- Modify: `cloudflare-shortlinks/src/routes/PrivateLinksPage.tsx` +- Create: `cloudflare-shortlinks/src/routes/AdminReviewPage.tsx` +- Create: `cloudflare-shortlinks/src/components/PromotionDialog.tsx` + +**Verification:** + +```bash +npx playwright test tests/e2e/promotions.spec.ts +``` + +Expected: user submits, admin approves, `heygo.cc/` resolves. + +## Phase 4 — Django data export/import + +### Task 13: Add Django export command + +**Objective:** 从现有 Linux/Django app 导出短链为 JSONL,不迁移 pages/posts/images。 + +**Files:** +- Create: `links/management/commands/export_shortlinks_jsonl.py` +- Create: `tests/test_export_shortlinks_jsonl.py` + +**Output format:** + +```json +{"alias":"claude","link_type":"redirect","target_url":"https://claude.ai","content_markdown":null,"description":"","tags":["ai"],"created_at":"...","updated_at":"..."} +``` + +**Rules:** + +- 所有 Linux/Django 短链先导入为某个 admin 用户的 private links。 +- 不自动导入 public,避免把私人历史数据暴露给全球。 +- alias 冲突时导入脚本生成 report,不静默覆盖。 + +**Verification:** + +```bash +python manage.py export_shortlinks_jsonl --output /tmp/shortlinks.jsonl +python -m json.tool /tmp/shortlinks.jsonl +``` + +Expected: JSONL valid; row count equals `Link.objects.count()`. + +### Task 14: Add D1 import script + +**Objective:** 把 JSONL 导入 Cloudflare D1 private links。 + +**Files:** +- Create: `cloudflare-shortlinks/scripts/import-links-to-d1.ts` + +**Command:** + +```bash +npm run import:links -- --file ./exports/shortlinks.jsonl --owner-email admin@example.com --local +``` + +**Behavior:** + +- lookup/create owner user +- validate alias and URL templates +- insert as `scope='private'` +- write `import-report.json` listing imported/skipped/conflicts + +**Verification:** + +```bash +npm run import:links -- --file ./exports/shortlinks.jsonl --owner-email --local +npx wrangler d1 execute heygo-shortlinks --local --command "SELECT COUNT(*) FROM links WHERE scope='private';" +``` + +Expected: count equals imported count from report. + +## Phase 5 — Cloudflare deployment + +### Task 15: Create Cloudflare resources + +**Objective:** 创建 D1/KV/secrets/custom domains。 + +**Commands:** + +```bash +cd /home/ai-bot/code/links/cloudflare-shortlinks +npx wrangler d1 create heygo-shortlinks +npx wrangler kv namespace create PUBLIC_LINK_CACHE +npx wrangler secret put GOOGLE_CLIENT_ID +npx wrangler secret put GOOGLE_CLIENT_SECRET +npx wrangler secret put GITHUB_CLIENT_ID +npx wrangler secret put GITHUB_CLIENT_SECRET +npx wrangler secret put SESSION_SECRET +``` + +Then update `wrangler.jsonc` with generated IDs. + +**Custom domains/routes:** + +- `heygo.cc/*` -> `heygo-shortlinks` +- `my.heygo.cc/*` -> `heygo-shortlinks` + +**Do not enable Apple secrets unless paid Apple Developer Program credentials exist:** + +```bash +npx wrangler secret put APPLE_CLIENT_ID +npx wrangler secret put APPLE_TEAM_ID +npx wrangler secret put APPLE_KEY_ID +npx wrangler secret put APPLE_PRIVATE_KEY +``` + +### Task 16: Production migration and smoke test + +**Objective:** Apply schema, import private links, deploy, verify domain behavior. + +**Commands:** + +```bash +npx wrangler d1 migrations apply heygo-shortlinks --remote +npm run build +npx wrangler deploy +``` + +**Smoke tests:** + +```bash +curl -I https://heygo.cc/claude +curl -i https://heygo.cc/definitely-not-existing +curl -i https://my.heygo.cc/claude +``` + +Expected: + +- public existing alias -> 302 +- public missing alias -> 404 +- private unauthenticated alias -> 404 body includes login link + +--- + +## 5. Risks / tradeoffs / open questions + +### 5.1 Apple 登录是否支持 + +- 风险:Apple web sign-in 需要 $99/year Apple Developer Program。 +- Plan decision:Google/GitHub 先上;Apple provider 预留但 feature flag off。 +- Action:实施前登录 Apple Developer Portal 实测 Service ID/Sign in with Apple capability 是否要求 paid team。 + +### 5.2 KV eventual consistency + +- 风险:公开短链修改后,全球某些地区可能短时间读到旧目标。 +- Mitigation:D1 为准;KV 只缓存 public redirects;后台提示“公开短链修改最多 60 秒全球生效”。如不能接受,MVP 先不启用 KV。 + +### 5.3 D1 写热点 + +- 风险:热门公开短链每次点击更新 D1 计数会形成写热点。 +- MVP:`ctx.waitUntil` 写 `click_daily`,失败不影响跳转。 +- 后续:用 Queues batching 或 Analytics Engine 存点击流。 + +### 5.4 私人短链不能公开分享 + +- 这是当前域名设计的自然结果:`my.heygo.cc/` 依赖登录用户。 +- 如果未来想分享个人公开页,需要新增 `u//` 或 `my.heygo.cc/@user/`,不是本 MVP 范围。 + +### 5.5 与现有 Django Links 的范围边界 + +- 本计划只迁移短链功能。 +- 不迁移 pages/posts/images/files/invest/price monitor 等 Links app 其它模块。 +- 迁移导入默认为 private,避免误公开历史数据。 + +--- + +## 6. Validation checklist + +Before calling the Cloudflare version ready: + +- [ ] `heygo.cc/` anonymous redirect works. +- [ ] `heygo.cc/` anonymous custom page works. +- [ ] `heygo.cc/` returns public 404. +- [ ] `my.heygo.cc/` unauthenticated returns 404 + login link. +- [ ] Google login works. +- [ ] GitHub login works. +- [ ] Private aliases are isolated per user. +- [ ] Parameterized URL default and override match Django tests. +- [ ] User can create private regular/custom/parameterized links. +- [ ] User can multi-select recommend private links. +- [ ] Admin can approve/reject promotions. +- [ ] Approved promotion appears on `heygo.cc/` without login. +- [ ] Django export row count matches imported private row count. +- [ ] Production deploy has secrets, D1 migration, domains, and smoke tests passing. + +--- + +## 7. Suggested execution order + +1. Phase 1: skeleton + D1 + alias/template/public redirect. +2. Phase 2: auth + private redirect + CRUD UI. +3. Phase 3: promotion queue + admin review. +4. Phase 4: Django export/import. +5. Phase 5: deploy to Cloudflare and smoke test. + +Do not start production deploy until local `wrangler dev`, Vitest, and Playwright smoke tests pass.