chore: restructure repo as worker collection
This commit is contained in:
220
smsreceiver-worker/README.md
Normal file
220
smsreceiver-worker/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
smsreceiver-worker/pages/public/app.css
Normal file
95
smsreceiver-worker/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
smsreceiver-worker/pages/public/app.js
Normal file
319
smsreceiver-worker/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
smsreceiver-worker/pages/public/index.html
Normal file
13
smsreceiver-worker/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
smsreceiver-worker/worker/migrate_sqlite_to_d1.py
Normal file
56
smsreceiver-worker/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
smsreceiver-worker/worker/package-lock.json
generated
Normal file
1531
smsreceiver-worker/worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
smsreceiver-worker/worker/package.json
Normal file
17
smsreceiver-worker/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
smsreceiver-worker/worker/schema.sql
Normal file
31
smsreceiver-worker/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
smsreceiver-worker/worker/src/index.ts
Normal file
47
smsreceiver-worker/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
smsreceiver-worker/worker/src/middlewares/auth.ts
Normal file
10
smsreceiver-worker/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
smsreceiver-worker/worker/src/routes/auth.ts
Normal file
33
smsreceiver-worker/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
smsreceiver-worker/worker/src/routes/logs.ts
Normal file
29
smsreceiver-worker/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
smsreceiver-worker/worker/src/routes/messages.ts
Normal file
70
smsreceiver-worker/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
smsreceiver-worker/worker/src/routes/receive.ts
Normal file
99
smsreceiver-worker/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
smsreceiver-worker/worker/src/routes/stats.ts
Normal file
24
smsreceiver-worker/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
smsreceiver-worker/worker/src/utils/common.ts
Normal file
9
smsreceiver-worker/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
smsreceiver-worker/worker/src/utils/db.ts
Normal file
40
smsreceiver-worker/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
smsreceiver-worker/worker/src/utils/session.ts
Normal file
39
smsreceiver-worker/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
smsreceiver-worker/worker/src/utils/sign.ts
Normal file
14
smsreceiver-worker/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
smsreceiver-worker/worker/tsconfig.json
Normal file
10
smsreceiver-worker/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
smsreceiver-worker/worker/wrangler.toml
Normal file
19
smsreceiver-worker/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