Initial commit: Sproutlink short URL system

This commit is contained in:
2026-06-18 20:08:22 +08:00
commit 45875e9feb
40 changed files with 11485 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
.wrangler/
.dev.vars
.env.local
*.env
# Cloudflare credentials / deployment secrets
cf-token
d1-id.txt
deploy.ps1
DEPLOY.md
worker/wrangler.toml

23
migrations/0001_init.sql Normal file
View File

@@ -0,0 +1,23 @@
-- Sequence table: single-row counter for short link code generation
CREATE TABLE IF NOT EXISTS link_seq (
id INTEGER PRIMARY KEY CHECK (id = 1),
next_id INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO link_seq (id, next_id) VALUES (1, 0);
-- Short links table
CREATE TABLE IF NOT EXISTS short_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
target_url TEXT NOT NULL,
expires_at INTEGER NULL, -- Unix seconds, NULL = never expires
max_clicks INTEGER NULL, -- NULL = unlimited
clicks INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NULL, -- NULL = no password; format: "pbkdf2:<salt>:<hash>"
deleted INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_short_links_code ON short_links (code);
CREATE INDEX IF NOT EXISTS idx_short_links_expires ON short_links (expires_at) WHERE expires_at IS NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE short_links ADD COLUMN client_rule TEXT NULL;

View File

@@ -0,0 +1,6 @@
-- Cumulative page views (SPA: POST /api/pv increments)
CREATE TABLE IF NOT EXISTS app_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
page_views INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO app_stats (id, page_views) VALUES (1, 0);

View File

@@ -0,0 +1 @@
ALTER TABLE short_links ADD COLUMN ip_rule TEXT NULL;

8162
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "sproutlink",
"version": "1.0.0",
"private": true,
"workspaces": ["worker", "web"],
"scripts": {
"dev:worker": "npm run dev --workspace=worker",
"dev:web": "npm run dev --workspace=web",
"build:web": "npm run build --workspace=web",
"deploy:worker": "npm run deploy --workspace=worker"
}
}

227
shortlink-design-summary.md Normal file
View File

@@ -0,0 +1,227 @@
# 短链系统设计方案对比与融合总结
## 一、核心目标
短链系统本质只做两件事:
1. **压缩**:把长网址映射成短字符串。
2. **跳转**:用户访问短链时,准确、快速地返回长链。
围绕这两件事,不同场景会衍生出容量、并发、成本、安全、业务规则等差异。
---
## 二、方案 ACloudflare Sproutlink当前项目
### 2.1 架构组成
| 组件 | 说明 |
|------|------|
| 边缘运行时 | Cloudflare Worker |
| 数据库 | Cloudflare D1SQLite |
| 静态前端 | Vite React SPA通过 `ASSETS` 绑定由 Worker 直接服务 |
| 短码生成 | D1 单表原子自增 + 线性同余混洗 + Base62 编码 |
| 部署 | Serverless静态资源不计入 Worker Bundle |
### 2.2 短码生成原理
1. **原子自增序列**`link_seq` 表保存 `next_id`,每次创建时 `UPDATE ... SET next_id = next_id + 1 RETURNING next_id`
2. **线性同余混洗**:把顺序的 `seq` 映射到 `[0, 62^4)` 空间,公式为 `index = (A × seq + B) mod 62^4`
- `A`:与 `62^4` 互质的乘数(`LINK_MULTIPLIER` secret默认内置大质数
- `B`:偏移量(`LINK_OFFSET` secret
- 双射特性保证唯一,同时让短码看起来“不连续”。
3. **Base62 编码**:字符集 `0-9A-Za-z`,固定 **4 位**,容量 `62^4 ≈ 1477 万`
4. **自定义短码**:管理员可指定 132 位 `a-zA-Z0-9_-`,但不可为恰好 4 位纯英数字,避免与自动码池冲突。
### 2.3 跳转与规则校验
- 使用 **HTTP 302 临时重定向**,确保每次点击都经过 Worker便于统计。
- 访问流程:查 D1 → 校验过期/点击上限 → 校验 Geo/设备/浏览器/IP/密码 → 302 跳转并原子递增 `clicks`
### 2.4 业务规则能力
| 规则 | 实现 |
|------|------|
| 有效期 | `expires_at` 字段 |
| 点击上限 | `max_clicks` 字段 |
| 密码保护 | PBKDF2 哈希,密码页表单验证 |
| Geo 限制 | `request.cf.country` |
| 设备/浏览器 | `User-Agent` + `Sec-CH-UA*` |
| IP 白/黑名单 | `CF-Connecting-IP`,支持 IPv4 CIDR |
| 软删除 | `deleted` 字段 |
### 2.5 优势特点
- **成本极低**Serverless + D1 + 静态托管,免费/低价套餐即可运行。
- **部署极简**单人可维护无需维护服务器、Redis、MySQL 集群。
- **短码最短**4 位 Base62对二维码、短信字数敏感场景友好。
- **访问规则丰富**密码、Geo、设备、浏览器、IP 规则开箱即用,适合私域/营销场景。
- **短码防扫号**:线性同余混洗让相邻创建的短码不是连续递增,提高被遍历的难度。
### 2.6 局限
- **容量有限**4 位码仅 1477 万,公开大规模服务很快耗尽。
- **读并发依赖 D1**:无独立缓存层,热点短链会直接把压力打到数据库。
- **无缓存穿透防护**:缺少布隆过滤器或空值缓存,面对恶意遍历存在一定风险。
- **Cloudflare 生态绑定**:迁移或私有化部署成本较高。
---
## 三、方案 B大规模分布式短链设计经典大厂方案
### 3.1 架构组成
| 组件 | 说明 |
|------|------|
| 发号器 | 分布式 ID 发号器MySQL 号段模式 / Redis 自增) |
| 数据库 | MySQL 持久化存储 `code → target_url` |
| 缓存 | Redis 读缓存 |
| 防护 | 布隆过滤器 + 空值缓存 + 网关限流 |
| 跳转状态码 | HTTP 302 |
### 3.2 短码生成原理
1. **分布式 ID 发号器**:为每个长链接分配一个全局唯一、单调递增的数字 ID。
2. **Base62 编码**:把十进制 ID 转换成 62 进制,固定 **6 位**
- 字符集 `0-9a-zA-Z`
- 容量 `62^6 ≈ 568 亿`,足够公开服务长期运行。
3. **唯一索引兜底**MySQL 对 `code` 加唯一索引,防止并发冲突。
### 3.3 读链路(高并发重点)
```
用户点击 /abc123
网关限流 + WAF
Redis 查 sl:abc123
├─ 命中 → 直接返回 302
└─ 未命中 → 布隆过滤器判断
├─ 一定不存在 → 直接 404
└─ 可能存在 → 查 MySQL
├─ 存在 → 写入 Redis → 302
└─ 不存在 → 写短时空值到 Redis → 404
```
### 3.4 写链路
```
用户提交长链
参数校验、黑名单域名过滤
分布式发号器取号
Base62 编码
MySQL 插入(唯一索引防冲突)
写入 Redis + 布隆过滤器
返回短链
```
### 3.5 优势特点
- **容量巨大**6 位码 568 亿,远超 4 位方案。
- **读性能强**Redis 缓存扛住读多写少特性,适合热点营销短信/微博场景。
- **生产级健壮**:布隆过滤器防缓存穿透、空值缓存防击穿、网关限流防刷。
- **去 Cloudflare 依赖**:技术栈通用,易于私有化或多云部署。
### 3.6 局限
- **成本高**:需要维护 MySQL、Redis、发号器、网关等多套组件。
- **短码更长**6 位比 4 位在短信/二维码场景略逊。
- **业务规则薄弱**标准方案通常只讲“跳转”缺少密码、Geo、设备等访问控制能力。
---
## 四、两套方案核心差异对比
| 维度 | SproutlinkCloudflare | 大规模分布式方案 |
|------|--------------------------|------------------|
| **短码长度** | 4 位 | 6 位 |
| **容量** | 62⁴ ≈ 1477 万 | 62⁶ ≈ 568 亿 |
| **发号方式** | D1 原子自增 + 线性同余混洗 | 分布式 ID 发号器 |
| **存储** | Cloudflare D1 | MySQL + Redis |
| **读缓存** | 无 | Redis |
| **防缓存穿透** | 无 | 布隆过滤器 + 空值缓存 |
| **并发能力** | 中等,依赖 D1 | 高,依赖 Redis |
| **访问规则** | 丰富Geo/设备/IP/密码) | 通常无或简单 |
| **部署成本** | 极低 | 较高 |
| **适用场景** | 私域、小团队、个人、轻量营销 | 公开互联网、大厂、海量用户 |
---
## 五、融合后的推荐设计
如果让我从零设计一个**可大可小、可进化的短链系统**,我会把两套方案取长补短:
### 5.1 用大规模方案做底座
- **短码长度**:默认 6 位 Base62容量 568 亿。
- **发号器**:采用 **MySQL 号段模式**Segment每个实例本地批量领号减少 DB 压力。
- **存储**MySQL 持久化 + Redis 读缓存。
- **防护**:布隆过滤器 + 空值缓存 + 网关限流。
- **状态码**:统一使用 **302**,保证点击量可统计。
### 5.2 吸收 Sproutlink 的规则引擎
| 规则 | 实现位置 |
|------|----------|
| 有效期 | MySQL `expires_at` + 定时清理任务 |
| 点击上限 | MySQL 原子递增 `clicks` / `max_clicks` |
| 密码保护 | Worker / API 网关,密码页验证后 302 |
| Geo 限制 | CDN 边缘Cloudflare、阿里云 CDN 等)或网关解析 `CF-IPCountry` |
| 设备/浏览器 | 解析 `User-Agent` + Client Hints |
| IP 白/黑名单 | 网关层,支持 IPv4 / IPv6 / CIDR |
| 自定义短码 | 管理员接口,与自动 6 位码池隔离 |
### 5.3 可选:保留短码混洗能力
在 6 位方案中,可以**可选地**对发号器拿到的顺序 ID 做一次可逆混洗(如线性同余),让短码看起来不连续,提升防扫号能力。注意混洗函数必须保证双射。
### 5.4 最终数据流
**写(生成短链)**
```
提交长链
→ 校验 + 黑名单过滤
→ 号段发号器取唯一 ID
→ 可选混洗
→ Base62 编码
→ MySQL 写入
→ 写入 Redis + 布隆过滤器
→ 返回 6 位短链
```
**读(访问短链)**
```
用户访问 /abc123
→ 网关限流/WAF
→ Redis 查缓存
├─ 命中 → 规则校验 → 302
└─ 未命中 → 布隆过滤器
├─ 不存在 → 404
└─ 可能存在 → MySQL 查询
├─ 存在 → 写 Redis → 规则校验 → 302
└─ 不存在 → 写空值缓存 → 404
```
---
## 六、选型建议
| 场景 | 推荐方案 |
|------|----------|
| 个人博客、小团队、私域运营、预算敏感 | **SproutlinkCloudflare** |
| 公开互联网、大厂、营销短信、微博 | **大规模分布式方案6 位 + 发号器 + Redis** |
| 希望初期低成本、后期可平滑扩展 | **融合方案**Worker/D1 起步,用户量增长后迁移到 MySQL + Redis |
---
## 七、一句话总结
> **Sproutlink 是“小而美、功能全、低成本”的私域短链方案;大规模分布式方案是“高容量、高并发、抗攻击”的公开短链标准架构。最优实践是把后者的容量与并发能力,与前者的访问规则引擎结合起来,按需分层演进。**

19
web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SproutLink</title>
<meta name="description" content="短链与二维码" />
<meta name="theme-color" content="#fafafa" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "sproutlink-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"gen:pwa-icons": "node scripts/gen-pwa-icons.mjs"
},
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"sharp": "^0.34.5",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vite-plugin-pwa": "^1.2.0"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
web/public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
web/public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -0,0 +1,23 @@
/**
* Regenerate PWA icons from public/logo.png (run after logo change).
* node scripts/gen-pwa-icons.mjs
*/
import sharp from 'sharp';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pub = join(__dirname, '..', 'public');
const src = join(pub, 'logo.png');
await sharp(src)
.resize(192, 192, { fit: 'cover', position: 'center' })
.png()
.toFile(join(pub, 'pwa-192x192.png'));
await sharp(src)
.resize(512, 512, { fit: 'cover', position: 'center' })
.png()
.toFile(join(pub, 'pwa-512x512.png'));
console.log('Wrote public/pwa-192x192.png, public/pwa-512x512.png');

510
web/src/App.css Normal file
View File

@@ -0,0 +1,510 @@
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
align-items: stretch;
}
/* Header */
.site-header {
position: sticky;
top: 0;
z-index: 50;
width: 100%;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.header-inner {
max-width: var(--doc-max);
margin: 0 auto;
padding: 0 1.5rem;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
}
.app-layout--home .header-inner {
max-width: min(var(--content-max), 100% - 2rem);
}
.header-nav {
display: flex;
align-items: center;
gap: 0.15rem;
}
.nav-btn {
padding: 0.25rem 0.6rem;
font-size: 0.8125rem;
font-weight: 500;
background: transparent;
color: var(--muted);
text-decoration: none;
}
.nav-btn:hover { color: var(--text); }
.nav-btn.active {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
.nav-btn-ghost { font-size: 0.8125rem; color: var(--muted); }
/* Main + document shell */
.doc-main {
flex: 1;
width: 100%;
margin: 0;
padding: 1.75rem 1.5rem 2.5rem;
}
.doc-container {
max-width: var(--doc-max);
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
/* 首页:统计在上、主内容下,整体收拢居中 */
.doc-container.doc-home {
max-width: min(var(--content-max), 100% - 1.5rem);
}
.doc-home-layout {
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.doc-home-stats {
width: 100%;
}
.doc-col-main {
min-width: 0;
}
/* 首页三列统计:顶部横排(桌面/手机同结构) */
.doc-home .stats-bar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.doc-home .stat-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 3.6rem;
padding: 0.55rem 0.4rem;
}
.doc-home .stat-value {
font-size: 1.2rem;
}
.doc-home .stat-label {
font-size: 0.64rem;
text-transform: none;
letter-spacing: 0.04em;
margin-top: 0.1rem;
}
@media (min-width: 600px) {
.doc-home .stat-value {
font-size: 1.35rem;
}
.doc-home .stat-card {
min-height: 3.9rem;
padding: 0.65rem 0.5rem;
}
}
@media (max-width: 899px) {
.doc-main {
padding: 1.1rem 0.75rem 1.75rem;
}
.page-title {
margin-bottom: 0.75rem;
}
.paper, .card {
padding: 1.1rem 1rem;
}
.result-box {
padding: 0.85rem 0.8rem;
margin-top: 0.85rem;
}
.result-box-body {
align-items: center;
}
.result-qr {
align-items: center;
width: 100%;
}
.result-qr-img,
.result-qr img {
width: min(200px, 78vw) !important;
max-width: 100%;
}
.result-text > div {
justify-content: center !important;
}
.result-meta {
text-align: center;
max-width: 100%;
}
}
.page-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
margin-bottom: 1rem;
letter-spacing: 0.02em;
}
/* 统计条(非首页场景保留横向) */
.stats-bar {
display: flex;
flex-direction: row;
gap: 0.75rem;
width: 100%;
}
.stat-card {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
padding: 0.8rem 0.9rem;
background: var(--surface);
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1.2;
}
.stat-label {
font-size: 0.6875rem;
color: var(--muted);
margin-top: 0.2rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.site-footer {
width: 100%;
text-align: center;
padding: 1.25rem 1.5rem;
color: var(--faint);
font-size: 0.75rem;
border-top: 1px solid var(--line);
}
.site-footer a { color: var(--faint); text-decoration: none; }
.site-footer a:hover { text-decoration: underline; color: var(--muted); }
/* Paper / card */
.paper, .card {
border: 1px solid var(--line);
background: var(--surface);
padding: 1.5rem 1.35rem;
border-radius: var(--radius);
}
/* Buttons */
.btn-primary {
background: var(--ink);
color: var(--surface);
padding: 0.5rem 1.1rem;
font-weight: 500;
font-size: 0.9rem;
text-decoration: none;
}
.btn-primary:hover { background: #262626; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
text-decoration: none;
}
.btn-danger:hover { background: var(--hover); }
.btn-ghost {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
text-decoration: none;
}
.btn-ghost:hover { background: var(--hover); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.page-num {
min-width: 2rem;
text-align: center;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding: 0.3rem 0.45rem;
}
.page-num.is-active {
background: var(--ink) !important;
color: var(--surface) !important;
border-color: var(--ink) !important;
}
/* Form */
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.85rem;
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.02em;
}
.form-hint {
font-size: 0.72rem;
color: var(--muted);
line-height: 1.35;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 560px) {
.form-row { grid-template-columns: 1fr; }
}
/* Result */
.result-box {
background: var(--hover);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.15rem;
margin-top: 1.1rem;
}
.result-box-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.1rem;
}
@media (min-width: 520px) {
.result-box-body {
flex-direction: row;
align-items: flex-start;
}
}
.result-qr {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
.result-qr img,
.result-qr-img {
display: block;
width: 200px;
max-width: 100%;
height: auto;
aspect-ratio: 1;
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
}
.result-qr-download {
font-size: 0.8rem;
text-decoration: none;
}
.result-text { flex: 1; min-width: 0; width: 100%; }
.result-url {
font-size: 0.95rem;
font-weight: 500;
color: var(--ink);
word-break: break-all;
text-decoration: none;
}
.result-url:hover { text-decoration: underline; }
.result-meta {
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--muted);
word-break: break-all;
}
/* Error (neutral) */
.error-msg {
background: var(--hover);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
color: var(--text);
font-size: 0.82rem;
margin-top: 0.85rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal-card {
background: var(--surface);
border: 1px solid var(--line);
padding: 1.5rem 1.4rem;
width: 100%;
max-width: 22rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.modal-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.9rem;
letter-spacing: 0.02em;
}
.modal-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.9rem;
justify-content: flex-end;
}
/* Table */
.link-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.link-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.link-table th {
text-align: left;
padding: 0.45rem 0.6rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--ink);
white-space: nowrap;
background: var(--surface);
}
.link-table td {
padding: 0.6rem 0.6rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.link-table tr:last-child td { border-bottom: none; }
.link-table tr:hover td { background: var(--hover); }
.tag {
display: inline-block;
padding: 0.08rem 0.32rem;
font-size: 0.65rem;
font-weight: 500;
border: 1px solid var(--line);
color: var(--muted);
background: var(--bg);
border-radius: 1px;
margin-left: 0.2rem;
vertical-align: middle;
line-height: 1.3;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-void, .tag-red {
color: var(--text);
border-color: var(--faint);
background: #f0f0f0;
}
.tag-ok, .tag-green {
color: var(--ink);
border-color: #c4c4c4;
background: var(--surface);
}
.tag-mute, .tag-gray, .tag-blue { color: var(--muted); }
.pagination {
display: flex;
gap: 0.3rem;
justify-content: center;
margin-top: 1.1rem;
flex-wrap: wrap;
align-items: center;
}
/* Edit drawer */
.edit-drawer {
border: 1px solid var(--line);
background: var(--surface);
padding: 1.1rem 1.15rem;
margin-bottom: 1rem;
border-radius: var(--radius);
}
.edit-drawer-title {
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.85rem;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.logotype {
text-decoration: none;
}
.logotype span {
text-decoration: none;
}
.link-muted {
color: var(--muted);
text-decoration: none;
font-size: 0.8rem;
}
.link-muted:hover { text-decoration: underline; color: var(--text); }
.admin-toolbar {
text-align: right;
font-size: 0.75rem;
color: var(--muted);
margin: -0.2rem 0 0.65rem;
}

105
web/src/App.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useState, useCallback } from 'react';
import Logo from './components/Logo';
import CreateForm from './components/CreateForm';
import AdminPanel from './components/AdminPanel';
import TokenModal from './components/TokenModal';
import Stats from './components/Stats';
import './App.css';
export default function App() {
const [isAdmin, setIsAdmin] = useState(() => !!sessionStorage.getItem('admin_token'));
const [showTokenModal, setShowTokenModal] = useState(false);
const [logoClickCount, setLogoClickCount] = useState(0);
const [view, setView] = useState<'home' | 'admin'>('home');
const handleLogoClick = useCallback(() => {
if (isAdmin) {
setView(v => (v === 'admin' ? 'home' : 'admin'));
return;
}
const next = logoClickCount + 1;
setLogoClickCount(next);
if (next >= 5) {
setLogoClickCount(0);
setShowTokenModal(true);
}
}, [logoClickCount, isAdmin]);
const handleTokenSuccess = () => {
setIsAdmin(true);
setShowTokenModal(false);
setView('admin');
};
const handleLogout = () => {
sessionStorage.removeItem('admin_token');
setIsAdmin(false);
setView('home');
};
return (
<div className={`app-layout${view === 'home' ? ' app-layout--home' : ''}`}>
<header className="site-header">
<div className="header-inner">
<Logo onClick={handleLogoClick} />
<nav className="header-nav" aria-label="主导航">
{isAdmin && (
<>
<button
type="button"
className={`nav-btn ${view === 'home' ? 'active' : ''}`}
onClick={() => setView('home')}
>
</button>
<button
type="button"
className={`nav-btn ${view === 'admin' ? 'active' : ''}`}
onClick={() => setView('admin')}
>
</button>
<button type="button" className="nav-btn nav-btn-ghost" onClick={handleLogout}>
退
</button>
</>
)}
</nav>
</div>
</header>
<main className="doc-main">
{view === 'home' && (
<div className="doc-container doc-home">
<div className="doc-home-layout">
<div className="doc-home-stats" aria-label="全站统计">
<Stats />
</div>
<div className="doc-col-main">
<h1 className="page-title"></h1>
<CreateForm isAdmin={isAdmin} />
</div>
</div>
</div>
)}
{view === 'admin' && isAdmin && (
<div className="doc-container">
<h1 className="page-title"></h1>
<AdminPanel />
</div>
)}
</main>
<footer className="site-footer">
<p>SproutLink</p>
</footer>
{showTokenModal && (
<TokenModal
onSuccess={handleTokenSuccess}
onClose={() => setShowTokenModal(false)}
/>
)}
</div>
);
}

160
web/src/api.ts Normal file
View File

@@ -0,0 +1,160 @@
// API client — talks to the Cloudflare Worker
const BASE = import.meta.env.VITE_API_BASE ?? '';
function authHeader(): Record<string, string> {
const token = sessionStorage.getItem('admin_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`${BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...authHeader(),
...(init.headers as Record<string, string> ?? {}),
},
});
}
// ---------------------------------------------------------------------------
export interface GeoRule {
mode: 'allow' | 'block';
countries: string[];
}
/** 终端与浏览器:与 Worker 内校验逻辑一致 */
export type ClientDevice = 'mobile' | 'desktop';
export type ClientBrowser = 'edge' | 'chrome' | 'safari' | 'firefox' | 'opera' | 'other';
export interface ClientRule {
/** null 表示不限制 */
device: ClientDevice | null;
/**
* 非空 = 仅允许名单内浏览器;空数组 = 不限制浏览器
* 与 null 在存储时统一为「无浏览器限制」
*/
browsers: ClientBrowser[] | null;
}
/** IP 白/黑名单,与边缘 CF-Connecting-IP 比较 */
export interface IpRule {
mode: 'allow' | 'block';
/** 支持 IPv4、IPv4 CIDR(如 10.0.0.0/8)、完整 IPv6 地址;最多约 200 条 */
ips: string[];
}
export interface LinkItem {
id: number;
code: string;
target_url: string;
expires_at: number | null;
max_clicks: number | null;
clicks: number;
has_password: number;
geo_rule: string | null; // JSON string of GeoRule, or null
client_rule: string | null; // JSON string of ClientRule, or null
ip_rule: string | null; // JSON string of IpRule, or null
deleted: number;
created_at: number;
updated_at: number;
}
export interface CreatePayload {
target_url: string;
/**
* 仅管理员可传132 位,仅 a-zA-Z0-9_-,且不能为「恰好 4 位纯英数字」以免与系统随机短码空间混淆。
*/
custom_code?: string | null;
ttl_seconds?: number | null;
max_clicks?: number | null;
password?: string | null;
geo_rule?: GeoRule | null;
client_rule?: ClientRule | null;
ip_rule?: IpRule | null;
}
export interface UpdatePayload {
target_url?: string;
expires_at?: number | null;
max_clicks?: number | null;
password?: string | null;
clear_password?: boolean;
geo_rule?: GeoRule | null;
client_rule?: ClientRule | null;
ip_rule?: IpRule | null;
deleted?: number;
}
export async function verifyAdmin(token: string): Promise<boolean> {
const res = await fetch(`${BASE}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
return res.ok;
}
export async function createLink(payload: CreatePayload): Promise<{ code: string; url: string; target_url: string }> {
const res = await apiFetch('/api/links', { method: 'POST', body: JSON.stringify(payload) });
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
return res.json();
}
export async function listLinks(page = 1, limit = 20): Promise<{ items: LinkItem[]; total: number; page: number; limit: number }> {
const res = await apiFetch(`/api/links?page=${page}&limit=${limit}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function encCode(code: string): string {
return encodeURIComponent(code);
}
export async function getLink(code: string): Promise<LinkItem> {
const res = await apiFetch(`/api/links/${encCode(code)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function updateLink(code: string, payload: UpdatePayload): Promise<void> {
const res = await apiFetch(`/api/links/${encCode(code)}`, { method: 'PATCH', body: JSON.stringify(payload) });
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
}
export async function deleteLink(code: string): Promise<void> {
const res = await apiFetch(`/api/links/${encCode(code)}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
export interface SiteStats {
total_links: number;
total_clicks: number;
/** 全站页浏览量(由前端进入站点时 POST /api/pv 累计) */
page_views: number;
}
export async function getStats(): Promise<SiteStats> {
const res = await fetch(`${BASE}/api/stats`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
/** 全站「浏览量」+1建议在应用根组件挂载时调用一次需配合 useRef 避免 Strict 双次) */
/** @returns 是否请求成功200失败可再次尝试上报 */
export async function recordPageView(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/api/pv`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
return res.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,37 @@
import type { ClientBrowser, ClientDevice, ClientRule } from './api';
export const DEVICE_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: '不限制' },
{ value: 'mobile', label: '仅手机端可访问' },
{ value: 'desktop', label: '仅电脑端可访问' },
];
export const BROWSER_OPTIONS: { id: ClientBrowser; label: string }[] = [
{ id: 'chrome', label: 'Chrome' },
{ id: 'edge', label: 'Edge' },
{ id: 'safari', label: 'Safari' },
{ id: 'firefox', label: 'Firefox' },
{ id: 'opera', label: 'Opera' },
];
export function buildClientRule(device: string, selected: Set<ClientBrowser>): ClientRule | null {
const d: ClientDevice | null = device === 'mobile' || device === 'desktop' ? device : null;
const browsers = BROWSER_OPTIONS.filter(o => selected.has(o.id)).map(o => o.id);
if (!d && browsers.length === 0) return null;
return { device: d, browsers: browsers.length > 0 ? browsers : null };
}
export function parseClientRuleJson(raw: string | null | undefined): { device: string; selected: Set<ClientBrowser> } {
if (!raw) return { device: '', selected: new Set() };
try {
const r = JSON.parse(raw) as ClientRule;
const device = r.device === 'mobile' || r.device === 'desktop' ? r.device : '';
const selected = new Set<ClientBrowser>();
for (const b of r.browsers ?? []) {
if (BROWSER_OPTIONS.some(o => o.id === b)) selected.add(b);
}
return { device, selected };
} catch {
return { device: '', selected: new Set() };
}
}

View File

@@ -0,0 +1,296 @@
import { useState, useEffect, useCallback } from 'react';
import { listLinks, updateLink, deleteLink, type ClientRule, type IpRule, type LinkItem, type GeoRule } from '../api';
import EditDrawer from './EditDrawer';
const PAGE_SIZE = 20;
function formatDate(ts: number | null): string {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function geoBadge(item: LinkItem): JSX.Element | null {
if (!item.geo_rule) return null;
let rule: GeoRule;
try { rule = JSON.parse(item.geo_rule); } catch { return null; }
const label = rule.mode === 'allow'
? `${rule.countries.join('/')}`
: `屏蔽 ${rule.countries.join('/')}`;
return <span className="tag tag-mute" style={{ marginLeft: '0.3rem' }} title={label}>{label}</span>;
}
function clientBadge(item: LinkItem): JSX.Element | null {
if (!item.client_rule) return null;
let r: ClientRule;
try { r = JSON.parse(item.client_rule); } catch { return null; }
const parts: string[] = [];
if (r.device === 'mobile') parts.push('仅手机');
if (r.device === 'desktop') parts.push('仅电脑');
if (r.browsers && r.browsers.length) {
const m: Record<string, string> = { chrome: 'Chrome', edge: 'Edge', safari: 'Safari', firefox: 'Fx', opera: 'Op', other: '其他' };
parts.push(r.browsers.map(b => m[b] || b).join('+'));
}
if (!parts.length) return null;
const label = [r.device && (r.device === 'mobile' ? '手机' : '电脑'), r.browsers?.length ? `浏览器: ${r.browsers.join(',')}` : ''].filter(Boolean).join(' · ');
return (
<span
className="tag tag-mute"
style={{ marginLeft: '0.3rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={label}
>
{parts.join(' · ')}
</span>
);
}
function ipBadge(item: LinkItem): JSX.Element | null {
if (!item.ip_rule) return null;
let r: IpRule;
try { r = JSON.parse(item.ip_rule); } catch { return null; }
const n = r.ips?.length ?? 0;
if (!n) return null;
const short = r.mode === 'allow' ? `${n}` : `${n}`;
return (
<span
className="tag tag-mute"
style={{ marginLeft: '0.3rem' }}
title={r.mode === 'allow' ? 'IP 白名单' : 'IP 黑名单'}
>
{short}
</span>
);
}
function expiredBadge(item: LinkItem): JSX.Element {
const now = Math.floor(Date.now() / 1000);
if (item.expires_at && now > item.expires_at) {
return <span className="tag tag-void"></span>;
}
if (item.max_clicks !== null && item.clicks >= item.max_clicks) {
return <span className="tag tag-void"></span>;
}
if (item.deleted) return <span className="tag tag-mute"></span>;
return <span className="tag tag-ok"></span>;
}
export default function AdminPanel() {
const [items, setItems] = useState<LinkItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editing, setEditing] = useState<LinkItem | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const load = useCallback(async (p: number) => {
setLoading(true);
setError('');
try {
const data = await listLinks(p, PAGE_SIZE);
setItems(data.items);
setTotal(data.total);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(page); }, [page, load]);
const handleDelete = async (code: string) => {
try {
await deleteLink(code);
setConfirmDelete(null);
load(page);
} catch (e) {
setError(String(e));
}
};
const handleToggleDeleted = async (item: LinkItem) => {
try {
await updateLink(item.code, { deleted: item.deleted ? 0 : 1 });
load(page);
} catch (e) {
setError(String(e));
}
};
const handleSave = async () => {
setEditing(null);
load(page);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div>
<div className="admin-toolbar">{total}</div>
{error && <div className="error-msg" style={{ marginBottom: '1rem' }}>{error}</div>}
{loading && <p className="link-muted" style={{ fontSize: '0.8rem', marginBottom: '0.75rem' }}></p>}
{editing && (
<EditDrawer
item={editing}
onSave={handleSave}
onClose={() => setEditing(null)}
/>
)}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="link-table-wrap">
<table className="link-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !loading && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--muted)', padding: '2rem' }}>
</td>
</tr>
)}
{items.map(item => (
<tr key={item.id}>
<td style={{ maxWidth: 200, wordBreak: 'break-all' }}>
<code style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.88rem', fontWeight: 500, color: 'var(--ink)' }}>
{item.code}
</code>
{item.has_password ? (
<span className="tag tag-mute" style={{ marginLeft: '0.35rem' }}></span>
) : null}
{geoBadge(item)}
{clientBadge(item)}
{ipBadge(item)}
</td>
<td>
<a
href={item.target_url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.82rem', color: 'var(--muted)', wordBreak: 'break-all' }}
>
{item.target_url.length > 45 ? item.target_url.slice(0, 45) + '…' : item.target_url}
</a>
</td>
<td>{expiredBadge(item)}</td>
<td style={{ fontSize: '0.85rem' }}>
{item.clicks}
{item.max_clicks !== null && (
<span style={{ color: 'var(--muted)' }}> / {item.max_clicks}</span>
)}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
{formatDate(item.expires_at)}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
{formatDate(item.created_at)}
</td>
<td>
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
<button
className="btn-ghost"
onClick={() => setEditing(item)}
disabled={!!editing}
>
</button>
<button
className="btn-ghost"
onClick={() => handleToggleDeleted(item)}
style={{ fontSize: '0.78rem' }}
>
{item.deleted ? '恢复' : '禁用'}
</button>
{confirmDelete === item.code ? (
<>
<button
className="btn-danger"
onClick={() => handleDelete(item.code)}
>
</button>
<button
className="btn-ghost"
onClick={() => setConfirmDelete(null)}
>
</button>
</>
) : (
<button
className="btn-danger"
onClick={() => setConfirmDelete(item.code)}
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<div className="pagination">
<button
type="button"
className="btn-ghost"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => Math.abs(p - page) <= 2 || p === 1 || p === totalPages)
.reduce<(number | '...')[]>((acc, p, idx, arr) => {
if (idx > 0 && (arr[idx - 1] as number) < p - 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === '...'
? <span key={`ellipsis-${i}`} style={{ padding: '0.3rem 0.5rem', color: 'var(--muted)' }}></span>
: (
<button
type="button"
key={p}
className={`btn-ghost page-num ${p === page ? 'is-active' : ''}`}
onClick={() => setPage(p as number)}
>
{p}
</button>
)
)
}
<button
type="button"
className="btn-ghost"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,362 @@
import { useState } from 'react';
import QRCode from 'qrcode';
import { createLink, type ClientBrowser, type GeoRule } from '../api';
import {
BROWSER_OPTIONS,
DEVICE_OPTIONS,
buildClientRule,
} from '../clientRuleHelpers';
import { buildIpRule } from '../ipRuleHelpers';
const TTL_OPTIONS = [
{ label: '永久有效', value: '' },
{ label: '1 小时', value: '3600' },
{ label: '6 小时', value: '21600' },
{ label: '1 天', value: '86400' },
{ label: '3 天', value: '259200' },
{ label: '7 天', value: '604800' },
{ label: '30 天', value: '2592000' },
{ label: '自定义天数', value: 'custom' },
];
// Preset geo rules shown in the dropdown
const GEO_OPTIONS = [
{ label: '无限制', value: '' },
{ label: '仅允许中国大陆访问', value: 'allow_cn' },
{ label: '屏蔽中国大陆访问', value: 'block_cn' },
{ label: '仅允许中国大陆 + 港澳台', value: 'allow_cntw' },
{ label: '自定义…', value: 'custom' },
];
function buildGeoRule(preset: string, custom: string): GeoRule | null {
switch (preset) {
case 'allow_cn': return { mode: 'allow', countries: ['CN'] };
case 'block_cn': return { mode: 'block', countries: ['CN'] };
case 'allow_cntw': return { mode: 'allow', countries: ['CN', 'TW', 'HK', 'MO'] };
case 'custom': {
const raw = custom.toUpperCase().split(/[\s,]+/).filter(c => /^[A-Z]{2}$/.test(c));
if (!raw.length) return null;
return { mode: 'block', countries: raw };
}
default: return null;
}
}
interface CreatedLink {
code: string;
url: string;
target_url: string;
/** PNG data URL; missing if 浏览器生成失败 */
qrDataUrl?: string;
}
type CreateFormProps = { isAdmin?: boolean };
export default function CreateForm({ isAdmin = false }: CreateFormProps) {
const [targetUrl, setTargetUrl] = useState('');
const [customCode, setCustomCode] = useState('');
const [ttl, setTtl] = useState('');
const [customDays, setCustomDays] = useState('');
const [maxClicks, setMaxClicks] = useState('');
const [password, setPassword] = useState('');
const [geoPreset, setGeoPreset] = useState('');
const [geoCustomMode, setGeoCustomMode] = useState<'allow' | 'block'>('block');
const [geoCustom, setGeoCustom] = useState('');
const [clientDevice, setClientDevice] = useState('');
const [clientBrowsers, setClientBrowsers] = useState<Set<ClientBrowser>>(() => new Set());
const [ipMode, setIpMode] = useState<'' | 'allow' | 'block'>('');
const [ipText, setIpText] = useState('');
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState<CreatedLink | null>(null);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setCreated(null);
const url = targetUrl.trim();
if (!url) { setError('请输入目标 URL'); return; }
let ttlSeconds: number | null = null;
if (ttl === 'custom') {
const days = parseFloat(customDays);
if (isNaN(days) || days <= 0) { setError('请输入有效的自定义天数'); return; }
ttlSeconds = Math.round(days * 86400);
} else if (ttl) {
ttlSeconds = parseInt(ttl, 10);
}
const maxClicksNum = maxClicks ? parseInt(maxClicks, 10) : null;
if (maxClicks && (isNaN(maxClicksNum!) || maxClicksNum! < 1)) {
setError('请输入有效的访问次数限制'); return;
}
let geoRule: GeoRule | null = buildGeoRule(geoPreset, geoCustom);
if (geoPreset === 'custom' && geoRule) {
geoRule.mode = geoCustomMode;
}
const clientRule = buildClientRule(clientDevice, clientBrowsers);
const ipRule = buildIpRule(ipMode, ipText);
setLoading(true);
try {
const result = await createLink({
target_url: url,
...(isAdmin && customCode.trim() ? { custom_code: customCode.trim() } : {}),
ttl_seconds: ttlSeconds,
max_clicks: maxClicksNum,
password: password.trim() || null,
geo_rule: geoRule,
client_rule: clientRule,
ip_rule: ipRule,
});
let qrDataUrl: string | undefined;
try {
qrDataUrl = await QRCode.toDataURL(result.url, {
width: 200,
margin: 1,
errorCorrectionLevel: 'M',
color: { dark: '#0a0a0a', light: '#ffffff' },
});
} catch {
// 仍展示短链
}
setCreated({ ...result, qrDataUrl });
setTargetUrl('');
setCustomCode('');
setPassword('');
setMaxClicks('');
setTtl('');
setCustomDays('');
setGeoPreset('');
setGeoCustom('');
setClientDevice('');
setClientBrowsers(new Set());
setIpMode('');
setIpText('');
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
};
const handleCopy = () => {
if (!created) return;
navigator.clipboard.writeText(created.url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const qrDownloadName = (code: string) => {
const safe = code.replace(/[^\w.\-]+/g, '_').replace(/^_+|_+$/g, '') || 'link';
return `sproutlink-${safe}.png`;
};
return (
<div className="paper">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="target_url">URL</label>
<input
id="target_url"
type="url"
placeholder="https://"
value={targetUrl}
onChange={e => setTargetUrl(e.target.value)}
required
/>
</div>
{isAdmin && (
<div className="form-group">
<label className="form-label" htmlFor="custom_code"></label>
<input
id="custom_code"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="可空,自定义(管理员)"
value={customCode}
onChange={e => setCustomCode(e.target.value)}
/>
</div>
)}
<div className="form-row">
<div className="form-group">
<label className="form-label" htmlFor="ttl"></label>
<select id="ttl" value={ttl} onChange={e => setTtl(e.target.value)}>
{TTL_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{ttl === 'custom' && (
<input
type="number" min="0.01" step="any" placeholder="天"
value={customDays} onChange={e => setCustomDays(e.target.value)}
style={{ marginTop: '0.4rem' }}
/>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="max_clicks"></label>
<input
id="max_clicks" type="number" min="1" step="1"
placeholder="空为不限"
value={maxClicks} onChange={e => setMaxClicks(e.target.value)}
/>
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="geo_preset"></label>
<select id="geo_preset" value={geoPreset} onChange={e => setGeoPreset(e.target.value)}>
{GEO_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{geoPreset === 'custom' && (
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<select
value={geoCustomMode}
onChange={e => setGeoCustomMode(e.target.value as 'allow' | 'block')}
style={{ width: 'auto', flexShrink: 0 }}
>
<option value="block"></option>
<option value="allow"></option>
</select>
<input
type="text"
placeholder="ISO如 US,GB"
value={geoCustom}
onChange={e => setGeoCustom(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="client_device"></label>
<select
id="client_device"
value={clientDevice}
onChange={e => setClientDevice(e.target.value)}
>
{DEVICE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="form-group">
<span className="form-label" style={{ display: 'block', marginBottom: '0.35rem' }}></span>
<div
className="browser-checkboxes"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem 1rem',
fontSize: '0.82rem',
}}
>
{BROWSER_OPTIONS.map(o => (
<label
key={o.id}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer' }}
>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={clientBrowsers.has(o.id)}
onChange={e => {
setClientBrowsers(prev => {
const n = new Set(prev);
if (e.target.checked) n.add(o.id);
else n.delete(o.id);
return n;
});
}}
/>
{o.label}
</label>
))}
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ip_mode">IP</label>
<select
id="ip_mode"
value={ipMode}
onChange={e => setIpMode(e.target.value as '' | 'allow' | 'block')}
>
<option value=""></option>
<option value="allow"></option>
<option value="block"></option>
</select>
{ipMode && (
<textarea
value={ipText}
onChange={e => setIpText(e.target.value)}
placeholder="IP / CIDR每行或逗号"
rows={3}
style={{ marginTop: '0.45rem', fontFamily: 'ui-monospace, monospace', fontSize: '0.82rem' }}
/>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="password"></label>
<input
id="password" type="password"
placeholder="可空"
value={password} onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<button type="submit" className="btn-primary" disabled={loading} style={{ width: '100%' }}>
{loading ? '生成中…' : '生成短链'}
</button>
</form>
{error && <div className="error-msg">{error}</div>}
{created && (
<div className="result-box">
<div className="result-box-body">
{created.qrDataUrl && (
<div className="result-qr">
<img className="result-qr-img" src={created.qrDataUrl} alt="" />
<a
className="btn-ghost result-qr-download"
href={created.qrDataUrl}
download={qrDownloadName(created.code)}
>
</a>
</div>
)}
<div className="result-text">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<a className="result-url" href={created.url} target="_blank" rel="noopener noreferrer">
{created.url}
</a>
<button className="btn-ghost" onClick={handleCopy} style={{ flexShrink: 0 }}>
{copied ? '已复制' : '复制链接'}
</button>
</div>
<p className="result-meta"> {created.target_url}</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import { updateLink, type ClientBrowser, type LinkItem, type GeoRule } from '../api';
import {
BROWSER_OPTIONS,
DEVICE_OPTIONS,
buildClientRule,
parseClientRuleJson,
} from '../clientRuleHelpers';
import { buildIpRule, parseIpRuleText } from '../ipRuleHelpers';
interface EditDrawerProps {
item: LinkItem;
onSave: () => void;
onClose: () => void;
}
const GEO_OPTIONS = [
{ label: '无限制', value: '' },
{ label: '仅允许中国大陆访问', value: 'allow_cn' },
{ label: '屏蔽中国大陆访问', value: 'block_cn' },
{ label: '仅允许中国大陆 + 港澳台', value: 'allow_cntw' },
{ label: '自定义…', value: 'custom' },
];
function ruleToPreset(rule: GeoRule | null): string {
if (!rule) return '';
const key = rule.countries.slice().sort().join(',');
if (rule.mode === 'allow' && key === 'CN') return 'allow_cn';
if (rule.mode === 'block' && key === 'CN') return 'block_cn';
if (rule.mode === 'allow' && key === 'CN,HK,MO,TW') return 'allow_cntw';
return 'custom';
}
function buildGeoRule(preset: string, customMode: 'allow' | 'block', customText: string): GeoRule | null {
switch (preset) {
case 'allow_cn': return { mode: 'allow', countries: ['CN'] };
case 'block_cn': return { mode: 'block', countries: ['CN'] };
case 'allow_cntw': return { mode: 'allow', countries: ['CN', 'TW', 'HK', 'MO'] };
case 'custom': {
const raw = customText.toUpperCase().split(/[\s,]+/).filter(c => /^[A-Z]{2}$/.test(c));
if (!raw.length) return null;
return { mode: customMode, countries: raw };
}
default: return null;
}
}
export default function EditDrawer({ item, onSave, onClose }: EditDrawerProps) {
const [targetUrl, setTargetUrl] = useState(item.target_url);
const [expiresAt, setExpiresAt] = useState<string>(
item.expires_at ? new Date(item.expires_at * 1000).toISOString().slice(0, 16) : ''
);
const [maxClicks, setMaxClicks] = useState<string>(
item.max_clicks !== null ? String(item.max_clicks) : ''
);
const [newPassword, setNewPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
// Geo state
const existingRule: GeoRule | null = item.geo_rule ? JSON.parse(item.geo_rule) : null;
const initPreset = ruleToPreset(existingRule);
const [geoPreset, setGeoPreset] = useState(initPreset);
const [geoCustomMode, setGeoCustomMode] = useState<'allow' | 'block'>(existingRule?.mode ?? 'block');
const [geoCustom, setGeoCustom] = useState(
initPreset === 'custom' && existingRule ? existingRule.countries.join(', ') : ''
);
const initClient = parseClientRuleJson(item.client_rule);
const [clientDevice, setClientDevice] = useState(initClient.device);
const [clientBrowsers, setClientBrowsers] = useState<Set<ClientBrowser>>(() => new Set(initClient.selected));
const initIp = parseIpRuleText(item.ip_rule);
const [ipMode, setIpMode] = useState<'' | 'allow' | 'block'>(initIp.mode);
const [ipText, setIpText] = useState(initIp.text);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!targetUrl.trim()) { setError('目标链接不能为空'); return; }
try { new URL(targetUrl.trim()); } catch { setError('请输入有效的 URL'); return; }
setLoading(true);
setError('');
try {
const geoRule = buildGeoRule(geoPreset, geoCustomMode, geoCustom);
const clientRule = buildClientRule(clientDevice, clientBrowsers);
const ipRule = buildIpRule(ipMode, ipText);
const payload: Parameters<typeof updateLink>[1] = {
target_url: targetUrl.trim(),
expires_at: expiresAt ? Math.floor(new Date(expiresAt).getTime() / 1000) : null,
max_clicks: maxClicks ? parseInt(maxClicks, 10) : null,
geo_rule: geoRule,
client_rule: clientRule,
ip_rule: ipRule,
};
if (clearPassword) payload.clear_password = true;
else if (newPassword) payload.password = newPassword;
await updateLink(item.code, payload);
onSave();
} catch (e) {
setError(String(e));
setLoading(false);
}
};
return (
<div className="edit-drawer">
<div className="edit-drawer-title">
<span> <code style={{ color: 'var(--ink)', fontSize: '0.95em' }}>{item.code}</code></span>
<button onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: '1.1rem', borderRadius: 0, padding: '0 0.25rem' }}>
</button>
</div>
<form onSubmit={handleSave}>
{/* Target URL */}
<div className="form-group">
<label className="form-label">URL</label>
<input type="url" value={targetUrl} onChange={e => setTargetUrl(e.target.value)} required />
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label"></label>
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label"></label>
<input type="number" min="1" step="1" placeholder="空为不限"
value={maxClicks} onChange={e => setMaxClicks(e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="form-label"></label>
<select value={geoPreset} onChange={e => setGeoPreset(e.target.value)}>
{GEO_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{geoPreset === 'custom' && (
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<select value={geoCustomMode} onChange={e => setGeoCustomMode(e.target.value as 'allow' | 'block')}
style={{ width: 'auto', flexShrink: 0 }}>
<option value="block"></option>
<option value="allow"></option>
</select>
<input type="text" placeholder="国家码,逗号分隔,如 US,GB"
value={geoCustom} onChange={e => setGeoCustom(e.target.value)} style={{ flex: 1, minWidth: 160 }} />
</div>
)}
</div>
<div className="form-group">
<label className="form-label"></label>
<select value={clientDevice} onChange={e => setClientDevice(e.target.value)}>
{DEVICE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="form-group">
<span className="form-label" style={{ display: 'block', marginBottom: '0.35rem' }}></span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', fontSize: '0.82rem' }}>
{BROWSER_OPTIONS.map(o => (
<label
key={o.id}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer' }}
>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={clientBrowsers.has(o.id)}
onChange={e => {
setClientBrowsers(prev => {
const n = new Set(prev);
if (e.target.checked) n.add(o.id);
else n.delete(o.id);
return n;
});
}}
/>
{o.label}
</label>
))}
</div>
</div>
<div className="form-group">
<label className="form-label">IP</label>
<select value={ipMode} onChange={e => setIpMode(e.target.value as '' | 'allow' | 'block')}>
<option value=""></option>
<option value="allow"></option>
<option value="block"></option>
</select>
{ipMode && (
<textarea
value={ipText}
onChange={e => setIpText(e.target.value)}
placeholder="每行一个 IP 或 CIDR最多 200 条"
rows={4}
style={{ marginTop: '0.45rem', fontFamily: 'ui-monospace, monospace', fontSize: '0.86rem' }}
/>
)}
</div>
<div className="form-group">
<label className="form-label"></label>
<input type="password" placeholder={item.has_password ? '新密码' : '可空'}
value={newPassword}
onChange={e => { setNewPassword(e.target.value); if (e.target.value) setClearPassword(false); }}
disabled={clearPassword} autoComplete="new-password" />
{item.has_password && (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.35rem', fontSize: '0.8rem', cursor: 'pointer', color: 'var(--muted)' }}>
<input type="checkbox" style={{ width: 'auto' }} checked={clearPassword}
onChange={e => { setClearPassword(e.target.checked); if (e.target.checked) setNewPassword(''); }} />
</label>
)}
</div>
{error && <div className="error-msg" style={{ marginBottom: '0.75rem' }}>{error}</div>}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button type="button" className="btn-ghost" onClick={onClose} disabled={loading}></button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '保存中…' : '保存'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,37 @@
interface LogoProps {
onClick: () => void;
}
export default function Logo({ onClick }: LogoProps) {
return (
<button
type="button"
onClick={onClick}
aria-label="首页"
className="logotype"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
borderRadius: 0,
color: 'inherit',
}}
>
<img
src="/logo.png"
alt="SproutLink"
width={30}
height={30}
draggable={false}
style={{ display: 'block', objectFit: 'contain' }}
/>
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', letterSpacing: '-0.01em' }}>
SproutLink
</span>
</button>
);
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import { getStats, type SiteStats } from '../api';
import { ensurePageViewOnce } from '../pageView';
export default function Stats() {
const [stats, setStats] = useState<SiteStats | null>(null);
const load = () => {
getStats().then(setStats).catch(() => { /* ignore */ });
};
useEffect(() => {
let cancelled = false;
(async () => {
let ok = await ensurePageViewOnce();
if (!ok && !cancelled) {
await new Promise(r => setTimeout(r, 400));
ok = await ensurePageViewOnce();
}
if (cancelled) return;
load();
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
const onVis = () => { if (document.visibilityState === 'visible') load(); };
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
if (!stats) {
return (
<div className="stats-bar" aria-hidden>
<div className="stat-card">
<div className="stat-value"></div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value"></div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value"></div>
<div className="stat-label"></div>
</div>
</div>
);
}
const pv = typeof stats.page_views === 'number' ? stats.page_views : 0;
return (
<div className="stats-bar">
<div className="stat-card">
<div className="stat-value">{stats.total_links.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_clicks.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{pv.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useRef, useEffect } from 'react';
import { verifyAdmin } from '../api';
interface TokenModalProps {
onSuccess: () => void;
onClose: () => void;
}
export default function TokenModal({ onSuccess, onClose }: TokenModalProps) {
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) return;
setLoading(true);
setError('');
const ok = await verifyAdmin(token.trim());
if (ok) {
sessionStorage.setItem('admin_token', token.trim());
onSuccess();
} else {
setError('Token 错误,请重试');
setLoading(false);
}
};
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-backdrop" onClick={handleBackdropClick}>
<div className="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<p className="modal-title" id="modal-title"> Token</p>
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="password"
placeholder="管理 Token"
value={token}
onChange={e => setToken(e.target.value)}
autoComplete="off"
/>
{error && <p style={{ color: 'var(--text)', fontSize: '0.8rem', marginTop: '0.5rem' }}>{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-ghost" onClick={onClose} disabled={loading}>
</button>
<button type="submit" className="btn-primary" disabled={loading || !token.trim()}>
{loading ? '验证中…' : '确认'}
</button>
</div>
</form>
</div>
</div>
);
}

63
web/src/index.css Normal file
View File

@@ -0,0 +1,63 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #0a0a0a;
--text: #171717;
--muted: #737373;
--faint: #a3a3a3;
--border: #d4d4d4;
--line: #e5e5e5;
--bg: #fafafa;
--surface: #ffffff;
--hover: #f5f5f5;
--radius: 2px;
--doc-max: 1240px;
--content-max: 36rem;
--font: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover { color: var(--ink); }
button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
border: none;
outline: none;
border-radius: var(--radius);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
input, textarea, select {
font-family: inherit;
font-size: 0.925rem;
outline: none;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.45rem 0.6rem;
width: 100%;
background: var(--surface);
color: var(--text);
transition: border-color 0.12s, box-shadow 0.12s;
}
input:focus, textarea:focus, select:focus {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
::placeholder { color: var(--faint); }

26
web/src/ipRuleHelpers.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { IpRule } from './api';
const MAX_IPS = 200;
/** 从表单生成 IpRule未选模式或内容为空时返回 null */
export function buildIpRule(mode: '' | 'allow' | 'block', text: string): IpRule | null {
if (mode !== 'allow' && mode !== 'block') return null;
const ips = text
.split(/[\n,;]+/u)
.map(s => s.trim())
.filter(Boolean)
.slice(0, MAX_IPS);
if (ips.length === 0) return null;
return { mode, ips };
}
export function parseIpRuleText(json: string | null | undefined): { mode: '' | 'allow' | 'block'; text: string } {
if (!json) return { mode: '', text: '' };
try {
const r = JSON.parse(json) as IpRule;
if (r.mode !== 'allow' && r.mode !== 'block') return { mode: '', text: '' };
return { mode: r.mode, text: (r.ips ?? []).join('\n') };
} catch {
return { mode: '', text: '' };
}
}

13
web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { registerSW } from 'virtual:pwa-register';
import App from './App';
import './index.css';
registerSW({ immediate: true });
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

11
web/src/pageView.ts Normal file
View File

@@ -0,0 +1,11 @@
import { recordPageView } from './api';
let succeeded = false;
/** 全站浏览 +1成功后才记住。返回是否已成功写入当次或以往。 */
export async function ensurePageViewOnce(): Promise<boolean> {
if (succeeded) return true;
const ok = await recordPageView();
if (ok) succeeded = true;
return ok;
}

2
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

18
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/clientrulehelpers.ts","./src/iprulehelpers.ts","./src/main.tsx","./src/pageview.ts","./src/vite-env.d.ts","./src/components/adminpanel.tsx","./src/components/createform.tsx","./src/components/editdrawer.tsx","./src/components/logo.tsx","./src/components/stats.tsx","./src/components/tokenmodal.tsx"],"version":"5.9.3"}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

11
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

73
web/vite.config.ts Normal file
View File

@@ -0,0 +1,73 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
/** logo.png 约 3MB+,不预缓存;由网络按需加载 */
includeAssets: ['favicon.ico', 'pwa-192x192.png', 'pwa-512x512.png'],
manifest: {
name: 'SproutLink',
short_name: 'SproutLink',
description: '短链与二维码',
lang: 'zh-CN',
dir: 'ltr',
display: 'standalone',
start_url: '/',
scope: '/',
theme_color: '#fafafa',
background_color: '#fafafa',
orientation: 'portrait-primary',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,woff,ttf}'],
globIgnores: ['**/logo.png'],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
/**
* 不设 navigateFallback同域短链如 /aOzb、POST /aOzb/verify 由 Worker 处理;
* 若用 index.html 兜底会抢走导航请求。静态资源仍由预缓存 + 网络回退提供。
*/
navigateFallback: null,
},
devOptions: {
enabled: false,
},
}),
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8787',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});

15
worker/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "sproutlink-worker",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240924.0",
"typescript": "^5.5.4",
"wrangler": "^4.85.0"
}
}

814
worker/src/index.ts Normal file
View File

@@ -0,0 +1,814 @@
// Sproutlink — Cloudflare Worker API + public redirect + static asset serving
// Static assets (Vite dist) are uploaded via [assets] in wrangler.toml — they
// do NOT count toward the 4 MB Worker JS bundle limit.
export interface Env {
DB: D1Database;
/** Cloudflare Workers Static Assets binding — serves ../web/dist */
ASSETS: Fetcher;
/** Secret: 加法项 B任意大整数取模 62^4建议 7+ 位随机,见 `wrangler secret put LINK_OFFSET` */
LINK_OFFSET: string;
/**
* Secret: 与 seq 相乘的系数 A。须满足 gcd(A, 62^4)=1奇数且非 31 倍数时通常成立);
* 与 LINK_OFFSET 一起将顺序递增的 seq 映射为「看似随机跳变」的 4 位码,仍保证全局唯一。
* 不设置时使用内置大质数。`wrangler secret put LINK_MULTIPLIER`
*/
LINK_MULTIPLIER?: string;
/** Secret: admin bearer token */
ADMIN_TOKEN: string;
/** Comma-separated allowed CORS origins, or "*" */
ALLOWED_ORIGINS: string;
}
// ---------------------------------------------------------------------------
// Geo rule types
// ---------------------------------------------------------------------------
interface GeoRule {
mode: 'allow' | 'block';
countries: string[]; // ISO 3166-1 alpha-2, e.g. ["CN"]
}
function parseGeoRule(raw: string | null | undefined): GeoRule | null {
if (!raw) return null;
try { return JSON.parse(raw) as GeoRule; } catch { return null; }
}
/**
* Returns true if access should be denied.
* Uses request.cf.country which Cloudflare populates at the edge — zero latency.
*/
function isGeoBlocked(rule: GeoRule | null, request: Request): boolean {
if (!rule || !rule.countries.length) return false;
// request.cf is typed as CfProperties in @cloudflare/workers-types
const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? 'XX';
const match = rule.countries.map(c => c.toUpperCase()).includes(country.toUpperCase());
if (rule.mode === 'block') return match; // blocked countries → deny
if (rule.mode === 'allow') return !match; // only allowed countries pass
return false;
}
// ---------------------------------------------------------------------------
// Client (device + browser) rules — uses User-Agent + Sec-CH-UA* headers
// ---------------------------------------------------------------------------
type ClientBrowserId = 'edge' | 'chrome' | 'safari' | 'firefox' | 'opera' | 'other';
interface ClientRule {
device: 'mobile' | 'desktop' | null;
browsers: ClientBrowserId[] | null;
}
const ALLOWED_BROWSER_IDS: ReadonlySet<string> = new Set([
'edge', 'chrome', 'safari', 'firefox', 'opera', 'other',
]);
function parseClientRule(raw: string | null | undefined): ClientRule | null {
if (!raw) return null;
try { return JSON.parse(raw) as ClientRule; } catch { return null; }
}
function normalizeClientRule(input: ClientRule | null | undefined): string | null {
if (!input) return null;
const d = input.device;
if (d !== null && d !== 'mobile' && d !== 'desktop') return null;
const br = (input.browsers ?? []).filter(b => ALLOWED_BROWSER_IDS.has(b)) as ClientBrowserId[];
const hasD = d === 'mobile' || d === 'desktop';
if (!hasD && br.length === 0) return null;
return JSON.stringify({ device: hasD ? d : null, browsers: br.length > 0 ? br : null });
}
function isMobileRequest(request: Request): boolean {
const ch = request.headers.get('sec-ch-ua-mobile');
if (ch === '?1') return true;
if (ch === '?0') return false;
const ua = request.headers.get('user-agent') ?? '';
return /Mobile|Android|iPhone|iPod|webOS|BlackBerry|IEMobile|Opera Mini|Kindle|Phone/i.test(ua);
}
function detectBrowser(request: Request): ClientBrowserId {
const ua = request.headers.get('user-agent') ?? '';
if (/Edg\//.test(ua) || /EdgA\//.test(ua) || /EdgiOS\//.test(ua)) return 'edge';
if (/OPR\//.test(ua) || /Opera\/.+OPR/.test(ua)) return 'opera';
if (/\bCriOS\//.test(ua) || (/\bChrome\//.test(ua) && !/\bEdg|OPR\//.test(ua))) return 'chrome';
if (/\bFxiOS\//.test(ua) || /\bFirefox\//.test(ua)) return 'firefox';
if (/\bVersion\/[\d.]+.*\bSafari\//.test(ua) && !/\bCriOS|FxiOS|Chrome|Chromium|Edg|OPR|android.*; wv\)/i.test(ua)) {
return 'safari';
}
if (/\bSafari\//.test(ua) && !/\bCriOS|Chrome|Chromium|FxiOS|Edg|OPR|wOSBrowser|wv\)/.test(ua)) return 'safari';
return 'other';
}
function maybeClientBlock(request: Request, row: { client_rule: string | null }): Response | null {
const r = parseClientRule(row.client_rule);
if (!r) return null;
const hasD = r.device === 'mobile' || r.device === 'desktop';
const hasB = r.browsers && r.browsers.length > 0;
if (!hasD && !hasB) return null;
const mobile = isMobileRequest(request);
if (r.device === 'mobile' && !mobile) {
return clientBlockPage('此短链仅支持手机端访问。');
}
if (r.device === 'desktop' && mobile) {
return clientBlockPage('此短链仅支持电脑端(桌面)访问。');
}
if (hasB) {
const b = detectBrowser(request);
if (!r.browsers!.includes(b)) {
return clientBlockPage('此短链仅允许在已指定的浏览器中打开。');
}
}
return null;
}
// ---------------------------------------------------------------------------
// IP allow / deny — uses CF-Connecting-IP (set by Cloudflare edge; not spoofed by clients on orange-clouded zones)
// ---------------------------------------------------------------------------
interface IpRule {
mode: 'allow' | 'block';
ips: string[];
}
const MAX_IP_RULE_ENTRIES = 200;
function parseIpRule(raw: string | null | undefined): IpRule | null {
if (!raw) return null;
try { return JSON.parse(raw) as IpRule; } catch { return null; }
}
function normalizeIpRule(input: IpRule | null | undefined): string | null {
if (!input) return null;
if (input.mode !== 'allow' && input.mode !== 'block') return null;
const list = (input.ips ?? []).map(s => s.trim()).filter(Boolean).slice(0, MAX_IP_RULE_ENTRIES);
if (list.length === 0) return null;
return JSON.stringify({ mode: input.mode, ips: list });
}
function getClientIP(request: Request): string {
const cf = request.headers.get('CF-Connecting-IP');
if (cf) return cf.trim();
const tci = request.headers.get('True-Client-IP');
if (tci) return tci.trim();
const xff = request.headers.get('X-Forwarded-For');
if (xff) {
const first = xff.split(',')[0].trim();
if (first) return first;
}
return '0.0.0.0';
}
function ipv4ToInt(s: string): number | null {
const parts = s.split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const x = parseInt(part, 10);
if (x < 0 || x > 255) return null;
n = (n << 8) | x;
}
return n >>> 0;
}
function cidr4Match(ip: string, cidr: string): boolean {
const idx = cidr.indexOf('/');
if (idx < 0) return false;
const baseStr = cidr.slice(0, idx).trim();
const p = parseInt(cidr.slice(idx + 1), 10);
if (Number.isNaN(p) || p < 0 || p > 32) return false;
const base = ipv4ToInt(baseStr);
const target = ipv4ToInt(ip);
if (base === null || target === null) return false;
if (p === 0) return true;
const mask = (0xffffffff << (32 - p)) >>> 0;
return (base & mask) === (target & mask);
}
function entryMatchesIP(client: string, entry: string): boolean {
const e = entry.trim();
if (!e) return false;
if (e.includes('/')) {
if (!e.includes(':') && e.includes('.')) {
if (!client.includes(':')) return cidr4Match(client, e);
}
return false;
}
if (client.includes(':') || e.includes(':')) {
return client.toLowerCase() === e.toLowerCase();
}
if (e.includes('.')) {
if (!client.includes(':')) {
return client === e;
}
}
return false;
}
function maybeIpBlock(request: Request, row: { ip_rule: string | null }): Response | null {
const r = parseIpRule(row.ip_rule);
if (!r || !r.ips || r.ips.length === 0) return null;
const ip = getClientIP(request);
const hit = r.ips.some(ent => entryMatchesIP(ip, ent));
if (r.mode === 'allow') {
if (!hit) return clientBlockPage('此短链已启用 IP 白名单,您当前的访问地址不在允许列表中。');
} else {
if (hit) return clientBlockPage('此短链已拒绝该 IP 的访问。');
}
return null;
}
// ---------------------------------------------------------------------------
// Base-62 encode (charset: 0-9 A-Z a-z)
// ---------------------------------------------------------------------------
const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const BASE = 62;
const CODE_LEN = 4;
const MAX_SEQ = BASE ** CODE_LEN; // 14_776_336
/**
* 默认 A与 62^4=2^4*31^4 互质(奇数、不被 31 整除),与 seq 相乘后连续序号在 62^4 环上散列。
* 可 overridden by secret LINK_MULTIPLIER。
*/
const DEFAULT_LINK_MULT = 1_103_515_245;
function gcd2(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y) {
const t = y; y = x % y; x = t;
}
return x;
}
function pickMultiplierB(env: { LINK_MULTIPLIER?: string }): number {
const raw = env.LINK_MULTIPLIER?.trim() ?? '';
let a = /^\d+$/.test(raw) ? parseInt(raw, 10) : 0;
if (!a || a <= 0) return DEFAULT_LINK_MULT;
a = ((a - 1) % MAX_SEQ) + 1; // 1 .. MAX_SEQ
if (gcd2(a, MAX_SEQ) !== 1) return DEFAULT_LINK_MULT;
return a;
}
function pickOffsetB(env: { LINK_OFFSET?: string }): number {
const o = parseInt(env.LINK_OFFSET ?? '0', 10) || 0;
return ((o % MAX_SEQ) + MAX_SEQ) % MAX_SEQ;
}
/**
* 将自增 seq 混洗为 0..62^4-1 的索引保证双射A 可逆当且仅当 gcd(A,M)=1
*/
function mixSeqToIndex(seq: number, a: number, b: number): number {
const M = BigInt(MAX_SEQ);
const n = (BigInt(a) * BigInt(seq) + BigInt(b) + M) % M;
return Number(n);
}
function encode62(n: number): string {
let result = '';
let num = ((n % MAX_SEQ) + MAX_SEQ) % MAX_SEQ;
for (let i = 0; i < CODE_LEN; i++) {
result = CHARSET[num % BASE] + result;
num = Math.floor(num / BASE);
}
return result;
}
// ---------------------------------------------------------------------------
// Crypto helpers (Web Crypto API)
// ---------------------------------------------------------------------------
async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, km, 256);
const hashHex = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
return `pbkdf2:${saltHex}:${hashHex}`;
}
async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split(':');
if (parts.length !== 3 || parts[0] !== 'pbkdf2') return false;
const salt = new Uint8Array(parts[1].match(/.{2}/g)!.map(h => parseInt(h, 16)));
const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, km, 256);
const actualHex = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
if (actualHex.length !== parts[2].length) return false;
let diff = 0;
for (let i = 0; i < actualHex.length; i++) diff |= actualHex.charCodeAt(i) ^ parts[2].charCodeAt(i);
return diff === 0;
}
// ---------------------------------------------------------------------------
// CORS
// ---------------------------------------------------------------------------
function corsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('Origin') ?? '';
const allowed = (env.ALLOWED_ORIGINS ?? '').split(',').map(s => s.trim()).filter(Boolean);
const isAllowed = allowed.includes(origin) || allowed.includes('*');
const acao = isAllowed ? (origin || (allowed.includes('*') ? '*' : '')) : '';
return {
'Access-Control-Allow-Origin': acao,
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
}
function json(data: unknown, status = 200, extra: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...extra },
});
}
function err(message: string, status: number, cors: Record<string, string>): Response {
return json({ error: message }, status, cors);
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function requireAdmin(request: Request, env: Env): boolean {
const auth = request.headers.get('Authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (token.length !== env.ADMIN_TOKEN.length) return false;
let diff = 0;
for (let i = 0; i < token.length; i++) diff |= token.charCodeAt(i) ^ env.ADMIN_TOKEN.charCodeAt(i);
return diff === 0;
}
// ---------------------------------------------------------------------------
// Sequence counter (atomic)
// ---------------------------------------------------------------------------
async function nextCode(env: Env): Promise<string> {
const a = pickMultiplierB(env);
const b = pickOffsetB(env);
const result = await env.DB.prepare(
'UPDATE link_seq SET next_id = next_id + 1 WHERE id = 1 RETURNING next_id'
).first<{ next_id: number }>();
if (!result) throw new Error('Sequence table not initialised');
const seq = result.next_id - 1;
const n = mixSeqToIndex(seq, a, b);
return encode62(n);
}
// ---------------------------------------------------------------------------
// DB row type
// ---------------------------------------------------------------------------
interface LinkRow {
id: number;
code: string;
target_url: string;
expires_at: number | null;
max_clicks: number | null;
clicks: number;
password_hash: string | null;
geo_rule: string | null;
client_rule: string | null;
ip_rule: string | null;
deleted: number;
created_at: number;
updated_at: number;
}
/** 路径段 132与系统 4 位纯英数字自动短码池区分;仅管理员可设 */
const SLUG_MAX = 32;
const RESERVED_PATH_SLUGS = new Set([
'api', 'favicon', 'static', 'assets', 'index', 'robots', 'sitemap', 'app', 'www',
]);
function parseCustomCodeInput(raw: unknown): { ok: true; code: string } | { ok: false; msg: string } {
if (raw === null || raw === undefined) return { ok: false, msg: 'empty' };
if (typeof raw !== 'string') return { ok: false, msg: 'invalid' };
const s = raw.trim();
if (s.length < 1 || s.length > SLUG_MAX) return { ok: false, msg: '长度须为 132' };
if (!/^[a-zA-Z0-9_-]+$/.test(s)) return { ok: false, msg: '仅允许字母、数字、连字符-与下划线_' };
if (/^[0-9A-Za-z]{4}$/.test(s)) {
return { ok: false, msg: '不可为恰好4位纯英数字请用其它长度或加入 - 或 _' };
}
if (RESERVED_PATH_SLUGS.has(s.toLowerCase())) {
return { ok: false, msg: '该短码与站点保留路径冲突' };
}
return { ok: true, code: s };
}
function isSqliteUniqueError(e: unknown): boolean {
const s = String(e);
return s.includes('UNIQUE') || s.includes('unique') || s.includes('SQLITE_CONSTRAINT') || s.includes('Constraint');
}
/** 与 migrations/0002 一致;未跑迁移的库也可正常累计浏览量 */
async function ensureAppStatsTable(env: Env): Promise<void> {
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS app_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
page_views INTEGER NOT NULL DEFAULT 0
)`
).run();
await env.DB.prepare(`INSERT OR IGNORE INTO app_stats (id, page_views) VALUES (1, 0)`).run();
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const { pathname } = url;
const method = request.method.toUpperCase();
const cors = corsHeaders(request, env);
if (method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
// ── POST /api/auth ──────────────────────────────────────────────────────
if (method === 'POST' && pathname === '/api/auth') {
let body: { token?: string } = {};
try { body = await request.json(); } catch { /* empty */ }
const token = body.token ?? '';
if (!token || token.length !== env.ADMIN_TOKEN.length) return err('Unauthorized', 401, cors);
let diff = 0;
for (let i = 0; i < token.length; i++) diff |= token.charCodeAt(i) ^ env.ADMIN_TOKEN.charCodeAt(i);
if (diff !== 0) return err('Unauthorized', 401, cors);
return json({ ok: true }, 200, cors);
}
// ── POST /api/links ─────────────────────────────────────────────────────
if (method === 'POST' && pathname === '/api/links') {
let body: {
target_url?: string;
custom_code?: string | null;
ttl_seconds?: number | null;
max_clicks?: number | null;
password?: string | null;
geo_rule?: GeoRule | null;
client_rule?: ClientRule | null;
ip_rule?: IpRule | null;
} = {};
try { body = await request.json(); } catch { return err('Invalid JSON', 400, cors); }
const targetUrl = (body.target_url ?? '').trim();
if (!targetUrl) return err('target_url is required', 400, cors);
try { new URL(targetUrl); } catch { return err('target_url is not a valid URL', 400, cors); }
const now = Math.floor(Date.now() / 1000);
const expiresAt = body.ttl_seconds ? now + body.ttl_seconds : null;
const maxClicks = body.max_clicks ?? null;
const passwordHash = body.password ? await hashPassword(body.password) : null;
const geoRuleJson = body.geo_rule ? JSON.stringify(body.geo_rule) : null;
const clientRuleJson = normalizeClientRule(body.client_rule ?? null);
const ipRuleJson = normalizeIpRule(body.ip_rule ?? null);
const customRaw = body.custom_code;
const wantsCustom = customRaw != null && String(customRaw).trim() !== '';
const runInsert = async (code: string) => {
await env.DB.prepare(
`INSERT INTO short_links (code, target_url, expires_at, max_clicks, password_hash, geo_rule, client_rule, ip_rule, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(code, targetUrl, expiresAt, maxClicks, passwordHash, geoRuleJson, clientRuleJson, ipRuleJson, now, now).run();
};
if (wantsCustom) {
if (!requireAdmin(request, env)) return err('自定义短码仅管理员可创建', 401, cors);
const parsed = parseCustomCodeInput(customRaw);
if (!parsed.ok) return err(parsed.msg, 400, cors);
const code = parsed.code;
const taken = await env.DB.prepare('SELECT 1 as x FROM short_links WHERE code = ?').bind(code).first();
if (taken) return err('该短码已被占用', 409, cors);
try {
await runInsert(code);
} catch (e) {
return err('DB error: ' + String(e), 500, cors);
}
return json({ code, url: `${url.origin}/${code}`, target_url: targetUrl }, 201, cors);
}
let code = '';
for (let attempt = 0; attempt < 14; attempt++) {
try {
code = await nextCode(env);
await runInsert(code);
return json({ code, url: `${url.origin}/${code}`, target_url: targetUrl }, 201, cors);
} catch (e) {
if (isSqliteUniqueError(e)) continue;
return err('DB error: ' + String(e), 500, cors);
}
}
return err('无法分配短码,请重试', 500, cors);
}
// ── GET /api/links ──────────────────────────────────────────────────────
if (method === 'GET' && pathname === '/api/links') {
if (!requireAdmin(request, env)) return err('Unauthorized', 401, cors);
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
const offset = (page - 1) * limit;
const rows = await env.DB.prepare(
`SELECT id, code, target_url, expires_at, max_clicks, clicks,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password,
geo_rule, client_rule, ip_rule, deleted, created_at, updated_at
FROM short_links ORDER BY id DESC LIMIT ? OFFSET ?`
).bind(limit, offset).all();
const total = await env.DB.prepare('SELECT COUNT(*) as n FROM short_links').first<{ n: number }>();
return json({ items: rows.results, total: total?.n ?? 0, page, limit }, 200, cors);
}
// ── GET /api/links/:code ────────────────────────────────────────────────
const codeParamMatch = pathname.match(/^\/api\/links\/([a-zA-Z0-9_-]{1,32})$/);
if (method === 'GET' && codeParamMatch) {
if (!requireAdmin(request, env)) return err('Unauthorized', 401, cors);
const code = codeParamMatch[1];
const row = await env.DB.prepare(
`SELECT id, code, target_url, expires_at, max_clicks, clicks,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password,
geo_rule, client_rule, ip_rule, deleted, created_at, updated_at
FROM short_links WHERE code = ?`
).bind(code).first();
if (!row) return err('Not found', 404, cors);
return json(row, 200, cors);
}
// ── PATCH /api/links/:code ──────────────────────────────────────────────
if (method === 'PATCH' && codeParamMatch) {
if (!requireAdmin(request, env)) return err('Unauthorized', 401, cors);
const code = codeParamMatch[1];
let body: {
target_url?: string;
ttl_seconds?: number | null;
expires_at?: number | null;
max_clicks?: number | null;
password?: string | null;
clear_password?: boolean;
geo_rule?: GeoRule | null;
client_rule?: ClientRule | null;
ip_rule?: IpRule | null;
deleted?: number;
} = {};
try { body = await request.json(); } catch { return err('Invalid JSON', 400, cors); }
const existing = await env.DB.prepare('SELECT * FROM short_links WHERE code = ?').bind(code).first<LinkRow>();
if (!existing) return err('Not found', 404, cors);
const now = Math.floor(Date.now() / 1000);
const targetUrl = body.target_url?.trim() ?? existing.target_url;
let expiresAt: number | null = existing.expires_at;
if ('ttl_seconds' in body) {
expiresAt = body.ttl_seconds ? now + body.ttl_seconds : null;
} else if ('expires_at' in body) {
expiresAt = body.expires_at ?? null;
}
const maxClicks = 'max_clicks' in body ? (body.max_clicks ?? null) : existing.max_clicks;
let passwordHash: string | null = existing.password_hash;
if (body.clear_password) passwordHash = null;
else if (body.password) passwordHash = await hashPassword(body.password);
// geo_rule: null means "clear it", undefined means "keep existing"
let geoRuleJson: string | null = existing.geo_rule;
if ('geo_rule' in body) {
geoRuleJson = body.geo_rule ? JSON.stringify(body.geo_rule) : null;
}
let clientRuleJson: string | null = existing.client_rule;
if ('client_rule' in body) {
clientRuleJson = body.client_rule === null ? null : normalizeClientRule(body.client_rule);
}
let ipRuleJson: string | null = existing.ip_rule;
if ('ip_rule' in body) {
ipRuleJson = body.ip_rule === null ? null : normalizeIpRule(body.ip_rule);
}
const deleted = 'deleted' in body ? (body.deleted ?? 0) : existing.deleted;
await env.DB.prepare(
`UPDATE short_links
SET target_url=?, expires_at=?, max_clicks=?, password_hash=?, geo_rule=?, client_rule=?, ip_rule=?, deleted=?, updated_at=?
WHERE code=?`
).bind(targetUrl, expiresAt, maxClicks, passwordHash, geoRuleJson, clientRuleJson, ipRuleJson, deleted, now, code).run();
return json({ ok: true }, 200, cors);
}
// ── DELETE /api/links/:code ─────────────────────────────────────────────
if (method === 'DELETE' && codeParamMatch) {
if (!requireAdmin(request, env)) return err('Unauthorized', 401, cors);
const code = codeParamMatch[1];
const result = await env.DB.prepare('DELETE FROM short_links WHERE code = ?').bind(code).run();
if (result.meta.changes === 0) return err('Not found', 404, cors);
return json({ ok: true }, 200, cors);
}
// ── POST /:code/verify (password form) ─────────────────────────────────
const verifyMatch = pathname.match(/^\/([a-zA-Z0-9_-]{1,32})\/verify$/);
if (method === 'POST' && verifyMatch) {
const code = verifyMatch[1];
if (RESERVED_PATH_SLUGS.has(code.toLowerCase())) {
return new Response('Not found', { status: 404 });
}
const row = await env.DB.prepare(
'SELECT * FROM short_links WHERE code = ? AND deleted = 0'
).bind(code).first<LinkRow>();
if (!row || !row.password_hash) {
return new Response(null, { status: 302, headers: { Location: `/${code}` } });
}
const text = await request.text();
const params = new URLSearchParams(text);
const password = params.get('password') ?? '';
if (!(await verifyPassword(password, row.password_hash))) {
return passwordPage(code, true);
}
const now = Math.floor(Date.now() / 1000);
if (row.expires_at && now > row.expires_at) return expiredPage();
if (row.max_clicks !== null && row.clicks >= row.max_clicks) return limitPage();
// Geo check after password — same logic
const geoRule = parseGeoRule(row.geo_rule);
if (isGeoBlocked(geoRule, request)) {
const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? 'XX';
return geoBlockPage(country, geoRule!);
}
const clientResp = maybeClientBlock(request, row);
if (clientResp) return clientResp;
const ipResp = maybeIpBlock(request, row);
if (ipResp) return ipResp;
await env.DB.prepare('UPDATE short_links SET clicks = clicks + 1, updated_at = ? WHERE code = ?')
.bind(now, code).run();
return new Response(null, { status: 302, headers: { Location: row.target_url } });
}
// ── GET /:code (public redirect) ───────────────────────────────────────
const redirectMatch = pathname.match(/^\/([a-zA-Z0-9_-]{1,32})$/);
if (method === 'GET' && redirectMatch) {
const code = redirectMatch[1];
if (RESERVED_PATH_SLUGS.has(code.toLowerCase())) {
return env.ASSETS.fetch(request);
}
const row = await env.DB.prepare(
'SELECT * FROM short_links WHERE code = ? AND deleted = 0'
).bind(code).first<LinkRow>();
if (!row) return notFoundPage();
const now = Math.floor(Date.now() / 1000);
if (row.expires_at && now > row.expires_at) return expiredPage();
if (row.max_clicks !== null && row.clicks >= row.max_clicks) return limitPage();
// Geo check — uses request.cf.country (Cloudflare edge, zero-latency)
const geoRule = parseGeoRule(row.geo_rule);
if (isGeoBlocked(geoRule, request)) {
const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? 'XX';
return geoBlockPage(country, geoRule!);
}
const clientResp = maybeClientBlock(request, row);
if (clientResp) return clientResp;
const ipResp = maybeIpBlock(request, row);
if (ipResp) return ipResp;
if (row.password_hash) return passwordPage(code);
// Direct 302 — no HTML, no animation, no interstitial
await env.DB.prepare('UPDATE short_links SET clicks = clicks + 1, updated_at = ? WHERE code = ?')
.bind(now, code).run();
return new Response(null, { status: 302, headers: { Location: row.target_url } });
}
// ── POST /api/pv (record SPA 浏览 / page view) ─────────────────────────
if (method === 'POST' && pathname === '/api/pv') {
try {
await ensureAppStatsTable(env);
await env.DB.prepare(
`INSERT INTO app_stats (id, page_views) VALUES (1, 1)
ON CONFLICT(id) DO UPDATE SET page_views = page_views + 1`
).run();
} catch { /* 极少D1 异常 */ }
return json({ ok: true }, 200, cors);
}
// ── GET /api/stats ──────────────────────────────────────────────────────
if (method === 'GET' && pathname === '/api/stats') {
const total = await env.DB.prepare(
'SELECT COUNT(*) as n, SUM(clicks) as clicks FROM short_links WHERE deleted = 0'
).first<{ n: number; clicks: number }>();
let pageViews = 0;
try {
await ensureAppStatsTable(env);
const pv = await env.DB.prepare('SELECT page_views AS v FROM app_stats WHERE id = 1').first<{ v: number }>();
pageViews = pv?.v ?? 0;
} catch {
pageViews = 0;
}
return json(
{
total_links: total?.n ?? 0,
total_clicks: total?.clicks ?? 0,
page_views: pageViews,
},
200,
cors
);
}
// Fall through → serve React SPA from static assets
return env.ASSETS.fetch(request);
},
};
// ---------------------------------------------------------------------------
// Inline HTML pages (not part of Vite bundle)
// ---------------------------------------------------------------------------
function passwordPage(code: string, error = false): Response {
const html = `<!DOCTYPE html>
<html lang="zh"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>验证密码</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#fafafa;padding:1.25rem;
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:2px;max-width:22rem;width:100%;
padding:1.5rem 1.35rem;box-shadow:0 1px 0 rgba(0,0,0,.04)}
h2{font-size:0.95rem;font-weight:600;letter-spacing:.02em;margin-bottom:0.45rem;color:#0a0a0a}
p{font-size:0.8rem;color:#737373;margin-bottom:1.1rem;line-height:1.45}
input{width:100%;padding:0.45rem 0.6rem;border:1px solid #d4d4d4;border-radius:2px;
font-size:0.95rem;outline:none;background:#fff;color:#171717;
box-sizing:border-box}
input:focus{border-color:#0a0a0a;box-shadow:0 0 0 1px #0a0a0a}
input::placeholder{color:#a3a3a3}
button{width:100%;margin-top:0.75rem;padding:0.5rem 0.9rem;border:none;border-radius:2px;
background:#0a0a0a;color:#fff;font-size:0.9rem;font-weight:500;cursor:pointer}
button:hover{background:#262626}
.err{margin-top:0.65rem;font-size:0.8rem;color:#404040;text-align:left}
</style></head>
<body><div class="card">
<h2>访问受限</h2>
<p>需要密码。</p>
<form method="POST" action="/${code}/verify">
<input type="password" name="password" placeholder="密码" autofocus required>
<button type="submit">继续</button>
${error ? '<p class="err">密码不正确</p>' : ''}
</form>
</div></body></html>`;
return new Response(html, { status: error ? 403 : 200, headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
function geoBlockPage(country: string, rule: GeoRule): Response {
const isAllowMode = rule.mode === 'allow';
const title = isAllowMode ? '地区访问受限' : '您所在地区无法访问';
const desc = isAllowMode
? `此短链仅对特定地区开放访问,您当前所在地区(${country})不在允许范围内。`
: `此短链已限制您所在地区(${country})的访问。`;
return tinyPage(title, desc, 403);
}
function clientBlockPage(msg: string): Response {
return tinyPage('访问受限', msg, 403);
}
function notFoundPage(): Response { return tinyPage('链接不存在', '该短链不存在或已被删除。', 404); }
function expiredPage(): Response { return tinyPage('链接已过期', '该短链的有效期已结束。', 410); }
function limitPage(): Response { return tinyPage('访问次数已达上限', '该短链的访问次数限制已达到。', 410); }
function tinyPage(title: string, msg: string, status: number): Response {
return new Response(
`<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#fafafa;padding:1.25rem;
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:2px;max-width:22rem;width:100%;
padding:1.5rem 1.35rem;box-shadow:0 1px 0 rgba(0,0,0,.04);text-align:left}
h1{font-size:0.95rem;font-weight:600;color:#0a0a0a;margin-bottom:0.45rem;letter-spacing:.02em}
p{color:#737373;font-size:0.8rem;line-height:1.45;max-width:28rem}</style></head>
<body><div class="card"><h1>${title}</h1><p>${msg}</p></div></body></html>`,
{ status, headers: { 'Content-Type': 'text/html;charset=UTF-8' } }
);
}

11
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,27 @@
name = "sproutlink"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
account_id = "YOUR_CLOUDFLARE_ACCOUNT_ID"
# Static assets: Vite build output is uploaded to Cloudflare CDN separately from
# the Worker JS bundle — does NOT count toward the 4 MB Worker size limit.
[assets]
directory = "../web/dist"
# Serve index.html for any path that doesn't match a real file (SPA routing)
not_found_handling = "single-page-application"
# Routes handled by the Worker:
# /{4-char-code} → public redirect
# /{4-char-code}/verify → password form submit
# /api/* → REST API
# Everything else → served from static assets (the React SPA)
[[d1_databases]]
binding = "DB"
database_name = "sproutlink"
database_id = "YOUR_D1_DATABASE_ID"
# Non-secret vars (override in dashboard or via wrangler secret)
[vars]
ALLOWED_ORIGINS = "*"