feat: import smsreceiver workerized code with full README

This commit is contained in:
OpenClaw Agent
2026-03-23 04:08:27 +08:00
commit 56179e6a75
21 changed files with 2725 additions and 0 deletions

220
README.md Normal file
View File

@@ -0,0 +1,220 @@
# SmsReceiver Worker前后端分离版
> 将 SmsReceiver-go 迁移到 Cloudflare Worker + Pages + D1 的 **Worker 化**实现。
> 目标是把「短信接收 API + 管理界面」拆分为独立的 API 服务与静态前端,提升部署弹性、跨域能力与运维简洁度。
## 开发目的
1. **Worker 化**:摆脱传统服务器进程部署,使用 Cloudflare Worker 作为 API 运行环境。
2. **前后端分离**:前端使用 Pages 静态托管API 单独在 Worker 上运行,天然解耦。
3. **低运维成本**无需自建数据库与运行时D1 提供托管 SQLite 体验。
4. **部署可重复**:通过 wrangler + 配置文件完成快速部署与迁移。
5. **兼容原协议**:保留原 SmsReceiver-go 的字段、签名逻辑与接入方式。
## 架构概览
```
[TranspondSms / 客户端]
|
| HTTP POST (token/sign)
v
Cloudflare Worker (Hono)
|
| D1 (SQLite)
v
数据持久化 + 管理 API
Cloudflare Pages (静态 UI)
|
| API_BASE 指向 Worker
v
管理后台
```
## 项目结构
```
.
├── worker/ # Cloudflare Worker API (Hono + D1)
│ ├── src/ # API 代码
│ ├── schema.sql # D1 表结构
│ ├── migrate_sqlite_to_d1.py # 旧 sqlite 数据迁移脚本
│ ├── wrangler.toml # Worker 配置
│ └── package.json
└── pages/public/ # Cloudflare Pages 静态管理 UI
├── index.html
├── app.js
└── app.css
```
---
# 关键设计说明(详细解读)
## 1. 前后端分离特性
- **API** 与 **UI** 完全解耦。
- UI 仅为静态资源HTML/JS/CSS部署到 Pages
- API 只处理数据与鉴权,部署到 Worker
- UI 通过 `API_BASE` 指向后端,无需在同域运行。
**优势**
- UI 部署独立、回滚简单。
- API 可单独扩容/升级。
- 适配多域名、CDN 就近访问。
## 2. 数据层D1 (SQLite)
- 使用 Cloudflare D1 替代本地 SQLite。
- 表结构与原 Go 版本保持一致:`sms_messages``receive_logs`
- 使用 SQL 语句创建表,便于版本迁移与重建。
## 3. 兼容原接收协议
API 保持字段一致:
- `from` / `content` / `timestamp` / `sign` / `device` / `sim` / `token`
签名逻辑保持与 Go 版一致:
```
stringToSign = `${timestamp}\n${secret}`
sign = HMAC-SHA256(stringToSign) -> Base64 -> URL encode
```
## 4. 认证与登录
- 使用 `ADMIN_USER` + `ADMIN_PASS_HASH` 登录。
- 登录密码使用 HMAC-SHA256 生成 hash与 SESSION_SECRET 绑定)。
- Cookie 采用 `SameSite=None; Secure`,解决跨域登录。
## 5. 查询能力
- 支持 `from`、时间区间筛选start_ts / end_ts
- UI 默认显示当前月,并提供“本月 / 上月 / 全部”。
- 移动端适配:表格横向滚动,筛选区可滚动。
---
# 部署指南
## 1) 部署 Worker API
```bash
cd worker
npm install
```
编辑 `wrangler.toml`
- `database_id`
- vars: `ADMIN_USER` / `ADMIN_PASS_HASH` / `SESSION_SECRET` / `HMAC_SECRET`
初始化 D1 表结构:
```bash
npx wrangler d1 execute smsreceiver --file=schema.sql
```
本地调试:
```bash
npx wrangler dev
```
发布:
```bash
npx wrangler deploy
```
---
## 2) 部署 Pages UI
`pages/public/` 作为静态站点部署到 Cloudflare Pages。
修改 `pages/public/app.js`
```js
const API_BASE = "https://你的-worker域名"
```
部署完成后即可访问管理后台。
---
## 3) 密码 hash 生成
当前登录逻辑:`HMAC_SHA256(password, SESSION_SECRET)`
生成方式:
```bash
node -e "const c=require('crypto');console.log(c.createHmac('sha256','YOUR_SESSION_SECRET').update('YOUR_PASSWORD').digest('hex'))"
```
将结果写入 `ADMIN_PASS_HASH`
---
## 4) 从旧 SQLite 迁移到 D1
脚本:`worker/migrate_sqlite_to_d1.py`
需要环境变量:
- `CF_ACCOUNT_ID`
- `CF_API_TOKEN`
- `D1_DATABASE_ID`
- `SQLITE_PATH`(默认 `sms_receiver_go.db`
运行:
```bash
cd worker
python3 migrate_sqlite_to_d1.py
```
---
# 使用指南
## 1. 客户端上报短信
`POST /api/v1/receive`
字段:
- `from`:发件人
- `content`:短信正文
- `timestamp`Unix 时间戳
- `token`API Token
- `sign`:签名
- `device` / `sim`:设备与卡槽信息(可选)
## 2. 登录管理后台
- 访问 Pages URL
- 输入 `ADMIN_USER` / 密码
- 可查看短信列表、详情、日志、筛选记录
## 3. 管理 API 端点示例
- `GET /api/v1/messages`
- `GET /api/v1/messages/:id`
- `GET /api/v1/logs`
---
# 本地开发流程(推荐)
1.`worker/` 中使用 `wrangler dev` 启动本地 API。
2.`pages/public/` 中修改前端并直接用静态服务器预览。
3. API_BASE 指向本地 API 或临时 Worker 域名。
4. 测试完成后分别部署 Worker 与 Pages。
---
# 注意事项
- UI 与 API 部署后跨域必须使用 `SameSite=None; Secure` Cookie。
- D1 为 SQLite 兼容,但限制与云端事务特性不同于本地。
- API Token 与 HMAC 密钥要妥善保管。
---
# License
内部项目使用,按需扩展。

95
pages/public/app.css Normal file
View File

@@ -0,0 +1,95 @@
:root {
--bg: #e8e8e8;
--card: #fefefe;
--ink: #1b1b1b;
--primary: #6c7cff;
--primary-dark: #4f5de1;
--danger: #e34a4a;
--border: #1b1b1b;
--shadow: 4px 4px 0 #1b1b1b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Pixel", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", sans-serif;
background: var(--bg);
color: var(--ink);
}
main { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.pixel-card {
background: var(--card);
border: 2px solid var(--border);
box-shadow: var(--shadow);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.title { font-size: 20px; font-weight: 700; }
.nav { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 6px 12px; border: 2px solid var(--border);
background: #fff; cursor: pointer; text-decoration: none; color: var(--ink);
box-shadow: 2px 2px 0 var(--border); border-radius: 6px; font-size: 14px;
}
.btn.active, .btn.primary { background: var(--primary); color: #fff; }
.btn.danger { background: var(--danger); color: #fff; }
.btn.small { padding: 4px 8px; font-size: 12px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.stat-card { text-align: center; }
.stat-card .label { font-size: 12px; color: #555; }
.stat-card .value { font-size: 28px; font-weight: 700; }
.filter .tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
padding: 4px 10px; border: 2px solid var(--border); border-radius: 6px;
background: #fff; cursor: pointer; box-shadow: 2px 2px 0 var(--border);
}
.tag.active { background: var(--primary); color: #fff; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
input {
padding: 8px 10px; border: 2px solid var(--border); border-radius: 6px;
box-shadow: 2px 2px 0 var(--border); outline: none; font-size: 14px;
}
.table-wrap { overflow-x: auto; }
.table-wrap table { width: 100%; border-collapse: collapse; min-width: 720px; }
.table-wrap th, .table-wrap td { padding: 10px; border-bottom: 2px dashed #ccc; text-align: left; }
.table-wrap th { background: #f3f3f3; }
.ellipsis { max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { padding: 2px 8px; border: 2px solid var(--border); border-radius: 6px; box-shadow: 2px 2px 0 var(--border); }
.badge.ok { background: #9be38b; }
.badge.err { background: #f08c8c; }
.pager { display: flex; gap: 10px; align-items: center; justify-content: center; margin: 16px 0; flex-wrap: wrap; }
.detail h3 { margin-top: 0; }
.detail-row { display: grid; grid-template-columns: 140px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px dashed #ddd; }
.detail-row .k { color: #666; }
.login-wrap { min-height: 80vh; display: flex; align-items: center; justify-content: center; padding: 0 12px; }
.login-card { max-width: 420px; width: 100%; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; margin-bottom: 6px; }
@media (max-width: 768px) {
main { padding: 0 10px; }
.header { padding: 12px; }
.title { width: 100%; text-align: center; }
.nav { width: 100%; justify-content: center; }
.stats { grid-template-columns: 1fr; }
.toolbar { flex-direction: column; align-items: stretch; }
.filter .tags { max-height: 120px; overflow-y: auto; }
.detail-row { grid-template-columns: 1fr; }
.table-wrap table { min-width: 600px; }
}

319
pages/public/app.js Normal file
View File

@@ -0,0 +1,319 @@
const API_BASE = window.API_BASE || 'https://sms-api.ouai.nyc.mn'
const app = document.getElementById('app')
const state = {
view: 'login',
messages: [],
logs: [],
page: 1,
total: 0,
logsPage: 1,
logsTotal: 0,
currentMessage: null,
fromNumbers: [],
selectedFrom: '',
search: '',
stats: { total: 0, today: 0, failed: 0 },
startTs: 0,
endTs: 0,
}
function monthRange(offset = 0) {
const now = new Date()
const y = now.getFullYear()
const m = now.getMonth() + offset
const start = new Date(y, m, 1, 0, 0, 0)
const end = new Date(y, m + 1, 0, 23, 59, 59)
return [start.getTime(), end.getTime()]
}
function initDefaultRange() {
const [s, e] = monthRange(0)
state.startTs = s
state.endTs = e
}
async function api(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
})
return res.json()
}
function setView(view) {
state.view = view
render()
}
function nav(active) {
return `
<div class="header pixel-card">
<div class="title">📱 短信转发接收端</div>
<div class="nav">
<a class="btn ${active==='list'?'active':''}" data-view="list">短信列表</a>
<a class="btn ${active==='logs'?'active':''}" data-view="logs">接收日志</a>
<a class="btn ${active==='stats'?'active':''}" data-view="stats">统计信息</a>
<a class="btn danger" id="logoutBtn">退出</a>
</div>
</div>
`
}
function loginView() {
return `
<div class="login-wrap">
<div class="login-card pixel-card">
<h1>📱 短信转发接收端</h1>
<div class="form-group">
<label>用户名</label>
<input id="username" placeholder="请输入用户名" />
</div>
<div class="form-group">
<label>密码</label>
<input id="password" type="password" placeholder="请输入密码" />
</div>
<button class="btn primary" id="loginBtn">登录</button>
</div>
</div>
`
}
function statsCards() {
const s = state.stats
return `
<div class="stats">
<div class="stat-card pixel-card"><div class="label">总短信</div><div class="value">${s.total}</div></div>
<div class="stat-card pixel-card"><div class="label">今日</div><div class="value">${s.today}</div></div>
<div class="stat-card pixel-card"><div class="label">异常</div><div class="value">${s.failed}</div></div>
</div>
`
}
function fromFilter() {
const tags = state.fromNumbers.map(n => `
<span class="tag ${state.selectedFrom===n?'active':''}" data-from="${n}">${n}</span>
`).join('')
return `
<div class="filter pixel-card">
<div class="label">发送方筛选</div>
<div class="tags">
<span class="tag ${state.selectedFrom===''?'active':''}" data-from="">全部</span>
${tags}
</div>
</div>
`
}
function timeFilter() {
return `
<div class="toolbar pixel-card">
<span class="label">时间筛选</span>
<button class="btn" id="thisMonthBtn">本月</button>
<button class="btn" id="lastMonthBtn">上月</button>
<button class="btn" id="allBtn">全部</button>
</div>
`
}
function toolbar() {
return `
<div class="toolbar pixel-card">
<input id="search" placeholder="搜索内容/号码" value="${state.search}" />
<button class="btn primary" id="searchBtn">搜索</button>
<button class="btn" id="refreshBtn">刷新</button>
</div>
`
}
function listView() {
const rows = state.messages.map(m => `
<tr>
<td>${m.id}</td>
<td>${m.from_number}</td>
<td class="ellipsis">${m.content}</td>
<td>${new Date(m.timestamp).toLocaleString()}</td>
<td><a class="btn small" data-id="${m.id}" data-action="detail">详情</a></td>
</tr>
`).join('')
return `
${nav('list')}
${statsCards()}
${fromFilter()}
${timeFilter()}
${toolbar()}
<div class="table-wrap pixel-card">
<table>
<thead><tr><th>ID</th><th>号码</th><th>内容</th><th>时间</th><th></th></tr></thead>
<tbody>${rows || ''}</tbody>
</table>
</div>
<div class="pager">
<button class="btn" id="prevPage">上一页</button>
<span>第 ${state.page} 页</span>
<button class="btn" id="nextPage">下一页</button>
</div>
`
}
function detailView() {
const m = state.currentMessage
if (!m) return ''
return `
${nav('list')}
<div class="detail pixel-card">
<h3>📱 短信详情</h3>
<div class="detail-row"><div class="k">ID</div><div class="v">${m.id}</div></div>
<div class="detail-row"><div class="k">发送方号码</div><div class="v">${m.from_number}</div></div>
<div class="detail-row"><div class="k">短信内容</div><div class="v">${m.content}</div></div>
<div class="detail-row"><div class="k">原始时间戳</div><div class="v">${m.timestamp}</div></div>
<div class="detail-row"><div class="k">入库时间</div><div class="v">${new Date(m.created_at || m.timestamp).toLocaleString()}</div></div>
<div class="detail-row"><div class="k">签名验证</div><div class="v">${m.sign_verified ? '已验证' : '未验证'}</div></div>
<div class="detail-row"><div class="k">设备信息</div><div class="v">${m.device_info || '-'}</div></div>
<div class="detail-row"><div class="k">SIM 卡信息</div><div class="v">${m.sim_info || '-'}</div></div>
<div class="detail-row"><div class="k">IP 地址</div><div class="v">${m.ip_address || '-'}</div></div>
<button class="btn" id="backBtn">返回列表</button>
</div>
`
}
function logsView() {
const rows = state.logs.map(l => `
<tr>
<td>${l.id}</td>
<td>${l.from_number}</td>
<td><span class="badge ${l.status==='success'?'ok':'err'}">${l.status}</span></td>
<td>${l.error_message || ''}</td>
<td>${new Date(l.timestamp).toLocaleString()}</td>
</tr>
`).join('')
return `
${nav('logs')}
<div class="table-wrap pixel-card">
<table>
<thead><tr><th>ID</th><th>号码</th><th>状态</th><th>错误</th><th>时间</th></tr></thead>
<tbody>${rows || ''}</tbody>
</table>
</div>
<div class="pager">
<button class="btn" id="prevLogs">上一页</button>
<span>第 ${state.logsPage} 页</span>
<button class="btn" id="nextLogs">下一页</button>
</div>
`
}
function statsView() {
return `
${nav('stats')}
${statsCards()}
<div class="detail pixel-card">
<h3>统计概览</h3>
<div class="detail-row"><div class="k">总短信</div><div class="v">${state.stats.total}</div></div>
<div class="detail-row"><div class="k">今日</div><div class="v">${state.stats.today}</div></div>
<div class="detail-row"><div class="k">异常</div><div class="v">${state.stats.failed}</div></div>
</div>
`
}
async function loadStats() {
const data = await api('/api/stats')
if (data.success) state.stats = data.data
}
async function loadMessages() {
const data = await api(`/api/messages?page=${state.page}&limit=20&from=${encodeURIComponent(state.selectedFrom)}&q=${encodeURIComponent(state.search)}&start_ts=${state.startTs}&end_ts=${state.endTs}`)
if (!data.success) return
state.messages = data.data
state.total = data.total
state.fromNumbers = data.from_numbers || []
}
async function loadLogs() {
const data = await api(`/api/logs?page=${state.logsPage}&limit=20`)
if (!data.success) return
state.logs = data.data
state.logsTotal = data.total
}
async function render() {
if (state.view === 'login') {
app.innerHTML = loginView()
document.getElementById('loginBtn').onclick = async () => {
const username = document.getElementById('username').value
const password = document.getElementById('password').value
const res = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
if (res.success) {
initDefaultRange()
state.view = 'list'
await loadStats()
await loadMessages()
render()
} else alert(res.error || '登录失败')
}
return
}
if (state.view === 'list') {
await loadStats()
await loadMessages()
app.innerHTML = listView()
bindCommon()
document.querySelectorAll('.tag').forEach(tag => {
tag.onclick = () => { state.selectedFrom = tag.dataset.from || ''; state.page = 1; render(); }
})
document.getElementById('searchBtn').onclick = () => { state.search = document.getElementById('search').value; state.page = 1; render(); }
document.getElementById('refreshBtn').onclick = () => render()
document.getElementById('prevPage').onclick = () => { if (state.page>1) { state.page--; render(); } }
document.getElementById('nextPage').onclick = () => { state.page++; render(); }
document.getElementById('thisMonthBtn').onclick = () => { const [s,e] = monthRange(0); state.startTs=s; state.endTs=e; state.page=1; render(); }
document.getElementById('lastMonthBtn').onclick = () => { const [s,e] = monthRange(-1); state.startTs=s; state.endTs=e; state.page=1; render(); }
document.getElementById('allBtn').onclick = () => { state.startTs=0; state.endTs=0; state.page=1; render(); }
document.querySelectorAll('[data-action="detail"]').forEach(btn => {
btn.onclick = async () => {
const id = btn.dataset.id
const data = await api(`/api/messages/${id}`)
if (data.success) { state.currentMessage = data.data; state.view = 'detail'; render(); }
}
})
return
}
if (state.view === 'detail') {
app.innerHTML = detailView()
bindCommon()
document.getElementById('backBtn').onclick = () => { state.view = 'list'; render(); }
return
}
if (state.view === 'logs') {
await loadLogs()
app.innerHTML = logsView()
bindCommon()
document.getElementById('prevLogs').onclick = () => { if (state.logsPage>1) { state.logsPage--; render(); } }
document.getElementById('nextLogs').onclick = () => { state.logsPage++; render(); }
return
}
if (state.view === 'stats') {
await loadStats()
app.innerHTML = statsView()
bindCommon()
return
}
}
function bindCommon() {
document.querySelectorAll('[data-view]').forEach(a => {
a.onclick = () => setView(a.dataset.view)
})
const logout = document.getElementById('logoutBtn')
if (logout) logout.onclick = async () => { await api('/api/auth/logout', { method: 'POST' }); state.view='login'; render(); }
}
initDefaultRange()
render()

13
pages/public/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>SmsReceiver</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<main id="app"></main>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import os
import json
import sqlite3
import time
import requests
D1_DATABASE_ID = os.getenv('D1_DATABASE_ID', '')
CF_API_TOKEN = os.getenv('CF_API_TOKEN', '')
CF_ACCOUNT_ID = os.getenv('CF_ACCOUNT_ID', '')
SQLITE_PATH = os.getenv('SQLITE_PATH', 'sms_receiver_go.db')
BATCH_SIZE = int(os.getenv('BATCH_SIZE', '50'))
if not D1_DATABASE_ID or not CF_API_TOKEN or not CF_ACCOUNT_ID:
raise SystemExit('Missing env: D1_DATABASE_ID / CF_API_TOKEN / CF_ACCOUNT_ID')
api = f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/d1/database/{D1_DATABASE_ID}/query"
headers = {"Authorization": f"Bearer {CF_API_TOKEN}", "Content-Type": "application/json"}
conn = sqlite3.connect(SQLITE_PATH)
conn.row_factory = sqlite3.Row
TABLES = ['sms_messages', 'receive_logs']
def post_query(sql, params=None):
payload = {"sql": sql}
if params is not None:
payload["params"] = params
r = requests.post(api, headers=headers, data=json.dumps(payload))
r.raise_for_status()
def migrate_table(table):
cur = conn.cursor()
cur.execute(f"SELECT * FROM {table}")
rows = cur.fetchall()
print(f"{table}: {len(rows)} rows")
batch = 0
for row in rows:
cols = row.keys()
placeholders = ','.join(['?'] * len(cols))
sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})"
params = [row[c] for c in cols]
post_query(sql, params)
batch += 1
if batch >= BATCH_SIZE:
time.sleep(0.2)
batch = 0
for table in TABLES:
migrate_table(table)
print('done')

1531
worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
worker/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "smsreceiver-worker-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"hono": "^4.6.10"
},
"devDependencies": {
"typescript": "^5.6.3",
"wrangler": "^4.7.0"
}
}

31
worker/schema.sql Normal file
View File

@@ -0,0 +1,31 @@
CREATE TABLE sms_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_number TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
device_info TEXT,
sim_info TEXT,
sign_verified INTEGER,
ip_address TEXT,
created_at TEXT
);
CREATE TABLE receive_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_number TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
sign TEXT,
sign_valid INTEGER,
ip_address TEXT,
status TEXT NOT NULL,
error_message TEXT,
created_at TEXT
);
CREATE INDEX idx_messages_from ON sms_messages(from_number);
CREATE INDEX idx_messages_timestamp ON sms_messages(timestamp);
CREATE INDEX idx_messages_created ON sms_messages(created_at);
CREATE INDEX idx_logs_created ON receive_logs(created_at);
CREATE INDEX idx_logs_status ON receive_logs(status);

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

@@ -0,0 +1,47 @@
import { Hono } from 'hono'
import { authRoutes } from './routes/auth'
import { messagesRoutes } from './routes/messages'
import { logsRoutes } from './routes/logs'
import { receiveRoutes } from './routes/receive'
import { statsRoutes } from './routes/stats'
export type Env = {
DB: D1Database
ADMIN_USER: string
ADMIN_PASS_HASH: string
SESSION_SECRET: string
API_TOKEN: string
HMAC_SECRET: string
SIGN_VERIFY: string
SIGN_MAX_AGE_MS: string
CORS_ORIGIN: string
}
const app = new Hono<{ Bindings: Env }>()
app.use('*', async (c, next) => {
const reqOrigin = c.req.header('Origin')
const allowOrigin = reqOrigin || c.env.CORS_ORIGIN || '*'
c.header('Access-Control-Allow-Origin', allowOrigin)
c.header('Vary', 'Origin')
c.header('Access-Control-Allow-Credentials', 'true')
c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
c.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
if (c.req.method === 'OPTIONS') {
return c.body(null, 204)
}
await next()
})
app.route('/api/auth', authRoutes)
app.route('/api/messages', messagesRoutes)
app.route('/api/logs', logsRoutes)
app.route('/api/receive', receiveRoutes)
app.route('/api/stats', statsRoutes)
app.get('/api/health', (c) => c.json({ ok: true }))
export default app

View File

@@ -0,0 +1,10 @@
import { createMiddleware } from 'hono/factory'
import type { Env } from '../index'
import { verifySessionCookie } from '../utils/session'
export const requireAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
const cookie = c.req.header('Cookie') || ''
const ok = verifySessionCookie(cookie, c.env.SESSION_SECRET)
if (!ok) return c.json({ success: false, error: '未授权' }, 401)
await next()
})

33
worker/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { createSessionCookie, clearSessionCookie, hashPassword } from '../utils/session'
export const authRoutes = new Hono<{ Bindings: Env }>()
authRoutes.post('/login', async (c) => {
const body = await c.req.json().catch(() => ({})) as any
const username = body.username || ''
const password = body.password || ''
if (!username || !password) {
return c.json({ success: false, error: '缺少用户名或密码' }, 400)
}
if (username !== c.env.ADMIN_USER) {
return c.json({ success: false, error: '用户名或密码错误' }, 401)
}
const passHash = hashPassword(password, c.env.SESSION_SECRET)
if (passHash !== c.env.ADMIN_PASS_HASH) {
return c.json({ success: false, error: '用户名或密码错误' }, 401)
}
const cookie = createSessionCookie(username, c.env.SESSION_SECRET)
c.header('Set-Cookie', cookie)
return c.json({ success: true })
})
authRoutes.post('/logout', async (c) => {
c.header('Set-Cookie', clearSessionCookie())
return c.json({ success: true })
})

29
worker/src/routes/logs.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const logsRoutes = new Hono<{ Bindings: Env }>()
logsRoutes.get('/', requireAuth, async (c) => {
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') || 20)))
const status = (c.req.query('status') || '').trim()
const offset = (page - 1) * limit
let where = ''
const params: any[] = []
if (status) {
where = 'WHERE status = ?'
params.push(status)
}
const totalStmt = c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM receive_logs ${where}`)
const total = (await totalStmt.bind(...params).first() as any)?.cnt || 0
const stmt = c.env.DB.prepare(
`SELECT * FROM receive_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
)
const rows = await stmt.bind(...params, limit, offset).all()
return c.json({ success: true, data: rows.results || [], total, page, limit })
})

View File

@@ -0,0 +1,70 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const messagesRoutes = new Hono<{ Bindings: Env }>()
messagesRoutes.get('/', requireAuth, async (c) => {
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') || 20)))
const q = (c.req.query('q') || '').trim()
const from = (c.req.query('from') || '').trim()
const startTs = Number(c.req.query('start_ts') || 0)
const endTs = Number(c.req.query('end_ts') || 0)
const offset = (page - 1) * limit
const whereParts: string[] = []
const params: any[] = []
if (from) {
whereParts.push('from_number = ?')
params.push(from)
}
if (q) {
whereParts.push('(from_number LIKE ? OR content LIKE ?)')
params.push(`%${q}%`, `%${q}%`)
}
if (startTs > 0) {
whereParts.push('timestamp >= ?')
params.push(startTs)
}
if (endTs > 0) {
whereParts.push('timestamp <= ?')
params.push(endTs)
}
const where = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const totalStmt = c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM sms_messages ${where}`)
const total = (await totalStmt.bind(...params).first() as any)?.cnt || 0
const stmt = c.env.DB.prepare(
`SELECT * FROM sms_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`
)
const rows = await stmt.bind(...params, limit, offset).all()
const fromRows = await c.env.DB.prepare(
`SELECT DISTINCT from_number FROM sms_messages ORDER BY from_number`
).all()
return c.json({
success: true,
data: rows.results || [],
total,
page,
limit,
from_numbers: (fromRows.results || []).map((r: any) => r.from_number),
})
})
messagesRoutes.get('/:id', requireAuth, async (c) => {
const id = c.req.param('id')
const stmt = c.env.DB.prepare(`SELECT * FROM sms_messages WHERE id = ?`)
const row = await stmt.bind(id).first()
if (!row) return c.json({ success: false, error: '消息不存在' }, 404)
return c.json({ success: true, data: row })
})

View File

@@ -0,0 +1,99 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { getClientIP, nowMs } from '../utils/common'
import { verifySign } from '../utils/sign'
import { insertMessageAndLog } from '../utils/db'
export const receiveRoutes = new Hono<{ Bindings: Env }>()
receiveRoutes.post('/', async (c) => {
const contentType = c.req.header('content-type') || ''
let form: Record<string, string> = {}
if (contentType.includes('application/json')) {
const body = (await c.req.json().catch(() => ({}))) as any
form = { ...body }
} else {
const body = await c.req.parseBody()
for (const [k, v] of Object.entries(body)) {
form[k] = Array.isArray(v) ? String(v[0]) : String(v ?? '')
}
}
const from = form.from || ''
const msg = form.content || ''
if (!from || !msg) {
return c.json({ success: false, error: `缺少必填参数 (from: '${from}', content: '${msg}')` }, 400)
}
const timestamp = form.timestamp ? Number(form.timestamp) : nowMs()
const sign = form.sign || ''
const device = form.device || ''
const sim = form.sim || ''
const token = c.req.query('token') || form.token || ''
const ip = getClientIP(c.req.raw.headers)
let signValid: number | null = 1
let status = 'success'
let errorMessage: string | null = null
if (c.env.SIGN_VERIFY === 'true' && token) {
if (c.env.API_TOKEN && token !== c.env.API_TOKEN) {
signValid = 0
status = 'error'
errorMessage = '无效的 token'
} else if (!c.env.HMAC_SECRET) {
// 对齐旧版token 存在但 secret 为空时,跳过签名验证
signValid = 1
} else {
const maxAge = Number(c.env.SIGN_MAX_AGE_MS || 300000)
const now = nowMs()
const diff = now - timestamp
if (diff > maxAge) {
signValid = 0
status = 'error'
errorMessage = `时间戳过期(差异: ${(diff / 1000).toFixed(1)} 秒)`
} else {
const valid = verifySign(timestamp, sign, c.env.HMAC_SECRET)
signValid = valid ? 1 : 0
if (!valid) {
status = 'error'
errorMessage = '签名不匹配'
}
}
}
}
const nowIso = new Date().toISOString()
const messageRow = {
from_number: from,
content: msg,
timestamp,
device_info: device || null,
sim_info: sim || null,
sign_verified: signValid,
ip_address: ip,
created_at: nowIso,
}
const logRow = {
from_number: from,
content: msg,
timestamp,
sign: sign || null,
sign_valid: signValid,
ip_address: ip,
status,
error_message: errorMessage,
created_at: nowIso,
}
try {
await insertMessageAndLog(c.env, messageRow, logRow)
} catch {
return c.json({ success: false, error: '保存消息失败' }, 500)
}
return c.json({ success: true, message: '短信已接收' })
})

View File

@@ -0,0 +1,24 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const statsRoutes = new Hono<{ Bindings: Env }>()
statsRoutes.get('/', requireAuth, async (c) => {
const totalRow = await c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM sms_messages`).first<{ cnt: number }>()
const todayRow = await c.env.DB.prepare(`
SELECT COUNT(*) as cnt
FROM sms_messages
WHERE datetime(timestamp/1000, 'unixepoch') >= date('now')
`).first<{ cnt: number }>()
const failRow = await c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM receive_logs WHERE status != 'success'`).first<{ cnt: number }>()
return c.json({
success: true,
data: {
total: totalRow?.cnt || 0,
today: todayRow?.cnt || 0,
failed: failRow?.cnt || 0,
},
})
})

View File

@@ -0,0 +1,9 @@
export function getClientIP(headers: Headers) {
const forwarded = headers.get('X-Forwarded-For')
if (forwarded) return forwarded.split(',')[0].trim()
return headers.get('CF-Connecting-IP') || ''
}
export function nowMs() {
return Date.now()
}

40
worker/src/utils/db.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Env } from '../index'
export async function insertMessageAndLog(env: Env, msg: any, log: any) {
const stmtMsg = env.DB.prepare(
`INSERT INTO sms_messages (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
const stmtLog = env.DB.prepare(
`INSERT INTO receive_logs (from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
const now = new Date().toISOString()
const batch = env.DB.batch([
stmtMsg.bind(
msg.from_number,
msg.content,
msg.timestamp,
msg.device_info,
msg.sim_info,
msg.sign_verified,
msg.ip_address,
msg.created_at || now
),
stmtLog.bind(
log.from_number,
log.content,
log.timestamp,
log.sign,
log.sign_valid,
log.ip_address,
log.status,
log.error_message,
log.created_at || now
),
])
const results = await batch
return results
}

View File

@@ -0,0 +1,39 @@
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto'
const COOKIE_NAME = 'sms_session'
export function hashPassword(password: string, secret: string) {
const h = createHmac('sha256', secret)
h.update(password)
return h.digest('hex')
}
export function createSessionCookie(username: string, secret: string) {
const nonce = randomBytes(16).toString('hex')
const payload = `${username}.${Date.now()}.${nonce}`
const sig = createHmac('sha256', secret).update(payload).digest('hex')
return `${COOKIE_NAME}=${payload}.${sig}; HttpOnly; Path=/; SameSite=None; Secure`
}
export function clearSessionCookie() {
return `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=None; Secure`
}
export function verifySessionCookie(cookieHeader: string, secret: string) {
const cookie = parseCookie(cookieHeader, COOKIE_NAME)
if (!cookie) return false
const parts = cookie.split('.')
if (parts.length < 4) return false
const sig = parts.pop() as string
const payload = parts.join('.')
const expected = createHmac('sha256', secret).update(payload).digest('hex')
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
function parseCookie(header: string, key: string) {
const items = header.split(';').map((v) => v.trim())
for (const item of items) {
if (item.startsWith(key + '=')) return item.slice(key.length + 1)
}
return ''
}

14
worker/src/utils/sign.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createHmac } from 'node:crypto'
export function generateSign(timestamp: number, secret: string) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = createHmac('sha256', secret)
hmac.update(stringToSign)
const base64 = hmac.digest('base64')
return encodeURIComponent(base64)
}
export function verifySign(timestamp: number, sign: string, secret: string) {
const expected = generateSign(timestamp, secret)
return expected === sign
}

10
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
}
}

19
worker/wrangler.toml Normal file
View File

@@ -0,0 +1,19 @@
name = "smsreceiver-worker-api"
main = "src/index.ts"
compatibility_date = "2026-03-22"
compatibility_flags = ["nodejs_compat"]
[vars]
ADMIN_USER = "admin"
ADMIN_PASS_HASH = "b8e450efa9c66b2b50ec8a4d7eb51213d7fa3395e107575881ad999ee92744b5"
SESSION_SECRET = "1e81b5f9e5a695eba01e996b14871db8899b08e111cf8252df8aa4c91d1c7144"
API_TOKEN = "default_token"
HMAC_SECRET = ""
SIGN_VERIFY = "true"
SIGN_MAX_AGE_MS = "3600000"
CORS_ORIGIN = "*"
[[d1_databases]]
binding = "DB"
database_name = "smsreceiver"
database_id = "50bd73ba-218c-4eb6-948a-9014a8892575"