feat: import smsreceiver workerized code with full README
This commit is contained in:
220
README.md
Normal file
220
README.md
Normal 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
95
pages/public/app.css
Normal 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
319
pages/public/app.js
Normal 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
13
pages/public/index.html
Normal 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>
|
||||||
56
worker/migrate_sqlite_to_d1.py
Normal file
56
worker/migrate_sqlite_to_d1.py
Normal 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
1531
worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
worker/package.json
Normal file
17
worker/package.json
Normal 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
31
worker/schema.sql
Normal 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
47
worker/src/index.ts
Normal 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
|
||||||
10
worker/src/middlewares/auth.ts
Normal file
10
worker/src/middlewares/auth.ts
Normal 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
33
worker/src/routes/auth.ts
Normal 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
29
worker/src/routes/logs.ts
Normal 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 })
|
||||||
|
})
|
||||||
70
worker/src/routes/messages.ts
Normal file
70
worker/src/routes/messages.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
|
||||||
99
worker/src/routes/receive.ts
Normal file
99
worker/src/routes/receive.ts
Normal 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: '短信已接收' })
|
||||||
|
})
|
||||||
|
|
||||||
24
worker/src/routes/stats.ts
Normal file
24
worker/src/routes/stats.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
9
worker/src/utils/common.ts
Normal file
9
worker/src/utils/common.ts
Normal 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
40
worker/src/utils/db.ts
Normal 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
|
||||||
|
}
|
||||||
39
worker/src/utils/session.ts
Normal file
39
worker/src/utils/session.ts
Normal 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
14
worker/src/utils/sign.ts
Normal 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
10
worker/tsconfig.json
Normal 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
19
worker/wrangler.toml
Normal 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"
|
||||||
Reference in New Issue
Block a user