mirror of
https://github.com/xxnuo/serverless-qrcode-hub.git
synced 2026-06-06 21:09:50 +08:00
1086 lines
33 KiB
JavaScript
1086 lines
33 KiB
JavaScript
let DB;
|
|
|
|
const RESERVED_PATHS = new Set([
|
|
'login',
|
|
'admin',
|
|
'__total_count',
|
|
'admin.html',
|
|
'login.html',
|
|
'[email protected]',
|
|
'[email protected]',
|
|
'qr-code-styling.js',
|
|
'zxing.js',
|
|
'robots.txt',
|
|
'wechat.svg',
|
|
'favicon.svg',
|
|
]);
|
|
|
|
const SESSION_COOKIE = 'token';
|
|
const SESSION_MAX_AGE = 60 * 60 * 24;
|
|
const EXPIRING_DAYS = 3;
|
|
const EXPIRED_RETENTION_DAYS = 30;
|
|
const DEFAULT_PAGE_SIZE = 10;
|
|
const MAX_PAGE_SIZE = 100;
|
|
const DEFAULT_WECHAT_ANNOUNCEMENT_HTML = '<p>请长按识别下方二维码</p>';
|
|
const DEFAULT_WECHAT_HINT_HTML = '<p>二维码失效请联系作者更新</p>';
|
|
const RICH_TEXT_TAG_MAP = {
|
|
a: 'a',
|
|
b: 'strong',
|
|
br: 'br',
|
|
div: 'div',
|
|
em: 'em',
|
|
i: 'em',
|
|
li: 'li',
|
|
ol: 'ol',
|
|
p: 'p',
|
|
span: 'span',
|
|
strong: 'strong',
|
|
u: 'u',
|
|
ul: 'ul',
|
|
};
|
|
|
|
const SORT_FIELD_MAP = {
|
|
created_at: 'created_at',
|
|
updated_at: 'updated_at',
|
|
expiry: 'expiry',
|
|
visit_count: 'visit_count',
|
|
path: 'path',
|
|
name: 'name',
|
|
};
|
|
|
|
class AppError extends Error {
|
|
constructor(message, status = 400, code = 'BAD_REQUEST') {
|
|
super(message);
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function json(data, status = 200, headers = {}) {
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: {
|
|
'Content-Type': 'application/json;charset=UTF-8',
|
|
'Cache-Control': 'no-store',
|
|
...headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
function errorResponse(error) {
|
|
const status = error instanceof AppError ? error.status : 500;
|
|
const code = error instanceof AppError ? error.code : 'INTERNAL_ERROR';
|
|
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
|
return json({ error: message, code }, status);
|
|
}
|
|
|
|
function getSessionSecret(env) {
|
|
return env.SESSION_SECRET || env.PASSWORD || 'serverless-qrcode-hub';
|
|
}
|
|
|
|
function toBase64Url(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
}
|
|
|
|
async function hmac(value, secret) {
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(secret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
|
|
return toBase64Url(signature);
|
|
}
|
|
|
|
async function createSessionToken(env) {
|
|
return hmac('admin-session', getSessionSecret(env));
|
|
}
|
|
|
|
function getCookieValue(cookieHeader, name) {
|
|
if (!cookieHeader) {
|
|
return '';
|
|
}
|
|
const parts = cookieHeader.split(';');
|
|
for (const part of parts) {
|
|
const [key, ...rest] = part.trim().split('=');
|
|
if (key === name) {
|
|
return rest.join('=');
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
async function verifyAuthCookie(request, env) {
|
|
const currentToken = getCookieValue(request.headers.get('Cookie') || '', SESSION_COOKIE);
|
|
if (!currentToken) {
|
|
return false;
|
|
}
|
|
const expectedToken = await createSessionToken(env);
|
|
return currentToken === expectedToken;
|
|
}
|
|
|
|
async function setAuthCookie(env) {
|
|
const token = await createSessionToken(env);
|
|
return {
|
|
'Set-Cookie': `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}`,
|
|
};
|
|
}
|
|
|
|
function clearAuthCookie() {
|
|
return {
|
|
'Set-Cookie': `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
|
|
};
|
|
}
|
|
|
|
function getChinaDate(dayOffset = 0) {
|
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
now.setUTCDate(now.getUTCDate() + dayOffset);
|
|
return now.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function normalizePath(value) {
|
|
return String(value || '').trim().replace(/^\/+/, '');
|
|
}
|
|
|
|
function normalizeExpiry(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const text = String(value).trim();
|
|
const dateText = text.slice(0, 10);
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
|
|
throw new AppError('有效日期格式不正确');
|
|
}
|
|
const parsed = new Date(`${dateText}T00:00:00.000Z`);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
throw new AppError('有效日期格式不正确');
|
|
}
|
|
return dateText;
|
|
}
|
|
|
|
function normalizeBoolean(value, defaultValue = false) {
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (value === undefined || value === null || value === '') {
|
|
return defaultValue;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value === 1;
|
|
}
|
|
if (typeof value === 'string') {
|
|
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
function assertValidTarget(target) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(target);
|
|
} catch {
|
|
throw new AppError('目标 URL 不合法');
|
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
throw new AppError('目标 URL 仅支持 http 或 https');
|
|
}
|
|
}
|
|
|
|
function sanitizeLinkHref(value) {
|
|
const href = String(value || '').trim();
|
|
if (!href) {
|
|
return '';
|
|
}
|
|
if (/^(#|\/(?!\/)|\.\.?(\/|$))/i.test(href)) {
|
|
return href;
|
|
}
|
|
try {
|
|
const parsed = new URL(href);
|
|
if (['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol)) {
|
|
return href;
|
|
}
|
|
} catch {
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function sanitizeRichText(value, fallback = '') {
|
|
const source = String(value || '').trim();
|
|
if (!source) {
|
|
return fallback;
|
|
}
|
|
|
|
const tokens = source.matchAll(/<\/?([A-Za-z0-9]+)([^>]*)>/g);
|
|
const stack = [];
|
|
let lastIndex = 0;
|
|
let output = '';
|
|
|
|
for (const match of tokens) {
|
|
output += escapeHtml(source.slice(lastIndex, match.index));
|
|
lastIndex = match.index + match[0].length;
|
|
|
|
const rawTag = String(match[1] || '').toLowerCase();
|
|
const tag = RICH_TEXT_TAG_MAP[rawTag];
|
|
if (!tag) {
|
|
continue;
|
|
}
|
|
|
|
const isClosing = match[0][1] === '/';
|
|
if (isClosing) {
|
|
if (tag === 'br') {
|
|
continue;
|
|
}
|
|
if (stack[stack.length - 1] === tag) {
|
|
stack.pop();
|
|
output += `</${tag}>`;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (tag === 'br') {
|
|
output += '<br>';
|
|
continue;
|
|
}
|
|
|
|
if (tag === 'a') {
|
|
const attrs = match[2] || '';
|
|
const hrefMatch = attrs.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i);
|
|
const safeHref = sanitizeLinkHref(hrefMatch ? (hrefMatch[1] || hrefMatch[2] || hrefMatch[3] || '') : '');
|
|
if (!safeHref) {
|
|
continue;
|
|
}
|
|
const isExternal = !/^(#|\/|\.\.?(\/|$))/i.test(safeHref) && !/^mailto:|^tel:/i.test(safeHref);
|
|
output += `<a href="${escapeHtml(safeHref)}"${isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''}>`;
|
|
stack.push('a');
|
|
continue;
|
|
}
|
|
|
|
output += `<${tag}>`;
|
|
stack.push(tag);
|
|
}
|
|
|
|
output += escapeHtml(source.slice(lastIndex));
|
|
|
|
while (stack.length > 0) {
|
|
output += `</${stack.pop()}>`;
|
|
}
|
|
|
|
const normalized = output
|
|
.replace(/(?: |\u00A0)/g, ' ')
|
|
.replace(/<div><\/div>/gi, '')
|
|
.trim();
|
|
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function normalizeMappingPayload(payload, options = {}) {
|
|
const path = normalizePath(payload.path);
|
|
const target = String(payload.target || '').trim();
|
|
const name = String(payload.name || '').trim();
|
|
const expiry = normalizeExpiry(payload.expiry);
|
|
const enabled = normalizeBoolean(payload.enabled, true);
|
|
const isWechat = normalizeBoolean(payload.isWechat, false);
|
|
const qrCodeData = payload.qrCodeData ? String(payload.qrCodeData).trim() : '';
|
|
const announcementHtml = sanitizeRichText(payload.announcementHtml || payload.announcement_html || '', '');
|
|
const hintHtml = sanitizeRichText(payload.hintHtml || payload.hint_html || '', '');
|
|
|
|
if (!path) {
|
|
throw new AppError('短链名不能为空');
|
|
}
|
|
if (!/^[A-Za-z0-9_-]+$/.test(path)) {
|
|
throw new AppError('短链名只能包含字母、数字、下划线和横线');
|
|
}
|
|
if (RESERVED_PATHS.has(path)) {
|
|
throw new AppError('该短链名已被系统保留,请使用其他名称');
|
|
}
|
|
if (!target) {
|
|
throw new AppError('目标 URL 不能为空');
|
|
}
|
|
|
|
assertValidTarget(target);
|
|
|
|
if (isWechat && !qrCodeData) {
|
|
throw new AppError('微信二维码必须提供原始二维码数据');
|
|
}
|
|
|
|
const normalized = {
|
|
path,
|
|
target,
|
|
name: name || null,
|
|
expiry,
|
|
enabled,
|
|
isWechat,
|
|
qrCodeData: isWechat ? qrCodeData : null,
|
|
announcementHtml: announcementHtml || null,
|
|
hintHtml: hintHtml || null,
|
|
};
|
|
|
|
if (options.includeMeta) {
|
|
normalized.visitCount = Number(payload.visitCount || payload.visit_count || 0) || 0;
|
|
normalized.lastVisitedAt = payload.lastVisitedAt || payload.last_visited_at || null;
|
|
normalized.createdAt = payload.createdAt || payload.created_at || null;
|
|
normalized.updatedAt = payload.updatedAt || payload.updated_at || null;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function isExpiredDate(expiry) {
|
|
if (!expiry) {
|
|
return false;
|
|
}
|
|
return expiry.slice(0, 10) < getChinaDate();
|
|
}
|
|
|
|
function isExpiringDate(expiry) {
|
|
if (!expiry) {
|
|
return false;
|
|
}
|
|
const dateText = expiry.slice(0, 10);
|
|
const today = getChinaDate();
|
|
return dateText >= today && dateText <= getChinaDate(EXPIRING_DAYS);
|
|
}
|
|
|
|
function getMappingStatus(mapping) {
|
|
if (!mapping.enabled) {
|
|
return 'disabled';
|
|
}
|
|
if (mapping.expiry && isExpiredDate(mapping.expiry)) {
|
|
return 'expired';
|
|
}
|
|
if (mapping.expiry && isExpiringDate(mapping.expiry)) {
|
|
return 'expiring';
|
|
}
|
|
return 'active';
|
|
}
|
|
|
|
function serializeMapping(row) {
|
|
const mapping = {
|
|
path: row.path,
|
|
target: row.target,
|
|
name: row.name || '',
|
|
expiry: row.expiry ? String(row.expiry).slice(0, 10) : '',
|
|
enabled: row.enabled === 1 || row.enabled === true,
|
|
isWechat: row.isWechat === 1 || row.isWechat === true,
|
|
qrCodeData: row.qrCodeData || '',
|
|
announcementHtml: row.announcementHtml || row.announcement_html || '',
|
|
hintHtml: row.hintHtml || row.hint_html || '',
|
|
visitCount: Number(row.visit_count || row.visitCount || 0) || 0,
|
|
lastVisitedAt: row.last_visited_at || row.lastVisitedAt || '',
|
|
createdAt: row.created_at || row.createdAt || '',
|
|
updatedAt: row.updated_at || row.updatedAt || '',
|
|
};
|
|
mapping.status = getMappingStatus(mapping);
|
|
return mapping;
|
|
}
|
|
|
|
function parseListParams(searchParams) {
|
|
const page = Math.max(Number(searchParams.get('page') || DEFAULT_PAGE_SIZE / DEFAULT_PAGE_SIZE) || 1, 1);
|
|
const pageSize = Math.min(
|
|
Math.max(Number(searchParams.get('pageSize') || DEFAULT_PAGE_SIZE) || DEFAULT_PAGE_SIZE, 1),
|
|
MAX_PAGE_SIZE
|
|
);
|
|
const query = String(searchParams.get('query') || '').trim().toLowerCase();
|
|
const status = String(searchParams.get('status') || 'all').trim();
|
|
const type = String(searchParams.get('type') || 'all').trim();
|
|
const sortBy = SORT_FIELD_MAP[searchParams.get('sortBy')] ? searchParams.get('sortBy') : 'created_at';
|
|
const sortOrder = String(searchParams.get('sortOrder') || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
return { page, pageSize, query, status, type, sortBy, sortOrder };
|
|
}
|
|
|
|
function buildFilterQuery(filters) {
|
|
const where = [`path NOT IN (${Array.from(RESERVED_PATHS).map(() => '?').join(',')})`];
|
|
const bindings = Array.from(RESERVED_PATHS);
|
|
const today = getChinaDate();
|
|
const expiringLimit = getChinaDate(EXPIRING_DAYS);
|
|
|
|
if (filters.query) {
|
|
const keyword = `%${filters.query}%`;
|
|
where.push(`(
|
|
lower(path) LIKE ?
|
|
OR lower(COALESCE(name, '')) LIKE ?
|
|
OR lower(target) LIKE ?
|
|
)`);
|
|
bindings.push(keyword, keyword, keyword);
|
|
}
|
|
|
|
if (filters.type === 'wechat') {
|
|
where.push('isWechat = 1');
|
|
} else if (filters.type === 'normal') {
|
|
where.push('isWechat = 0');
|
|
}
|
|
|
|
if (filters.status === 'active') {
|
|
where.push('(enabled = 1 AND (expiry IS NULL OR substr(expiry, 1, 10) > ?))');
|
|
bindings.push(expiringLimit);
|
|
} else if (filters.status === 'expiring') {
|
|
where.push('(enabled = 1 AND expiry IS NOT NULL AND substr(expiry, 1, 10) >= ? AND substr(expiry, 1, 10) <= ?)');
|
|
bindings.push(today, expiringLimit);
|
|
} else if (filters.status === 'expired') {
|
|
where.push('(expiry IS NOT NULL AND substr(expiry, 1, 10) < ?)');
|
|
bindings.push(today);
|
|
} else if (filters.status === 'disabled') {
|
|
where.push('(enabled = 0)');
|
|
}
|
|
|
|
return { where: where.join(' AND '), bindings };
|
|
}
|
|
|
|
async function initDatabase() {
|
|
await DB.prepare(`
|
|
CREATE TABLE IF NOT EXISTS mappings (
|
|
path TEXT PRIMARY KEY,
|
|
target TEXT NOT NULL,
|
|
name TEXT,
|
|
expiry TEXT,
|
|
enabled INTEGER DEFAULT 1,
|
|
isWechat INTEGER DEFAULT 0,
|
|
qrCodeData TEXT,
|
|
announcementHtml TEXT,
|
|
hintHtml TEXT,
|
|
visit_count INTEGER DEFAULT 0,
|
|
last_visited_at TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).run();
|
|
|
|
const tableInfo = await DB.prepare('PRAGMA table_info(mappings)').all();
|
|
const columns = new Set((tableInfo.results || []).map((column) => column.name));
|
|
|
|
if (!columns.has('isWechat')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN isWechat INTEGER DEFAULT 0').run();
|
|
}
|
|
if (!columns.has('qrCodeData')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN qrCodeData TEXT').run();
|
|
}
|
|
if (!columns.has('announcementHtml')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN announcementHtml TEXT').run();
|
|
}
|
|
if (!columns.has('hintHtml')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN hintHtml TEXT').run();
|
|
}
|
|
if (!columns.has('visit_count')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN visit_count INTEGER DEFAULT 0').run();
|
|
}
|
|
if (!columns.has('last_visited_at')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN last_visited_at TEXT').run();
|
|
}
|
|
if (!columns.has('updated_at')) {
|
|
await DB.prepare('ALTER TABLE mappings ADD COLUMN updated_at TEXT').run();
|
|
}
|
|
|
|
await DB.prepare(`
|
|
UPDATE mappings
|
|
SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)
|
|
WHERE updated_at IS NULL OR updated_at = ''
|
|
`).run();
|
|
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_expiry ON mappings(expiry)').run();
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_created_at ON mappings(created_at)').run();
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_updated_at ON mappings(updated_at)').run();
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_enabled_expiry ON mappings(enabled, expiry)').run();
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_visit_count ON mappings(visit_count)').run();
|
|
await DB.prepare('CREATE INDEX IF NOT EXISTS idx_mappings_is_wechat ON mappings(isWechat)').run();
|
|
}
|
|
|
|
async function getMappingByPath(path) {
|
|
const mapping = await DB.prepare(`
|
|
SELECT path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml, visit_count, last_visited_at, created_at, updated_at
|
|
FROM mappings
|
|
WHERE path = ?
|
|
`).bind(path).first();
|
|
return mapping ? serializeMapping(mapping) : null;
|
|
}
|
|
|
|
async function ensurePathAvailable(path, originalPath = '') {
|
|
const existing = await DB.prepare('SELECT path FROM mappings WHERE path = ?').bind(path).first();
|
|
if (existing && existing.path !== originalPath) {
|
|
throw new AppError('短链名已存在', 409, 'PATH_CONFLICT');
|
|
}
|
|
}
|
|
|
|
async function createMapping(payload, options = {}) {
|
|
const mapping = normalizeMappingPayload(payload, { includeMeta: options.includeMeta });
|
|
await ensurePathAvailable(mapping.path);
|
|
|
|
if (options.includeMeta) {
|
|
await DB.prepare(`
|
|
INSERT INTO mappings (
|
|
path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml,
|
|
visit_count, last_visited_at, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).bind(
|
|
mapping.path,
|
|
mapping.target,
|
|
mapping.name,
|
|
mapping.expiry,
|
|
mapping.enabled ? 1 : 0,
|
|
mapping.isWechat ? 1 : 0,
|
|
mapping.qrCodeData,
|
|
mapping.announcementHtml,
|
|
mapping.hintHtml,
|
|
mapping.visitCount,
|
|
mapping.lastVisitedAt,
|
|
mapping.createdAt || new Date().toISOString(),
|
|
mapping.updatedAt || mapping.createdAt || new Date().toISOString()
|
|
).run();
|
|
} else {
|
|
await DB.prepare(`
|
|
INSERT INTO mappings (path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).bind(
|
|
mapping.path,
|
|
mapping.target,
|
|
mapping.name,
|
|
mapping.expiry,
|
|
mapping.enabled ? 1 : 0,
|
|
mapping.isWechat ? 1 : 0,
|
|
mapping.qrCodeData,
|
|
mapping.announcementHtml,
|
|
mapping.hintHtml,
|
|
new Date().toISOString()
|
|
).run();
|
|
}
|
|
|
|
return getMappingByPath(mapping.path);
|
|
}
|
|
|
|
async function updateMapping(payload) {
|
|
const originalPath = normalizePath(payload.originalPath);
|
|
if (!originalPath) {
|
|
throw new AppError('原短链名不能为空');
|
|
}
|
|
|
|
const current = await getMappingByPath(originalPath);
|
|
if (!current) {
|
|
throw new AppError('映射不存在', 404, 'NOT_FOUND');
|
|
}
|
|
|
|
const mapping = normalizeMappingPayload(payload);
|
|
await ensurePathAvailable(mapping.path, originalPath);
|
|
|
|
const qrCodeData = mapping.isWechat
|
|
? (mapping.qrCodeData || current.qrCodeData || null)
|
|
: null;
|
|
|
|
if (mapping.isWechat && !qrCodeData) {
|
|
throw new AppError('微信二维码必须提供原始二维码数据');
|
|
}
|
|
|
|
await DB.prepare(`
|
|
UPDATE mappings
|
|
SET path = ?, target = ?, name = ?, expiry = ?, enabled = ?, isWechat = ?, qrCodeData = ?, announcementHtml = ?, hintHtml = ?, updated_at = ?
|
|
WHERE path = ?
|
|
`).bind(
|
|
mapping.path,
|
|
mapping.target,
|
|
mapping.name,
|
|
mapping.expiry,
|
|
mapping.enabled ? 1 : 0,
|
|
mapping.isWechat ? 1 : 0,
|
|
qrCodeData,
|
|
mapping.announcementHtml,
|
|
mapping.hintHtml,
|
|
new Date().toISOString(),
|
|
originalPath
|
|
).run();
|
|
|
|
return getMappingByPath(mapping.path);
|
|
}
|
|
|
|
async function deleteMapping(path) {
|
|
const normalizedPath = normalizePath(path);
|
|
if (!normalizedPath) {
|
|
throw new AppError('短链名不能为空');
|
|
}
|
|
if (RESERVED_PATHS.has(normalizedPath)) {
|
|
throw new AppError('系统保留的短链名无法删除');
|
|
}
|
|
const existing = await getMappingByPath(normalizedPath);
|
|
if (!existing) {
|
|
throw new AppError('映射不存在', 404, 'NOT_FOUND');
|
|
}
|
|
await DB.prepare('DELETE FROM mappings WHERE path = ?').bind(normalizedPath).run();
|
|
return existing;
|
|
}
|
|
|
|
async function bulkDeleteMappings(paths) {
|
|
if (!Array.isArray(paths) || paths.length === 0) {
|
|
throw new AppError('请选择至少一个短链');
|
|
}
|
|
const normalized = [...new Set(paths.map((item) => normalizePath(item)).filter(Boolean))];
|
|
const failures = [];
|
|
const removable = [];
|
|
|
|
for (const path of normalized) {
|
|
if (RESERVED_PATHS.has(path)) {
|
|
failures.push({ path, error: '系统保留的短链无法删除' });
|
|
continue;
|
|
}
|
|
removable.push(path);
|
|
}
|
|
|
|
if (removable.length > 0) {
|
|
const placeholders = removable.map(() => '?').join(',');
|
|
await DB.prepare(`DELETE FROM mappings WHERE path IN (${placeholders})`).bind(...removable).run();
|
|
}
|
|
|
|
return {
|
|
deletedCount: removable.length,
|
|
deletedPaths: removable,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
async function listMappings(filters) {
|
|
const { where, bindings } = buildFilterQuery(filters);
|
|
const sortBy = SORT_FIELD_MAP[filters.sortBy] || SORT_FIELD_MAP.created_at;
|
|
const sortOrder = filters.sortOrder === 'asc' ? 'ASC' : 'DESC';
|
|
const offset = (filters.page - 1) * filters.pageSize;
|
|
|
|
const totalResult = await DB.prepare(`SELECT COUNT(*) AS total FROM mappings WHERE ${where}`).bind(...bindings).first();
|
|
const rows = await DB.prepare(`
|
|
SELECT path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml, visit_count, last_visited_at, created_at, updated_at
|
|
FROM mappings
|
|
WHERE ${where}
|
|
ORDER BY ${sortBy} ${sortOrder}, created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`).bind(...bindings, filters.pageSize, offset).all();
|
|
|
|
const items = (rows.results || []).map(serializeMapping);
|
|
const totalItems = Number(totalResult?.total || 0);
|
|
const totalPages = totalItems === 0 ? 0 : Math.ceil(totalItems / filters.pageSize);
|
|
|
|
return {
|
|
items,
|
|
pagination: {
|
|
page: filters.page,
|
|
pageSize: filters.pageSize,
|
|
totalItems,
|
|
totalPages,
|
|
},
|
|
summary: {
|
|
query: filters.query,
|
|
status: filters.status,
|
|
type: filters.type,
|
|
sortBy: filters.sortBy,
|
|
sortOrder: filters.sortOrder,
|
|
selectedCount: items.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function getExpiringMappings() {
|
|
const expiring = await listMappings({
|
|
page: 1,
|
|
pageSize: MAX_PAGE_SIZE,
|
|
query: '',
|
|
status: 'expiring',
|
|
type: 'all',
|
|
sortBy: 'expiry',
|
|
sortOrder: 'asc',
|
|
});
|
|
const expired = await listMappings({
|
|
page: 1,
|
|
pageSize: MAX_PAGE_SIZE,
|
|
query: '',
|
|
status: 'expired',
|
|
type: 'all',
|
|
sortBy: 'expiry',
|
|
sortOrder: 'asc',
|
|
});
|
|
return {
|
|
expiring: expiring.items,
|
|
expired: expired.items,
|
|
};
|
|
}
|
|
|
|
async function getDashboard() {
|
|
const today = getChinaDate();
|
|
const expiringLimit = getChinaDate(EXPIRING_DAYS);
|
|
const bindings = Array.from(RESERVED_PATHS);
|
|
const reservedPlaceholders = bindings.map(() => '?').join(',');
|
|
|
|
const stats = await DB.prepare(`
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS active_total,
|
|
SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END) AS disabled_total,
|
|
SUM(CASE WHEN expiry IS NOT NULL AND substr(expiry, 1, 10) < ? THEN 1 ELSE 0 END) AS expired_total,
|
|
SUM(CASE WHEN enabled = 1 AND expiry IS NOT NULL AND substr(expiry, 1, 10) >= ? AND substr(expiry, 1, 10) <= ? THEN 1 ELSE 0 END) AS expiring_total,
|
|
SUM(CASE WHEN isWechat = 1 THEN 1 ELSE 0 END) AS wechat_total,
|
|
SUM(COALESCE(visit_count, 0)) AS visit_total
|
|
FROM mappings
|
|
WHERE path NOT IN (${reservedPlaceholders})
|
|
`).bind(today, today, expiringLimit, ...bindings).first();
|
|
|
|
const topVisited = await DB.prepare(`
|
|
SELECT path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml, visit_count, last_visited_at, created_at, updated_at
|
|
FROM mappings
|
|
WHERE path NOT IN (${reservedPlaceholders})
|
|
ORDER BY visit_count DESC, updated_at DESC
|
|
LIMIT 5
|
|
`).bind(...bindings).all();
|
|
|
|
return {
|
|
total: Number(stats?.total || 0),
|
|
active: Number(stats?.active_total || 0),
|
|
disabled: Number(stats?.disabled_total || 0),
|
|
expiring: Number(stats?.expiring_total || 0),
|
|
expired: Number(stats?.expired_total || 0),
|
|
wechat: Number(stats?.wechat_total || 0),
|
|
visits: Number(stats?.visit_total || 0),
|
|
topVisited: (topVisited.results || []).map(serializeMapping),
|
|
};
|
|
}
|
|
|
|
async function exportMappings() {
|
|
const bindings = Array.from(RESERVED_PATHS);
|
|
const placeholders = bindings.map(() => '?').join(',');
|
|
const rows = await DB.prepare(`
|
|
SELECT path, target, name, expiry, enabled, isWechat, qrCodeData, announcementHtml, hintHtml, visit_count, last_visited_at, created_at, updated_at
|
|
FROM mappings
|
|
WHERE path NOT IN (${placeholders})
|
|
ORDER BY created_at DESC
|
|
`).bind(...bindings).all();
|
|
return (rows.results || []).map(serializeMapping);
|
|
}
|
|
|
|
async function importMappings(items) {
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
throw new AppError('导入数据不能为空');
|
|
}
|
|
|
|
const failures = [];
|
|
let successCount = 0;
|
|
|
|
for (const item of items) {
|
|
try {
|
|
await createMapping(item, { includeMeta: true });
|
|
successCount += 1;
|
|
} catch (error) {
|
|
failures.push({
|
|
path: normalizePath(item?.path),
|
|
error: error instanceof Error ? error.message : '导入失败',
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
successCount,
|
|
failureCount: failures.length,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
async function cleanupExpiredMappings() {
|
|
const cutoff = getChinaDate(-EXPIRED_RETENTION_DAYS);
|
|
const result = await DB.prepare(`
|
|
DELETE FROM mappings
|
|
WHERE expiry IS NOT NULL AND substr(expiry, 1, 10) < ?
|
|
`).bind(cutoff).run();
|
|
return Number(result.meta?.changes || 0);
|
|
}
|
|
|
|
async function trackVisit(path) {
|
|
await DB.prepare(`
|
|
UPDATE mappings
|
|
SET visit_count = COALESCE(visit_count, 0) + 1,
|
|
last_visited_at = ?
|
|
WHERE path = ?
|
|
`).bind(new Date().toISOString(), path).run();
|
|
}
|
|
|
|
async function readRequestJson(request) {
|
|
try {
|
|
return await request.json();
|
|
} catch {
|
|
throw new AppError('请求数据格式不正确');
|
|
}
|
|
}
|
|
|
|
async function serveAsset(request, env, url, path) {
|
|
if (!env.ASSETS || typeof env.ASSETS.fetch !== 'function') {
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
let targetUrl = new URL(request.url);
|
|
if (path === 'admin') {
|
|
targetUrl = new URL('/admin.html', url.origin);
|
|
} else if (path === 'login') {
|
|
targetUrl = new URL('/login.html', url.origin);
|
|
}
|
|
return env.ASSETS.fetch(new Request(targetUrl.toString(), request));
|
|
}
|
|
|
|
function shouldServeAssetFirst(path) {
|
|
return !path || path === 'admin' || path === 'login' || path.includes('.') || RESERVED_PATHS.has(path);
|
|
}
|
|
|
|
function createExpiredHtml(mapping) {
|
|
const title = escapeHtml(mapping.name ? `${mapping.name} 已过期` : '链接已过期');
|
|
const expiry = escapeHtml(mapping.expiry || '');
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${title}</title>
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
.card { width: min(100%, 360px); padding: 28px 24px; text-align: center; background: #fff; border-radius: 18px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08); }
|
|
h1 { margin: 0 0 12px; font-size: 24px; color: #111827; }
|
|
p { margin: 8px 0; color: #6b7280; line-height: 1.6; }
|
|
@media (prefers-color-scheme: dark) {
|
|
body { background: #0f172a; }
|
|
.card { background: #111827; box-shadow: none; }
|
|
h1 { color: #f8fafc; }
|
|
p { color: #cbd5e1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>${title}</h1>
|
|
<p>过期时间:${expiry || '未设置'}</p>
|
|
<p>如需继续访问,请联系管理员更新链接。</p>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function createWechatHtml(mapping) {
|
|
const title = escapeHtml(mapping.name || '微信群二维码');
|
|
const announcementHtml = sanitizeRichText(mapping.announcementHtml || mapping.announcement_html || '', DEFAULT_WECHAT_ANNOUNCEMENT_HTML);
|
|
const hintHtml = sanitizeRichText(mapping.hintHtml || mapping.hint_html || '', DEFAULT_WECHAT_HINT_HTML);
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${title}</title>
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
.card { width: min(100%, 420px); padding: 28px 24px; text-align: center; background: #fff; border-radius: 18px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08); }
|
|
.icon { width: 40px; height: 40px; margin-bottom: 12px; }
|
|
h1 { margin: 0 0 8px; font-size: 24px; color: #111827; }
|
|
.rich-text { color: #6b7280; line-height: 1.7; }
|
|
.rich-text :where(p, div, ul, ol) { margin: 10px 0 0; }
|
|
.rich-text :where(p, div, ul, ol):first-child { margin-top: 0; }
|
|
.rich-text :where(ul, ol) { padding-left: 1.4em; text-align: left; }
|
|
.rich-text :where(li + li) { margin-top: 0.35em; }
|
|
.rich-text a { color: #2563eb; text-decoration: underline; }
|
|
img.qr { width: 100%; max-width: 260px; margin: 18px auto 0; border-radius: 16px; display: block; background: #fff; }
|
|
@media (prefers-color-scheme: dark) {
|
|
body { background: #0f172a; }
|
|
.card { background: #111827; box-shadow: none; }
|
|
h1 { color: #f8fafc; }
|
|
.rich-text { color: #cbd5e1; }
|
|
.rich-text a { color: #93c5fd; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<img class="icon" src="/wechat.svg" alt="WeChat">
|
|
<h1>${title}</h1>
|
|
<div class="rich-text">${announcementHtml}</div>
|
|
<img class="qr" src="${escapeHtml(mapping.qrCodeData || '')}" alt="微信群二维码">
|
|
<div class="rich-text">${hintHtml}</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function handleApi(request, env, ctx, url, path) {
|
|
if (path === 'api/login') {
|
|
if (request.method !== 'POST') {
|
|
return json({ error: 'Method Not Allowed' }, 405);
|
|
}
|
|
try {
|
|
const body = await readRequestJson(request);
|
|
if (String(body.password || '') !== String(env.PASSWORD || '')) {
|
|
throw new AppError('密码错误,请重试', 401, 'UNAUTHORIZED');
|
|
}
|
|
return json({ success: true }, 200, await setAuthCookie(env));
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
if (path === 'api/logout') {
|
|
if (request.method !== 'POST') {
|
|
return json({ error: 'Method Not Allowed' }, 405);
|
|
}
|
|
return json({ success: true }, 200, clearAuthCookie());
|
|
}
|
|
|
|
if (path === 'api/session') {
|
|
const authenticated = await verifyAuthCookie(request, env);
|
|
if (!authenticated) {
|
|
return json({ error: 'Unauthorized', authenticated: false }, 401);
|
|
}
|
|
return json({ authenticated: true });
|
|
}
|
|
|
|
if (!(await verifyAuthCookie(request, env))) {
|
|
return json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
try {
|
|
if (path === 'api/dashboard' && request.method === 'GET') {
|
|
return json(await getDashboard());
|
|
}
|
|
|
|
if (path === 'api/expiring-mappings' && request.method === 'GET') {
|
|
return json(await getExpiringMappings());
|
|
}
|
|
|
|
if (path === 'api/mappings' && request.method === 'GET') {
|
|
return json(await listMappings(parseListParams(url.searchParams)));
|
|
}
|
|
|
|
if (path === 'api/mappings/export' && request.method === 'GET') {
|
|
const exported = await exportMappings();
|
|
const filename = `mappings-${getChinaDate().replace(/-/g, '')}.json`;
|
|
return new Response(JSON.stringify({ items: exported }, null, 2), {
|
|
headers: {
|
|
'Content-Type': 'application/json;charset=UTF-8',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
}
|
|
|
|
if (path === 'api/mappings/import' && request.method === 'POST') {
|
|
const body = await readRequestJson(request);
|
|
const items = Array.isArray(body) ? body : body.items;
|
|
return json(await importMappings(items));
|
|
}
|
|
|
|
if (path === 'api/mappings/bulk-delete' && request.method === 'POST') {
|
|
const body = await readRequestJson(request);
|
|
return json(await bulkDeleteMappings(body.paths));
|
|
}
|
|
|
|
if (path === 'api/mapping') {
|
|
if (request.method === 'GET') {
|
|
const mappingPath = normalizePath(url.searchParams.get('path'));
|
|
if (!mappingPath) {
|
|
throw new AppError('缺少 path 参数');
|
|
}
|
|
const mapping = await getMappingByPath(mappingPath);
|
|
if (!mapping) {
|
|
throw new AppError('映射不存在', 404, 'NOT_FOUND');
|
|
}
|
|
return json(mapping);
|
|
}
|
|
|
|
if (request.method === 'POST') {
|
|
return json(await createMapping(await readRequestJson(request)), 201);
|
|
}
|
|
|
|
if (request.method === 'PUT') {
|
|
return json(await updateMapping(await readRequestJson(request)));
|
|
}
|
|
|
|
if (request.method === 'DELETE') {
|
|
const body = await readRequestJson(request);
|
|
return json(await deleteMapping(body.path));
|
|
}
|
|
}
|
|
|
|
return json({ error: 'Not Found' }, 404);
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
async function handleMappingRequest(path, env, ctx) {
|
|
const mapping = await getMappingByPath(path);
|
|
if (!mapping) {
|
|
return null;
|
|
}
|
|
|
|
if (!mapping.enabled) {
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
|
|
if (mapping.expiry && isExpiredDate(mapping.expiry)) {
|
|
return new Response(createExpiredHtml(mapping), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'text/html;charset=UTF-8', 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
ctx.waitUntil(trackVisit(path));
|
|
|
|
if (mapping.isWechat && mapping.qrCodeData) {
|
|
return new Response(createWechatHtml(mapping), {
|
|
headers: { 'Content-Type': 'text/html;charset=UTF-8', 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
return Response.redirect(mapping.target, 302);
|
|
}
|
|
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
DB = env.DB;
|
|
await initDatabase();
|
|
|
|
const url = new URL(request.url);
|
|
const path = normalizePath(url.pathname);
|
|
|
|
if (!path) {
|
|
return Response.redirect(`${url.origin}/admin`, 302);
|
|
}
|
|
|
|
if (path.startsWith('api/')) {
|
|
return handleApi(request, env, ctx, url, path);
|
|
}
|
|
|
|
if (shouldServeAssetFirst(path)) {
|
|
return serveAsset(request, env, url, path);
|
|
}
|
|
|
|
const mappingResponse = await handleMappingRequest(path, env, ctx);
|
|
if (mappingResponse) {
|
|
return mappingResponse;
|
|
}
|
|
|
|
return serveAsset(request, env, url, path);
|
|
},
|
|
|
|
async scheduled(controller, env) {
|
|
DB = env.DB;
|
|
await initDatabase();
|
|
const expiring = await getExpiringMappings();
|
|
const cleanedCount = await cleanupExpiredMappings();
|
|
console.log(JSON.stringify({
|
|
expiringCount: expiring.expiring.length,
|
|
expiredCount: expiring.expired.length,
|
|
cleanedCount,
|
|
}));
|
|
},
|
|
};
|