Initial commit: Sproutlink short URL system
This commit is contained in:
15
worker/package.json
Normal file
15
worker/package.json
Normal 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
814
worker/src/index.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 路径段 1–32,与系统 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: '长度须为 1–32' };
|
||||
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
11
worker/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
27
worker/wrangler.example.toml
Normal file
27
worker/wrangler.example.toml
Normal 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 = "*"
|
||||
Reference in New Issue
Block a user