New Database
This commit is contained in:
+9
-5
@@ -1,14 +1,13 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# System dependencies
|
# System dependencies (mirrors.aliyun.com for CN networks)
|
||||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
libgomp1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Clone project
|
# 复制项目代码(含 .git,供 compose 的 app_code 卷内 git pull 更新工作流使用)
|
||||||
RUN git clone https://gitee.com/zhe-ovo/iot-dboard.git /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
@@ -16,4 +15,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["python", "start.py"]
|
# Gunicorn:2 个 worker 对应 2 逻辑核心(1 物理核心),线程模式;
|
||||||
|
# 2G 内存/2 CPU 限制由 docker-compose.yml 的 deploy.resources 配置。
|
||||||
|
# 后台调度器通过 PostgreSQL advisory lock 保证只在其中一个 worker 内运行。
|
||||||
|
CMD gunicorn --workers ${GUNICORN_WORKERS:-2} --threads ${GUNICORN_THREADS:-8} \
|
||||||
|
--bind 0.0.0.0:5000 --timeout 120 \
|
||||||
|
--access-logfile - --error-logfile - start:app
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
- **控制端** (`/control`) — 完整配置面板
|
- **控制端** (`/control`) — 完整配置面板
|
||||||
- **Root 管理员** (`id=0`) — 首个自动创建的账户,拥有全部功能与用户管理权限
|
- **Root 管理员** (`id=0`) — 首个自动创建的账户,拥有全部功能与用户管理权限
|
||||||
|
|
||||||
|
数据库使用 **PostgreSQL**(Docker 内置),生产以 **Gunicorn** 多 worker 运行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多用户**:Root 管理员可注册/编辑/删除用户;所有用户数据完全隔离,普通用户无管理权限
|
- **多用户**:Root 管理员可注册/编辑/删除用户;所有用户数据完全隔离,普通用户无管理权限
|
||||||
- **两种注册方式**:管理员代注册(正式账户,可代配 TOTP/Passkey);用户自助申请(管理员确认后生成临时账户)
|
- **账户创建**:仅管理员可创建 —— 管理员代注册(正式账户,可代配 TOTP/Passkey)或创建临时账户(用户自助填写登录方式)
|
||||||
- **临时账户**:服务器持有临时 TOTP 密钥,管理员仅可见实时验证码;用户登录后被强制引导配置自己的登录方式,配置完成即正式生效
|
- **临时账户**:服务器持有临时 TOTP 密钥,管理员仅可见实时验证码;用户登录后被强制引导配置自己的登录方式,配置完成即正式生效
|
||||||
- **日历**:支持订阅 iCalendar (.ics) 链接及本地文件上传,自动同步日程
|
- **日历**:支持订阅 iCalendar (.ics) 链接及本地文件上传,自动同步日程
|
||||||
- **天气**:基于和风天气 (QWeather) API,含逐小时/分钟级预报
|
- **天气**:基于和风天气 (QWeather) API,含逐小时/分钟级预报
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
- **番茄钟**:定时提醒,自动继续,断网重连自动恢复
|
- **番茄钟**:定时提醒,自动继续,断网重连自动恢复
|
||||||
- **倒计时**:可自定义时长,到时提醒
|
- **倒计时**:可自定义时长,到时提醒
|
||||||
- **自定义展示页**:支持多组自定义 HTML 模板,一键切换
|
- **自定义展示页**:支持多组自定义 HTML 模板,一键切换
|
||||||
|
- **数据库管理(仅 Root)**:在 `/control → 设置 → 数据库管理` 导入旧版 SQLite 数据库文件或本系统导出的备份(自动识别格式)、导出现有数据库备份
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
**登录流程**:
|
**登录流程**:
|
||||||
|
|
||||||
```
|
```
|
||||||
首次启动(无数据库)→ 自动创建 Root 管理员 (id=0),控制台打印临时密码
|
首次启动(空数据库)→ 自动创建 Root 管理员 (id=0),控制台打印临时密码
|
||||||
↓
|
↓
|
||||||
访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码)
|
访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码)
|
||||||
↓
|
↓
|
||||||
@@ -58,16 +61,16 @@ Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA
|
|||||||
|
|
||||||
**用户管理(仅 Root)**:
|
**用户管理(仅 Root)**:
|
||||||
- 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成)
|
- 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成)
|
||||||
- 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码)
|
- 创建临时账户:生成临时 TOTP(密钥仅存服务器),管理员仅看到实时验证码
|
||||||
- 注册/编辑备注/删除用户均需**二次验证管理员身份**(Passkey 优先,无 Passkey 时用 TOTP)
|
- 注册/编辑备注/删除用户均需**二次验证管理员身份**(TOTP 优先,无 TOTP 时用 Passkey)
|
||||||
- 删除用户会级联删除其全部数据并使其会话立即失效
|
- 删除用户会级联删除其全部数据并使其会话立即失效
|
||||||
- 用户 ID 对用户透明(仅在备注默认为 ID 时可见一次),普通用户只能看到自己的数据
|
- 用户 ID 对用户透明(仅在备注默认为 ID 时可见一次),普通用户只能看到自己的数据
|
||||||
|
|
||||||
**安全防护**:
|
**安全防护**:
|
||||||
- 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时)
|
- 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时)
|
||||||
- CSRF 防护(Origin/Referer 校验)
|
- CSRF 防护(Origin/Referer 校验)
|
||||||
- 敏感操作二次验证(用户管理、MFA 配置需重新确认身份)
|
- 敏感操作二次验证(用户管理、MFA 配置、数据库管理需重新确认身份)
|
||||||
- **双 Token 认证**:短期 access token(`Authorization: Bearer`,15 分钟)+ 长期 refresh token(httpOnly cookie,30 天滑动续期、每次刷新轮换)
|
- **双 Token 认证**:短期 access token(`Authorization: Bearer`,15 分钟)+ 长期 refresh token(httpOnly cookie,30 天滑动续期、每次刷新轮换、并发重放安全)
|
||||||
- Access token 快照会话版本:删除用户 / 踢设备 / 踢所有用户后**立即**使全部 access token 失效
|
- Access token 快照会话版本:删除用户 / 踢设备 / 踢所有用户后**立即**使全部 access token 失效
|
||||||
- 显示端 SSE 通过一次性短时 ticket 鉴权(EventSource 无法带自定义 header)
|
- 显示端 SSE 通过一次性短时 ticket 鉴权(EventSource 无法带自定义 header)
|
||||||
- 内容安全策略(CSP)限制脚本/样式来源
|
- 内容安全策略(CSP)限制脚本/样式来源
|
||||||
@@ -78,21 +81,30 @@ Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- PostgreSQL 14+(Docker 部署时无需单独安装,镜像内置)
|
||||||
|
|
||||||
### 开发环境
|
### 开发环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 安装依赖
|
# 1. 准备 PostgreSQL(本机或 Docker)
|
||||||
|
# docker run -d --name pg -e POSTGRES_USER=iot -e POSTGRES_PASSWORD=iot \
|
||||||
|
# -e POSTGRES_DB=iot_dboard -p 5432:5432 postgres:16-alpine
|
||||||
|
|
||||||
|
# 2. 安装依赖
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 2. 直接运行(调试模式)
|
# 3. 运行(开发模式)
|
||||||
python start.py
|
DATABASE_URL="postgresql://iot:iot@localhost:5432/iot_dboard" python run.py
|
||||||
|
|
||||||
# 3. 访问
|
# 4. 访问
|
||||||
# 显示端: http://localhost:5000
|
# 显示端: http://localhost:5001
|
||||||
# 控制端: http://localhost:5000/control
|
# 控制端: http://localhost:5001/control
|
||||||
```
|
```
|
||||||
|
|
||||||
### 生产部署 (推荐 Nginx + Certbot)
|
### 生产部署 (推荐 Nginx + Gunicorn)
|
||||||
|
|
||||||
```
|
```
|
||||||
客户端 (HTTPS)
|
客户端 (HTTPS)
|
||||||
@@ -102,10 +114,10 @@ Nginx (SSL termination + Certbot)
|
|||||||
│ proxy_pass http://127.0.0.1:5000
|
│ proxy_pass http://127.0.0.1:5000
|
||||||
│
|
│
|
||||||
▼ 5000 (HTTP)
|
▼ 5000 (HTTP)
|
||||||
Flask (127.0.0.1:5000)
|
Gunicorn (2 workers × 2 threads, start:app)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
data.db (SQLite)
|
PostgreSQL (Docker 内置, 端口 5432)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1. Nginx 配置
|
#### 1. Nginx 配置
|
||||||
@@ -125,30 +137,22 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 300s; # SSE 长连接
|
||||||
}
|
}
|
||||||
|
|
||||||
# 可选:限制上传大小
|
client_max_body_size 200M; # 数据库导入需要较大体积
|
||||||
client_max_body_size 5M;
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP → HTTPS 强制跳转
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 启动 Flask
|
#### 2. 启动 Gunicorn
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 生产模式启动
|
DATABASE_URL="postgresql://iot:iot@127.0.0.1:5432/iot_dboard" \
|
||||||
FLASK_HTTPS=yes python start.py
|
FLASK_HTTPS=yes gunicorn --workers 2 --threads 2 --bind 127.0.0.1:5000 start:app
|
||||||
|
|
||||||
# 可选:固定 SECRET_KEY(保持跨重启会话有效)
|
|
||||||
export FLASK_SECRET_KEY="$(openssl rand -hex 32)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后台调度器(闹钟/提醒/天气/番茄钟)通过 **PostgreSQL advisory lock** 保证只在其中一个 worker 内运行;若持有者异常退出,其他 worker 的 watchdog 会自动接管。
|
||||||
|
|
||||||
#### 3. Certbot 申请证书
|
#### 3. Certbot 申请证书
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -157,14 +161,12 @@ sudo certbot --nginx -d your-domain.com
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker 部署
|
## Docker 部署
|
||||||
|
|
||||||
### 快速启动
|
### 快速启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建并启动(后台运行,首次会自动从 Gitee clone 代码)
|
# 构建并启动(自动创建 PostgreSQL 与 Web 服务)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 查看启动日志(获取临时密码)
|
# 查看启动日志(获取临时密码)
|
||||||
@@ -195,36 +197,36 @@ docker compose up -d
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- FLASK_BIND=0.0.0.0
|
- DATABASE_URL=postgresql://iot:${POSTGRES_PASSWORD:-iot}@db:5432/iot_dboard
|
||||||
- FLASK_SECRET_KEY=your-secret-here # 可选:固定密钥
|
- FLASK_SECRET_KEY=your-secret-here # 可选:固定密钥
|
||||||
- FLASK_HTTPS=yes # 如果前面有 HTTPS 反代
|
- FLASK_HTTPS=yes # 如果前面有 HTTPS 反代
|
||||||
|
- GUNICORN_WORKERS=2 # 默认 2(对应 1 物理核心 = 2 逻辑核心)
|
||||||
|
- GUNICORN_THREADS=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资源限制
|
||||||
|
|
||||||
|
`docker-compose.yml` 中已按 **1 物理核心(2 逻辑核心)+ 2G 内存** 配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "2"
|
||||||
|
memory: 2G
|
||||||
```
|
```
|
||||||
|
|
||||||
### 持久化数据
|
### 持久化数据
|
||||||
|
|
||||||
- **数据库**:`./data/` 目录挂载到 `/app/data/`,数据库文件 `data.db` 自动在此创建
|
- **PostgreSQL 数据**:命名卷 `pgdata`(`docker compose down` 不丢失)
|
||||||
- **项目代码**:命名卷 `app_code` 挂载到 `/app`,首次自动初始化为镜像内克隆的代码,之后 `git pull` 的修改永久保留数据库文件 `data.db` 会自动在该目录下创建,重启/升级后数据保留。
|
- **旧数据库导入**:将旧 SQLite `.db` 文件放入 `./data/` 目录后,在 `/control → 设置 → 数据库管理` 上传导入(也支持直接选择文件上传)
|
||||||
|
- **项目代码**:命名卷 `app_code` 挂载到 `/app`,首次自动初始化为镜像内代码,之后 `git pull` 的修改永久保留
|
||||||
```bash
|
|
||||||
# 数据文件位置
|
|
||||||
ls -la data/
|
|
||||||
# data.db ← 数据库
|
|
||||||
```
|
|
||||||
|
|
||||||
### 搭配 Nginx 反代(生产)
|
### 搭配 Nginx 反代(生产)
|
||||||
|
|
||||||
使用宿主机上的 Nginx(或额外 Nginx 容器)做 SSL 终结。配置与前文 Nginx 部分相同,只需将 `proxy_pass` 指向 Docker 宿主机 IP:5000:
|
使用宿主机上的 Nginx(或额外 Nginx 容器)做 SSL 终结。配置与前文 Nginx 部分相同,只需将 `proxy_pass` 指向 Docker 宿主机 IP:5000。
|
||||||
|
|
||||||
```nginx
|
> ⚠️ 注意:容器内 Gunicorn 绑定 `0.0.0.0:5000`,宿主机可直接访问 `localhost:5000`。如需限制仅 Nginx 访问,在 `docker-compose.yml` 中将 `ports` 改为 `127.0.0.1:5000:5000`。
|
||||||
location / {
|
|
||||||
proxy_pass http://宿主机IP:5000;
|
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ 注意:容器内 Flask 绑定 `0.0.0.0:5000`,宿主机可直接访问 `localhost:5000`。如需限制仅 Nginx 访问,在 `docker-compose.yml` 中将 `ports` 改为 `127.0.0.1:5000:5000`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,27 +234,59 @@ location / {
|
|||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
|
| `DATABASE_URL` | `postgresql://iot:iot@db:5432/iot_dboard` | PostgreSQL 连接串(Docker 内指向 db 服务) |
|
||||||
| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 refresh token cookie 的 Secure 属性(Nginx 反代时启用) |
|
| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 refresh token cookie 的 Secure 属性(Nginx 反代时启用) |
|
||||||
| `FLASK_SECRET_KEY` | 自动随机生成 | 保留兼容;认证已由 access/refresh 双 token 承载,不再依赖 Flask session |
|
| `FLASK_SECRET_KEY` | 自动随机生成 | 保留兼容;认证已由 access/refresh 双 token 承载,不再依赖 Flask session |
|
||||||
| `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` |
|
| `FLASK_DISABLE_SCHEDULER` | 空 | 设为 `true` 禁用后台调度器(调试用) |
|
||||||
| `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 |
|
| `GUNICORN_WORKERS` | `2` | Gunicorn worker 数(对应逻辑核心数) |
|
||||||
| `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) |
|
| `GUNICORN_THREADS` | `2` | 每个 worker 的线程数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库管理(仅 Root)
|
||||||
|
|
||||||
|
> 位置:`/control → 设置 → 数据库管理`(仅 Root 用户可见)
|
||||||
|
|
||||||
|
- **导出现有数据库**:下载自包含的 JSON 备份(含 schema 版本与全部数据,不含会话令牌;**含登录凭据,请妥善保管**)
|
||||||
|
- **导入旧版 SQLite 数据库**:上传旧版单用户 `.db` 文件,自动迁移(旧数据归属 Root、缺失字段用 DEFAULT 填充、旧版弃用表自动丢弃、旧版 PIN 不会迁移)
|
||||||
|
- **导入备份文件**:上传本系统导出的 JSON 备份(与 SQLite 文件自动识别)
|
||||||
|
|
||||||
|
导入为**单事务原子操作**:任何一行失败都会整体回滚,当前数据不受影响;导入成功会清空会话(所有设备需重新登录)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库迁移(版本化,升级无需重写数据)
|
||||||
|
|
||||||
|
数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 **2**)。每次启动时比对:
|
||||||
|
|
||||||
|
| 情况 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 数据库版本 == 程序版本 | 直接使用,跳过升级 |
|
||||||
|
| 无版本字段或 数据库版本 < 程序版本 | 创建缺失的表/列,缺失字段一律用 SCHEMA DEFAULT 填充,并更新版本号 |
|
||||||
|
| 数据库版本 > 程序版本 | **报错并终止启动**(数据库比程序新,需先升级程序) |
|
||||||
|
|
||||||
|
从旧版 SQLite 数据库迁移到 PostgreSQL **不依赖自动迁移**:由 Root 在 `/control → 设置 → 数据库管理` 手动导入旧 `.db` 文件完成。
|
||||||
|
|
||||||
|
运行测试:`python3 tests/test_multi_user.py`(需本地 PostgreSQL,可用环境变量 `TEST_PG_HOST/TEST_PG_USER/TEST_PG_PASS/TEST_PG_DB` 覆盖)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── run.py # 入口(端口 5001,仅开发)
|
├── start.py # 生产入口(Gunicorn WSGI: start:app,含调度器单例)
|
||||||
├── start.py # 入口(端口 5000,推荐生产使用)
|
├── run.py # 开发入口(Flask dev server,端口 5001)
|
||||||
|
├── Dockerfile # 镜像构建(Gunicorn)
|
||||||
|
├── docker-compose.yml # PostgreSQL + Web 服务编排
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── app.py # Flask 应用工厂 + 安全配置
|
│ ├── app.py # Flask 应用工厂 + 安全配置
|
||||||
│ ├── auth.py # 双 Token 认证(TOTP/Passkey/恢复码/临时密码)
|
│ ├── auth.py # 双 Token 认证(TOTP/Passkey/恢复码/临时密码)
|
||||||
│ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证
|
│ ├── pg_compat.py # sqlite3 兼容的 PostgreSQL 访问层(方言翻译)
|
||||||
│ ├── jwt_util.py # Ed25519 JWT 签名(和风天气)
|
│ ├── models.py # 数据模型、schema 版本迁移、token 管理
|
||||||
│ ├── models.py # SQLite 模型、schema 版本迁移、token 管理
|
│ ├── db_admin.py # 数据库导入/导出(SQLite/JSON、原子恢复)
|
||||||
│ ├── routes.py # 全部 API 路由 + CSRF 防护 + SSE ticket
|
│ ├── routes.py # 全部 API 路由 + CSRF 防护 + SSE ticket
|
||||||
|
│ ├── scheduler.py # 后台调度器(advisory lock 单例 + watchdog)
|
||||||
│ └── *_mgr.py # 各功能模块管理器
|
│ └── *_mgr.py # 各功能模块管理器
|
||||||
└── frontend/
|
└── frontend/
|
||||||
├── templates/ # HTML 模板
|
├── templates/ # HTML 模板
|
||||||
@@ -260,29 +294,18 @@ location / {
|
|||||||
├── css/ # 样式
|
├── css/ # 样式
|
||||||
├── js/ # JavaScript(纯原生,无框架)
|
├── js/ # JavaScript(纯原生,无框架)
|
||||||
│ ├── token.js # 双 Token 客户端(access 存储/刷新/401 重试/SSE ticket)
|
│ ├── token.js # 双 Token 客户端(access 存储/刷新/401 重试/SSE ticket)
|
||||||
│ ├── control.js # 控制端逻辑
|
│ ├── control.js # 控制端逻辑(含数据库管理)
|
||||||
│ └── display.js # 显示端逻辑(账户时区渲染)
|
│ └── display.js # 显示端逻辑(账户时区渲染、本地到期兜底)
|
||||||
└── ringtones/ # 自定义铃声
|
└── ringtones/ # 自定义铃声
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 数据库迁移(版本化,升级无需重写数据)
|
|
||||||
|
|
||||||
数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 1)。每次启动时比对:
|
|
||||||
|
|
||||||
| 情况 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| 数据库版本 == 程序版本 | 直接使用,跳过重建 |
|
|
||||||
| 无版本字段(旧版数据库)或 数据库版本 < 程序版本 | 重建数据库并迁移(旧版单用户数据自动归属 Root (id=0),新增字段一律用 DEFAULT 填充,`camera_config` 等已删除的表自动丢弃) |
|
|
||||||
| 数据库版本 > 程序版本 | **报错并终止启动**(数据库比程序新,需先升级程序) |
|
|
||||||
|
|
||||||
旧版 4 位 PIN 不会被迁移。运行测试:`python3 tests/test_multi_user.py`。
|
|
||||||
|
|
||||||
## 注意
|
## 注意
|
||||||
|
|
||||||
- **Passkey (WebAuthn) 需要 HTTPS**(localhost 除外)
|
- **Passkey (WebAuthn) 需要 HTTPS**(localhost 除外)
|
||||||
- 旧版 4 位 PIN 已弃用,数据库迁移时旧 PIN 不会被迁移
|
- 旧版 4 位 PIN 已弃用,SQLite 导入时旧 PIN 不会被迁移
|
||||||
|
- 多 Gunicorn worker 下,显示端闹钟/番茄钟/倒计时提醒除 SSE 即时推送外,还有**本地到期检测兜底**,保证到点必定提醒
|
||||||
|
|
||||||
### License
|
### License
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ def delete_alarm(alarm_id: int, user_id: int = None) -> bool:
|
|||||||
def check_alarms(user_id: int = None) -> list:
|
def check_alarms(user_id: int = None) -> list:
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
user_id = _uid()
|
user_id = _uid()
|
||||||
global _triggered_state
|
|
||||||
|
|
||||||
db = get_db(current_app.config["DB_PATH"])
|
db = get_db(current_app.config["DB_PATH"])
|
||||||
now = tz_now()
|
now = tz_now()
|
||||||
|
|||||||
+62
-30
@@ -1,16 +1,12 @@
|
|||||||
"""Flask application factory and SSE event system."""
|
"""Flask application factory and SSE event system."""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
from datetime import timedelta
|
||||||
import threading
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from backend.time_util import now as tz_now, strptime as tz_strptime
|
from backend.time_util import now as tz_now, strptime as tz_strptime
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from backend.models import init_db
|
from backend.models import init_db, get_db
|
||||||
from backend.routes import register_routes
|
from backend.routes import register_routes
|
||||||
from backend.events import EventBus
|
from backend.events import EventBus
|
||||||
from backend.weather_mgr import fetch_weather, fetch_hourly
|
from backend.weather_mgr import fetch_weather, fetch_hourly
|
||||||
@@ -70,33 +66,69 @@ def create_app(db_path: str = "data.db") -> Flask:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _startup_weather(app, db_path: str):
|
# Startup weather fetch lock: only one Gunicorn worker may fetch at a time
|
||||||
try:
|
# (others skip; the cache is then fresh so the regular scheduler job handles it).
|
||||||
conn = sqlite3.connect(db_path)
|
_WEATHER_STARTUP_LOCK_KEY = 0x5745415448455253 # "WEATHERS"
|
||||||
row = conn.execute(
|
|
||||||
"SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
row = None
|
|
||||||
|
|
||||||
should_fetch = True
|
|
||||||
if row and row[0]:
|
def _startup_weather(app, db_path: str):
|
||||||
|
# Multi-worker guard: without this, every worker would hit the weather API
|
||||||
|
# concurrently on boot. Whoever wins the advisory lock runs the check/fetch.
|
||||||
|
lock_conn = None
|
||||||
|
got_lock = False
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
lock_conn = psycopg2.connect(db_path, connect_timeout=5)
|
||||||
|
cur = lock_conn.cursor()
|
||||||
|
cur.execute("SELECT pg_try_advisory_lock(%s)", (_WEATHER_STARTUP_LOCK_KEY,))
|
||||||
|
got_lock = bool(cur.fetchone()[0])
|
||||||
|
cur.close()
|
||||||
|
except Exception:
|
||||||
|
if lock_conn is not None:
|
||||||
|
try:
|
||||||
|
lock_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if not got_lock:
|
||||||
try:
|
try:
|
||||||
updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S")
|
lock_conn.close()
|
||||||
age = tz_now() - updated
|
|
||||||
if age < timedelta(minutes=30):
|
|
||||||
should_fetch = False
|
|
||||||
app.logger.info(
|
|
||||||
f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch"
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
if should_fetch:
|
try:
|
||||||
try:
|
try:
|
||||||
fetch_weather()
|
conn = get_db(db_path)
|
||||||
fetch_hourly()
|
row = conn.execute(
|
||||||
app.logger.info("Startup: weather fetched (cache stale or empty)")
|
"SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1"
|
||||||
except Exception as e:
|
).fetchone()
|
||||||
app.logger.warning(f"Startup weather fetch failed: {e}")
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
row = None
|
||||||
|
|
||||||
|
should_fetch = True
|
||||||
|
if row and row["updated_at"]:
|
||||||
|
try:
|
||||||
|
updated = tz_strptime(row["updated_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
age = tz_now() - updated
|
||||||
|
if age < timedelta(minutes=30):
|
||||||
|
should_fetch = False
|
||||||
|
app.logger.info(
|
||||||
|
f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if should_fetch:
|
||||||
|
try:
|
||||||
|
fetch_weather()
|
||||||
|
fetch_hourly()
|
||||||
|
app.logger.info("Startup: weather fetched (cache stale or empty)")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.warning(f"Startup weather fetch failed: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
lock_conn.close() # releases the advisory lock
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
+3
-4
@@ -20,11 +20,10 @@ import string
|
|||||||
import time
|
import time
|
||||||
from flask import g, redirect, url_for, request, jsonify, current_app
|
from flask import g, redirect, url_for, request, jsonify, current_app
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
get_auth_config, update_auth_config, get_temp_password, set_temp_password,
|
get_auth_config, get_temp_password,
|
||||||
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured,
|
is_admin_user, get_db,
|
||||||
user_exists, is_admin_user, get_db,
|
|
||||||
create_auth_token, find_access_token,
|
create_auth_token, find_access_token,
|
||||||
delete_auth_token, REFRESH_TOKEN_TTL, ACCESS_TOKEN_TTL,
|
delete_auth_token, REFRESH_TOKEN_TTL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── Token / cookie constants ────────────────
|
# ─── Token / cookie constants ────────────────
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ Every function is scoped to an explicit `user_id` so each user only ever
|
|||||||
sees/mutates their own data (defense in depth: rows are filtered by user_id
|
sees/mutates their own data (defense in depth: rows are filtered by user_id
|
||||||
even though they also join user-scoped groups/sources).
|
even though they also join user-scoped groups/sources).
|
||||||
"""
|
"""
|
||||||
import os
|
from datetime import datetime, timedelta
|
||||||
import hashlib
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from backend.time_util import now as tz_now
|
from backend.time_util import now as tz_now
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from backend.models import get_db
|
from backend.models import get_db
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Countdown timer management — duration-based, once-only."""
|
"""Countdown timer management — duration-based, once-only."""
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from backend.time_util import isoformat as tz_isoformat
|
from backend.time_util import isoformat as tz_isoformat
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from backend.models import get_db
|
from backend.models import get_db
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Database administration: export / import / format detection.
|
||||||
|
|
||||||
|
Only Root may call these (enforced at the route layer). All operations are
|
||||||
|
atomic: an import runs inside a single transaction — any failing row rolls the
|
||||||
|
whole import back, leaving the current database untouched.
|
||||||
|
|
||||||
|
Formats:
|
||||||
|
- ``sqlite`` — a legacy SQLite database file (magic bytes ``SQLite format 3``).
|
||||||
|
Tables not present in the current SCHEMA are dropped (e.g. camera_config);
|
||||||
|
the legacy single-user `auth_config` row (keyed by id=1) is attributed to
|
||||||
|
Root (user 0); missing columns are filled with their SCHEMA DEFAULT.
|
||||||
|
- ``json`` — the self-contained backup produced by :func:`export_database`
|
||||||
|
(``{"format": "iot-dboard-backup", ...}``).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from backend.models import (
|
||||||
|
get_db, SCHEMA, DB_VERSION, _CREATE_TABLE_RE, _table_columns,
|
||||||
|
_read_db_version, _ensure_defaults, _fix_identity_sequences,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tables whose contents are exported/imported (everything except the schema
|
||||||
|
# version row and transient session tokens).
|
||||||
|
_EXCLUDE_TABLES = {"meta", "auth_tokens"}
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_table_names() -> list:
|
||||||
|
return [m.group(1) for m in _CREATE_TABLE_RE.finditer(SCHEMA)]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_format(data: bytes) -> str:
|
||||||
|
"""Detect the database file format: 'sqlite', 'json' or 'unknown'."""
|
||||||
|
if data[:16] == b"SQLite format 3\x00":
|
||||||
|
return "sqlite"
|
||||||
|
try:
|
||||||
|
obj = json.loads(data.decode("utf-8"))
|
||||||
|
if isinstance(obj, dict) and obj.get("format") == "iot-dboard-backup":
|
||||||
|
return "json"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def export_database(db_path: str) -> dict:
|
||||||
|
"""Snapshot every data table (except auth_tokens) into a JSON-serializable dict."""
|
||||||
|
conn = get_db(db_path)
|
||||||
|
try:
|
||||||
|
tables = _schema_table_names()
|
||||||
|
out = {
|
||||||
|
"format": "iot-dboard-backup",
|
||||||
|
"backup_version": 1,
|
||||||
|
"db_version": _read_db_version(conn) or DB_VERSION,
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"tables": {},
|
||||||
|
}
|
||||||
|
for t in tables:
|
||||||
|
if t in _EXCLUDE_TABLES:
|
||||||
|
continue
|
||||||
|
cols = _table_columns(conn, t)
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT {', '.join(cols)} FROM {t} ORDER BY 1"
|
||||||
|
).fetchall()
|
||||||
|
out["tables"][t] = {
|
||||||
|
"columns": cols,
|
||||||
|
"rows": [[r[c] for c in cols] for r in rows],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def export_database_json(db_path: str) -> str:
|
||||||
|
"""Export as a compact JSON string (files are small; no pretty printing)."""
|
||||||
|
return json.dumps(export_database(db_path), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SQLite import ───────────────────────────
|
||||||
|
|
||||||
|
def _sqlite_tables(conn_old) -> list:
|
||||||
|
return [
|
||||||
|
r["name"] for r in conn_old.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_rows(conn_old, table: str, cols: list) -> list:
|
||||||
|
if not cols:
|
||||||
|
return []
|
||||||
|
col_str = ", ".join(cols)
|
||||||
|
return conn_old.execute(f"SELECT {col_str} FROM {table}").fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sqlite(data: bytes) -> dict:
|
||||||
|
"""Read a legacy SQLite file into {table: {"columns": [...], "rows": [...]}}.
|
||||||
|
auth_config special case: legacy single-user row (id=1) belongs to Root."""
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".db")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
conn_old = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True)
|
||||||
|
conn_old.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
tables = {}
|
||||||
|
for t in _sqlite_tables(conn_old):
|
||||||
|
cols = [r["name"] for r in conn_old.execute(f"PRAGMA table_info({t})").fetchall()]
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for row in _sqlite_rows(conn_old, t, cols):
|
||||||
|
rows.append([row[c] for c in cols])
|
||||||
|
tables[t] = {"columns": cols, "rows": rows}
|
||||||
|
return tables
|
||||||
|
finally:
|
||||||
|
conn_old.close()
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_table(conn, table: str, data: dict, where: str, skipped: list) -> int:
|
||||||
|
"""Insert rows of one table into the target PG database (strict mode).
|
||||||
|
Returns the number of rows inserted."""
|
||||||
|
src_cols = data.get("columns") or []
|
||||||
|
rows = data.get("rows") or []
|
||||||
|
if not src_cols or not rows:
|
||||||
|
return 0
|
||||||
|
dst_cols = _table_columns(conn, table)
|
||||||
|
if not dst_cols:
|
||||||
|
skipped.append(table) # table not in schema — dropped (e.g. camera_config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if table == "auth_config":
|
||||||
|
return _import_auth_config(conn, data, where)
|
||||||
|
|
||||||
|
# Only common columns are copied; missing target columns get their DEFAULT.
|
||||||
|
common = [c for c in dst_cols if c in src_cols]
|
||||||
|
if not common:
|
||||||
|
skipped.append(table)
|
||||||
|
return 0
|
||||||
|
col_str = ", ".join(common)
|
||||||
|
placeholders = ", ".join("?" * len(common))
|
||||||
|
sql = f"INSERT INTO {table} ({col_str}) VALUES ({placeholders})"
|
||||||
|
n = 0
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
vals = []
|
||||||
|
for c in common:
|
||||||
|
vals.append(row[src_cols.index(c)])
|
||||||
|
try:
|
||||||
|
conn.execute(sql, tuple(vals))
|
||||||
|
n += 1
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"{where}: 表 {table} 第 {i + 1} 行导入失败: {e}"
|
||||||
|
) from e
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _import_auth_config(conn, data: dict, where: str) -> int:
|
||||||
|
"""Special case: the legacy auth_config table is keyed by `id` (single user,
|
||||||
|
id=1); the new table is keyed by `user_id`. The legacy row belongs to Root."""
|
||||||
|
src_cols = data.get("columns") or []
|
||||||
|
rows = data.get("rows") or []
|
||||||
|
if not src_cols or not rows:
|
||||||
|
return 0
|
||||||
|
dst_cols = _table_columns(conn, "auth_config")
|
||||||
|
n = 0
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
d = {c: row[src_cols.index(c)] for c in src_cols}
|
||||||
|
if "user_id" in src_cols:
|
||||||
|
uid = d.get("user_id") or 0
|
||||||
|
else:
|
||||||
|
old_id = d.get("id", 1)
|
||||||
|
uid = 0 if old_id == 1 else (old_id or 0)
|
||||||
|
common = [c for c in dst_cols if c in src_cols and c not in ("id", "user_id")]
|
||||||
|
if not common:
|
||||||
|
continue
|
||||||
|
col_str = ", ".join(common)
|
||||||
|
placeholders = ", ".join("?" * len(common))
|
||||||
|
sql = f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})"
|
||||||
|
try:
|
||||||
|
conn.execute(sql, (uid, *[d[c] for c in common]))
|
||||||
|
n += 1
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"{where}: auth_config 第 {i + 1} 行导入失败: {e}"
|
||||||
|
) from e
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Restore core (shared by sqlite + json imports) ─────
|
||||||
|
|
||||||
|
def _restore(db_path: str, tables: dict, where: str) -> dict:
|
||||||
|
"""Atomic restore: TRUNCATE every data table, insert the given rows, then
|
||||||
|
re-bootstrap defaults and stamp the schema version. Any failure rolls back
|
||||||
|
the whole import (the shim rolls back on error, keeping it atomic)."""
|
||||||
|
conn = get_db(db_path)
|
||||||
|
stats: dict[str, int] = {}
|
||||||
|
skipped: list = []
|
||||||
|
try:
|
||||||
|
# 1. Wipe current data (TRUNCATE is transactional in PostgreSQL).
|
||||||
|
all_tables = _schema_table_names()
|
||||||
|
truncate_list = [t for t in all_tables if t != "meta"]
|
||||||
|
if truncate_list:
|
||||||
|
conn.execute("TRUNCATE TABLE " + ", ".join(truncate_list) + " CASCADE")
|
||||||
|
|
||||||
|
# 2. Insert data (strict: first failing row aborts everything).
|
||||||
|
for t in all_tables:
|
||||||
|
if t in _EXCLUDE_TABLES:
|
||||||
|
continue
|
||||||
|
if t in tables:
|
||||||
|
stats[t] = _import_table(conn, t, tables[t], where, skipped)
|
||||||
|
|
||||||
|
# 3. Re-bootstrap defaults (Root user, config rows) + stamp version.
|
||||||
|
_ensure_defaults(conn)
|
||||||
|
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||||
|
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||||
|
_fix_identity_sequences(conn)
|
||||||
|
conn.commit()
|
||||||
|
if skipped:
|
||||||
|
stats["_skipped_tables"] = skipped
|
||||||
|
return stats
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def import_sqlite(db_path: str, data: bytes) -> dict:
|
||||||
|
"""Import a legacy SQLite database file (atomic)."""
|
||||||
|
tables = _read_sqlite(data)
|
||||||
|
return _restore(db_path, tables, "SQLite 导入")
|
||||||
|
|
||||||
|
|
||||||
|
def import_json(db_path: str, data: dict) -> dict:
|
||||||
|
"""Import a JSON backup produced by export_database (atomic)."""
|
||||||
|
if not isinstance(data, dict) or data.get("format") != "iot-dboard-backup":
|
||||||
|
raise ValueError("不是有效的备份文件(缺少 iot-dboard-backup 标记)")
|
||||||
|
tables = data.get("tables") or {}
|
||||||
|
if not isinstance(tables, dict):
|
||||||
|
raise ValueError("备份文件结构无效:tables 缺失")
|
||||||
|
return _restore(db_path, tables, "备份导入")
|
||||||
|
|
||||||
|
|
||||||
|
def import_blob(db_path: str, data: bytes) -> dict:
|
||||||
|
"""Import a file, auto-detecting sqlite vs json backup."""
|
||||||
|
fmt = detect_format(data)
|
||||||
|
if fmt == "sqlite":
|
||||||
|
return import_sqlite(db_path, data)
|
||||||
|
if fmt == "json":
|
||||||
|
return import_json(db_path, json.loads(data.decode("utf-8")))
|
||||||
|
raise ValueError("无法识别的数据库文件格式(仅支持 SQLite 数据库或本系统导出的备份 JSON)")
|
||||||
@@ -44,6 +44,3 @@ class EventBus:
|
|||||||
for item in dead:
|
for item in dead:
|
||||||
self._subscribers.remove(item)
|
self._subscribers.remove(item)
|
||||||
|
|
||||||
def broadcast(self):
|
|
||||||
"""Send heartbeat to all subscribers."""
|
|
||||||
self.publish("heartbeat", {"ts": __import__("time").time()})
|
|
||||||
|
|||||||
+273
-200
@@ -1,23 +1,31 @@
|
|||||||
"""SQLite database models, initialization, and user-scoped helpers.
|
"""PostgreSQL database models, initialization, and user-scoped helpers.
|
||||||
|
|
||||||
Multi-user design:
|
Multi-user design (unchanged from the SQLite era):
|
||||||
- The `users` table stores every account. Root (ID 0) is the administrator and
|
- The `users` table stores every account. Root (ID 0) is the administrator and
|
||||||
is created automatically on first boot (fresh DB) or when migrating an
|
is created automatically on first boot (fresh DB) or when migrating an
|
||||||
existing single-user database (all pre-existing data becomes Root's data).
|
existing single-user database (all pre-existing data becomes Root's data).
|
||||||
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
|
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
|
||||||
so legacy single-user rows are automatically attributed to Root on migration
|
so legacy single-user rows are automatically attributed to Root on import
|
||||||
and any NEW column added in the future gets a DEFAULT (the migration fills
|
and any NEW column added in the future gets a DEFAULT (the migration fills
|
||||||
missing columns with their SCHEMA defaults).
|
missing columns with their SCHEMA defaults).
|
||||||
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
|
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
|
||||||
|
|
||||||
|
The data-access layer talks PostgreSQL through ``backend.pg_compat``, a thin
|
||||||
|
sqlite3-compatible shim (``?`` placeholders, ``INSERT OR IGNORE``,
|
||||||
|
``datetime('now')``, ``cursor.lastrowid``, ``conn.total_changes`` are all
|
||||||
|
translated transparently).
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import re
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from backend.pg_compat import connect as _pg_connect
|
||||||
|
|
||||||
|
# ─── PostgreSQL schema ───────────────────────
|
||||||
|
# All new columns must carry a DEFAULT (migration contract: missing columns are
|
||||||
|
# filled with their SCHEMA default when the DB is upgraded).
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
-- Global schema version (single row). All new columns must carry a DEFAULT.
|
-- Global schema version (single row).
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
version INTEGER NOT NULL DEFAULT 1
|
version INTEGER NOT NULL DEFAULT 1
|
||||||
@@ -26,7 +34,7 @@ CREATE TABLE IF NOT EXISTS meta (
|
|||||||
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
|
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
|
||||||
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
|
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
|
||||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
|
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
|
||||||
@@ -34,14 +42,14 @@ CREATE TABLE IF NOT EXISTS auth_tokens (
|
|||||||
temp INTEGER NOT NULL DEFAULT 0,
|
temp INTEGER NOT NULL DEFAULT 0,
|
||||||
session_version INTEGER NOT NULL DEFAULT 0,
|
session_version INTEGER NOT NULL DEFAULT 0,
|
||||||
reauth_at INTEGER NOT NULL DEFAULT 0,
|
reauth_at INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS')),
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
remark TEXT NOT NULL DEFAULT '',
|
remark TEXT NOT NULL DEFAULT '',
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
||||||
is_temp INTEGER NOT NULL DEFAULT 0,
|
is_temp INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -49,7 +57,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
|
||||||
session_version INTEGER NOT NULL DEFAULT 0,
|
session_version INTEGER NOT NULL DEFAULT 0,
|
||||||
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
|
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_config (
|
CREATE TABLE IF NOT EXISTS auth_config (
|
||||||
@@ -64,16 +72,16 @@ CREATE TABLE IF NOT EXISTS auth_config (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS calendar_groups (
|
CREATE TABLE IF NOT EXISTS calendar_groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT DEFAULT '#4A90D9',
|
color TEXT DEFAULT '#4A90D9',
|
||||||
enabled INTEGER DEFAULT 1,
|
enabled INTEGER DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS calendar_sources (
|
CREATE TABLE IF NOT EXISTS calendar_sources (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
group_id INTEGER NOT NULL,
|
group_id INTEGER NOT NULL,
|
||||||
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
|
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
|
||||||
@@ -85,7 +93,7 @@ CREATE TABLE IF NOT EXISTS calendar_sources (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
source_id INTEGER NOT NULL,
|
source_id INTEGER NOT NULL,
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
@@ -99,7 +107,7 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reminders (
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
calendar_group_id INTEGER NOT NULL,
|
calendar_group_id INTEGER NOT NULL,
|
||||||
minutes_before INTEGER DEFAULT 15,
|
minutes_before INTEGER DEFAULT 15,
|
||||||
@@ -108,7 +116,7 @@ CREATE TABLE IF NOT EXISTS reminders (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_config (
|
CREATE TABLE IF NOT EXISTS weather_config (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
api_key TEXT DEFAULT '',
|
api_key TEXT DEFAULT '',
|
||||||
city TEXT DEFAULT 'Beijing',
|
city TEXT DEFAULT 'Beijing',
|
||||||
@@ -124,35 +132,35 @@ CREATE TABLE IF NOT EXISTS weather_config (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_cache (
|
CREATE TABLE IF NOT EXISTS weather_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
|
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
fetched_at TEXT DEFAULT (datetime('now'))
|
fetched_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
|
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
fetched_at TEXT
|
fetched_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_warning_cache (
|
CREATE TABLE IF NOT EXISTS weather_warning_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
fetched_at TEXT
|
fetched_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS clock_config (
|
CREATE TABLE IF NOT EXISTS clock_config (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
city TEXT DEFAULT 'Beijing',
|
city TEXT DEFAULT 'Beijing',
|
||||||
timezone TEXT DEFAULT 'Asia/Shanghai',
|
timezone TEXT DEFAULT 'Asia/Shanghai',
|
||||||
@@ -160,7 +168,7 @@ CREATE TABLE IF NOT EXISTS clock_config (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alarms (
|
CREATE TABLE IF NOT EXISTS alarms (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
label TEXT DEFAULT '',
|
label TEXT DEFAULT '',
|
||||||
time TEXT NOT NULL,
|
time TEXT NOT NULL,
|
||||||
@@ -171,7 +179,7 @@ CREATE TABLE IF NOT EXISTS alarms (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS countdowns (
|
CREATE TABLE IF NOT EXISTS countdowns (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
label TEXT DEFAULT '',
|
label TEXT DEFAULT '',
|
||||||
duration_seconds INTEGER DEFAULT 300,
|
duration_seconds INTEGER DEFAULT 300,
|
||||||
@@ -181,7 +189,7 @@ CREATE TABLE IF NOT EXISTS countdowns (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS display_config (
|
CREATE TABLE IF NOT EXISTS display_config (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
|
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
|
||||||
custom_html TEXT DEFAULT '',
|
custom_html TEXT DEFAULT '',
|
||||||
@@ -191,7 +199,7 @@ CREATE TABLE IF NOT EXISTS display_config (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pomodoro_state (
|
CREATE TABLE IF NOT EXISTS pomodoro_state (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
running INTEGER DEFAULT 0,
|
running INTEGER DEFAULT 0,
|
||||||
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
|
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
|
||||||
@@ -213,26 +221,27 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Tables whose rows are completely per-user (used for cascade delete).
|
# Ordered list of all user-data tables (children first) for FK-safe deletion.
|
||||||
USER_DATA_TABLES = [
|
_DELETE_ORDER = [
|
||||||
"auth_config", "calendar_groups", "calendar_sources", "calendar_events",
|
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
||||||
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
|
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
||||||
"weather_minutely_cache", "weather_warning_cache", "clock_config",
|
"weather_warning_cache", "weather_config", "clock_config",
|
||||||
"alarms", "countdowns", "display_config", "pomodoro_state",
|
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
||||||
"auth_tokens",
|
"auth_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_db(db_path: str = "data.db") -> sqlite3.Connection:
|
def get_db(db_path: str = "data.db"):
|
||||||
conn = sqlite3.connect(db_path)
|
"""Open a PostgreSQL connection (sqlite3-compatible wrapper).
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
`db_path` is a PostgreSQL connection string (kept as the historical
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
parameter name to minimise churn across the codebase).
|
||||||
return conn
|
"""
|
||||||
|
return _pg_connect(db_path)
|
||||||
|
|
||||||
|
|
||||||
def _db_path(preferred: str = None) -> str:
|
def _db_path(preferred: str = None) -> str:
|
||||||
"""Get the actual database path, preferring current_app config if available."""
|
"""Get the actual database connection string, preferring current_app config."""
|
||||||
if preferred:
|
if preferred:
|
||||||
return preferred
|
return preferred
|
||||||
try:
|
try:
|
||||||
@@ -330,16 +339,8 @@ def delete_user(user_id: int, db_path: str = None):
|
|||||||
if user_id == 0:
|
if user_id == 0:
|
||||||
return
|
return
|
||||||
conn = get_db(_db_path(db_path))
|
conn = get_db(_db_path(db_path))
|
||||||
# Children first so FK constraints never reject a delete.
|
|
||||||
ordered = [
|
|
||||||
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
|
||||||
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
|
||||||
"weather_warning_cache", "weather_config", "clock_config",
|
|
||||||
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
|
||||||
"auth_tokens",
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
for table in ordered:
|
for table in _DELETE_ORDER:
|
||||||
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
|
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
|
||||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -826,6 +827,14 @@ def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int |
|
|||||||
|
|
||||||
# ─── DB init / migration ─────────────────────
|
# ─── DB init / migration ─────────────────────
|
||||||
|
|
||||||
|
# Stores passwords that were just generated, for the startup script to print.
|
||||||
|
_EMITTED_PASSWORDS: list = []
|
||||||
|
|
||||||
|
# Current schema version supported by this program. Bump it whenever the
|
||||||
|
# SCHEMA changes so existing databases get upgraded on next start.
|
||||||
|
DB_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
def _ensure_defaults(conn):
|
def _ensure_defaults(conn):
|
||||||
"""Insert default rows for config tables and the Root user when missing.
|
"""Insert default rows for config tables and the Root user when missing.
|
||||||
Generates a random 6-digit temp password when Root's auth_config is created
|
Generates a random 6-digit temp password when Root's auth_config is created
|
||||||
@@ -843,6 +852,11 @@ def _ensure_defaults(conn):
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
|
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
|
||||||
)
|
)
|
||||||
|
# Keep the users identity sequence ahead of explicit id=0 insertion.
|
||||||
|
conn.execute(
|
||||||
|
"SELECT setval(pg_get_serial_sequence('users', 'id'), "
|
||||||
|
"GREATEST((SELECT COALESCE(MAX(id), 1) FROM users), 1))"
|
||||||
|
)
|
||||||
|
|
||||||
# weather_config for Root
|
# weather_config for Root
|
||||||
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
|
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
|
||||||
@@ -895,165 +909,224 @@ def _ensure_defaults(conn):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
# Stores passwords that were just generated, for the startup script to print.
|
def _table_columns(conn, table_name: str) -> list:
|
||||||
_EMITTED_PASSWORDS: list = []
|
"""Return the column names of a table (information_schema)."""
|
||||||
|
rows = conn.execute(
|
||||||
# Current schema version supported by this program. Bump it whenever the
|
"SELECT column_name FROM information_schema.columns "
|
||||||
# SCHEMA changes so existing databases get rebuilt+migrated on next start.
|
"WHERE table_schema = current_schema() AND table_name = ? "
|
||||||
DB_VERSION = 1
|
"ORDER BY ordinal_position",
|
||||||
|
(table_name,)
|
||||||
|
|
||||||
def _table_columns(conn, table_name):
|
|
||||||
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_auth_config(conn_new, conn_old):
|
|
||||||
"""Special-case migration for auth_config: the legacy table is keyed by
|
|
||||||
`id` (single user, id=1), the new table is keyed by `user_id`.
|
|
||||||
The legacy single-user row belongs to Root (user 0).
|
|
||||||
"""
|
|
||||||
old_cols = _table_columns(conn_old, "auth_config")
|
|
||||||
new_cols = _table_columns(conn_new, "auth_config")
|
|
||||||
if not old_cols:
|
|
||||||
return
|
|
||||||
rows = conn_old.execute("SELECT * FROM auth_config").fetchall()
|
|
||||||
for row in rows:
|
|
||||||
d = dict(row)
|
|
||||||
if "user_id" in old_cols:
|
|
||||||
uid = d.get("user_id") or 0
|
|
||||||
else:
|
|
||||||
# Legacy single-user DB: the id=1 row belongs to Root (user 0).
|
|
||||||
old_id = d.get("id", 1)
|
|
||||||
uid = 0 if old_id == 1 else (old_id or 0)
|
|
||||||
common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")]
|
|
||||||
if not common:
|
|
||||||
continue
|
|
||||||
col_str = ", ".join(common)
|
|
||||||
placeholders = ", ".join("?" * len(common))
|
|
||||||
conn_new.execute(
|
|
||||||
f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})",
|
|
||||||
(uid, *[d[c] for c in common])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db(db_path: str = "data.db"):
|
|
||||||
"""Initialize the database with schema-version aware migration.
|
|
||||||
|
|
||||||
- No database file: create it from the latest SCHEMA and create Root.
|
|
||||||
- Existing database:
|
|
||||||
* no `meta.version` (legacy DB) → rebuild + migrate compatible data
|
|
||||||
* DB version < DB_VERSION → rebuild + migrate
|
|
||||||
* DB version == DB_VERSION → use as-is (fast path)
|
|
||||||
* DB version > DB_VERSION → abort with an error (DB is newer
|
|
||||||
than this program)
|
|
||||||
|
|
||||||
Rebuild copies compatible data (legacy single-user data becomes Root's)
|
|
||||||
and fills missing columns with SCHEMA defaults.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
conn = get_db(db_path)
|
|
||||||
conn.executescript(SCHEMA)
|
|
||||||
_ensure_defaults(conn)
|
|
||||||
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
conn_probe = get_db(db_path)
|
|
||||||
old_version = _read_db_version(conn_probe)
|
|
||||||
conn_probe.close()
|
|
||||||
|
|
||||||
if old_version is not None and old_version > DB_VERSION:
|
|
||||||
raise SystemExit(
|
|
||||||
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
|
||||||
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
|
||||||
)
|
|
||||||
|
|
||||||
if old_version is not None and old_version == DB_VERSION:
|
|
||||||
# Same version — no rebuild needed. Just ensure defaults exist.
|
|
||||||
conn = get_db(db_path)
|
|
||||||
_ensure_defaults(conn)
|
|
||||||
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Migrate by rebuilding: create a fresh DB with the latest schema,
|
|
||||||
# copy compatible data from the old DB, then replace the old DB.
|
|
||||||
new_db_path = db_path + ".new"
|
|
||||||
backup_path = db_path + ".backup"
|
|
||||||
|
|
||||||
# Clean up any stale temporary database from a previous failed run.
|
|
||||||
if os.path.exists(new_db_path):
|
|
||||||
os.remove(new_db_path)
|
|
||||||
|
|
||||||
conn_new = get_db(new_db_path)
|
|
||||||
conn_new.executescript(SCHEMA)
|
|
||||||
|
|
||||||
conn_old = get_db(db_path)
|
|
||||||
# Disable FK enforcement while copying; rows are copied table by table.
|
|
||||||
conn_old.execute("PRAGMA foreign_keys=OFF")
|
|
||||||
conn_new.execute("PRAGMA foreign_keys=OFF")
|
|
||||||
|
|
||||||
tables = conn_old.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
return [r["column_name"] for r in rows]
|
||||||
for (table_name,) in tables:
|
|
||||||
# Tables that no longer exist in the new schema are dropped (e.g. camera_config).
|
|
||||||
exists_new = conn_new.execute(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
||||||
(table_name,)
|
|
||||||
).fetchone()
|
|
||||||
if not exists_new:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if table_name == "auth_config":
|
|
||||||
_migrate_auth_config(conn_new, conn_old)
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_cols = _table_columns(conn_old, table_name)
|
|
||||||
new_cols = _table_columns(conn_new, table_name)
|
|
||||||
common_cols = [c for c in new_cols if c in old_cols]
|
|
||||||
if not common_cols:
|
|
||||||
continue
|
|
||||||
|
|
||||||
col_str = ", ".join(common_cols)
|
|
||||||
placeholders = ", ".join("?" * len(common_cols))
|
|
||||||
sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})"
|
|
||||||
|
|
||||||
for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"):
|
|
||||||
try:
|
|
||||||
conn_new.execute(sql, tuple(row))
|
|
||||||
except Exception:
|
|
||||||
# Ignore rows that cannot be migrated (e.g. constraint violations).
|
|
||||||
pass
|
|
||||||
|
|
||||||
conn_new.commit()
|
|
||||||
|
|
||||||
# Now fill defaults for any tables that are still empty + Root bootstrap.
|
|
||||||
_ensure_defaults(conn_new)
|
|
||||||
|
|
||||||
# Stamp the migrated database with the program's schema version.
|
|
||||||
conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
|
||||||
conn_new.commit()
|
|
||||||
|
|
||||||
conn_old.close()
|
|
||||||
conn_new.close()
|
|
||||||
|
|
||||||
# Replace old DB with the new one, keeping a backup.
|
|
||||||
if os.path.exists(backup_path):
|
|
||||||
os.remove(backup_path)
|
|
||||||
os.rename(db_path, backup_path)
|
|
||||||
os.rename(new_db_path, db_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_db_version(conn) -> int | None:
|
def _read_db_version(conn) -> int | None:
|
||||||
"""Read the schema version from the `meta` table.
|
"""Read the schema version from the `meta` table.
|
||||||
Returns None when the DB is legacy (no meta table / no version column).
|
Returns None when the DB is legacy (no meta table / no version row).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
|
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
|
||||||
return int(row["version"]) if row else None
|
return int(row["version"]) if row else None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
conn._safe_rollback()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SCHEMA parsing for column-level migration ──────
|
||||||
|
_CREATE_TABLE_RE = re.compile(
|
||||||
|
r"CREATE TABLE IF NOT EXISTS\s+(\w+)\s*\((.*?)\)\s*;",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_top_level(text: str) -> list:
|
||||||
|
"""Split `text` on top-level commas (ignoring commas inside parentheses
|
||||||
|
AND inside single-quoted strings, e.g. DEFAULT '1,2,3,4,5,6,7')."""
|
||||||
|
parts = []
|
||||||
|
depth = 0
|
||||||
|
cur = []
|
||||||
|
in_str = False
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if ch == "'":
|
||||||
|
# SQL 单引号转义用两个单引号表示
|
||||||
|
if in_str and i + 1 < len(text) and text[i + 1] == "'":
|
||||||
|
cur.append("''")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
in_str = not in_str
|
||||||
|
cur.append(ch)
|
||||||
|
elif not in_str:
|
||||||
|
if ch == "(":
|
||||||
|
depth += 1
|
||||||
|
elif ch == ")":
|
||||||
|
depth -= 1
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
parts.append("".join(cur))
|
||||||
|
cur = []
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
cur.append(ch)
|
||||||
|
else:
|
||||||
|
cur.append(ch)
|
||||||
|
i += 1
|
||||||
|
if cur:
|
||||||
|
parts.append("".join(cur))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_check(s: str) -> str:
|
||||||
|
"""Remove every CHECK(...) clause (parenthesis-balanced)."""
|
||||||
|
out = s
|
||||||
|
while True:
|
||||||
|
idx = out.upper().find("CHECK(")
|
||||||
|
if idx == -1:
|
||||||
|
break
|
||||||
|
depth = 0
|
||||||
|
end = -1
|
||||||
|
for j in range(idx, len(out)):
|
||||||
|
if out[j] == "(":
|
||||||
|
depth += 1
|
||||||
|
elif out[j] == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
out = out[:idx] + out[end + 1:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_missing_columns(conn):
|
||||||
|
"""Add columns that exist in SCHEMA but are missing from existing tables.
|
||||||
|
New columns are created with their SCHEMA DEFAULT (migration contract).
|
||||||
|
Best-effort: a failing column does not block startup.
|
||||||
|
"""
|
||||||
|
for m in _CREATE_TABLE_RE.finditer(SCHEMA):
|
||||||
|
table = m.group(1)
|
||||||
|
try:
|
||||||
|
existing = set(_table_columns(conn, table))
|
||||||
|
except Exception:
|
||||||
|
conn._safe_rollback()
|
||||||
|
continue
|
||||||
|
for col_def in _split_top_level(m.group(2)):
|
||||||
|
col_def = col_def.strip()
|
||||||
|
if not col_def:
|
||||||
|
continue
|
||||||
|
upper = col_def.upper()
|
||||||
|
if upper.startswith(("PRIMARY KEY", "UNIQUE", "CHECK", "FOREIGN KEY", "CONSTRAINT")):
|
||||||
|
continue
|
||||||
|
name = col_def.split()[0].strip('"')
|
||||||
|
if not name or name in existing:
|
||||||
|
continue
|
||||||
|
# Make the ADD COLUMN safe for rows that already exist:
|
||||||
|
# drop NOT NULL / PK / UNIQUE / IDENTITY; keep TYPE + DEFAULT.
|
||||||
|
safe = re.sub(r"\s+NOT\s+NULL\b", "", col_def, flags=re.IGNORECASE)
|
||||||
|
safe = re.sub(r"\s+PRIMARY\s+KEY\b", "", safe, flags=re.IGNORECASE)
|
||||||
|
safe = re.sub(r"\s+UNIQUE\b", "", safe, flags=re.IGNORECASE)
|
||||||
|
safe = re.sub(r"\s+GENERATED\s+BY\s+DEFAULT\s+AS\s+IDENTITY\b", "", safe, flags=re.IGNORECASE)
|
||||||
|
safe = _strip_check(safe).strip().rstrip(",")
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {safe}")
|
||||||
|
except Exception:
|
||||||
|
# rollback done by the shim; the next column starts a fresh tx
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_identity_sequences(conn):
|
||||||
|
"""Sync every IDENTITY sequence to max(id) (safe after explicit id inserts)."""
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT table_name, column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = current_schema() AND is_identity = 'YES'"
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
conn._safe_rollback()
|
||||||
|
return
|
||||||
|
for r in rows:
|
||||||
|
t, c = r["table_name"], r["column_name"]
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"SELECT setval(pg_get_serial_sequence('{t}', '{c}'), "
|
||||||
|
f"GREATEST((SELECT COALESCE(MAX({c}), 1) FROM {t}), 1))"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
conn._safe_rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# Timestamp columns whose DEFAULT was generated by early builds with the wrong
|
||||||
|
# to_char pattern ('YYYY-MM-DD HH:MM:SS' — HH is 12-hour, MM is the month).
|
||||||
|
_TIMESTAMP_DEFAULT_FIX = [
|
||||||
|
("auth_tokens", "created_at"),
|
||||||
|
("users", "created_at"),
|
||||||
|
("calendar_groups", "created_at"),
|
||||||
|
("weather_cache", "updated_at"),
|
||||||
|
("weather_hourly_cache", "fetched_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_timestamp_defaults(conn):
|
||||||
|
"""Repair legacy DEFAULT expressions (idempotent, best-effort)."""
|
||||||
|
for t, c in _TIMESTAMP_DEFAULT_FIX:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"ALTER TABLE {t} ALTER COLUMN {c} "
|
||||||
|
"SET DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS')"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
conn._safe_rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str = "data.db"):
|
||||||
|
"""Initialize the database with schema-version aware migration (PostgreSQL).
|
||||||
|
|
||||||
|
- Fresh database (no `meta` row) -> create the latest SCHEMA + defaults.
|
||||||
|
- Existing database:
|
||||||
|
* DB version == DB_VERSION -> use as-is (fast path, defaults ensured)
|
||||||
|
* no version / DB version < DB_VERSION
|
||||||
|
-> create missing tables/columns; missing
|
||||||
|
columns are filled with their SCHEMA
|
||||||
|
DEFAULT; stamp the new version
|
||||||
|
* DB version > DB_VERSION -> abort with an error (DB is newer than
|
||||||
|
this program)
|
||||||
|
"""
|
||||||
|
conn = get_db(db_path)
|
||||||
|
try:
|
||||||
|
old_version = _read_db_version(conn)
|
||||||
|
|
||||||
|
if old_version is not None and old_version > DB_VERSION:
|
||||||
|
raise SystemExit(
|
||||||
|
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
||||||
|
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_version is not None and old_version == DB_VERSION:
|
||||||
|
# Same version — no upgrade needed. Just ensure defaults exist.
|
||||||
|
_ensure_defaults(conn)
|
||||||
|
_fix_timestamp_defaults(conn)
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create any missing tables (CREATE TABLE IF NOT EXISTS is idempotent),
|
||||||
|
# then add any columns missing from older databases. The DDL is
|
||||||
|
# committed first so a later best-effort column migration (which may
|
||||||
|
# roll back its own transaction) can never undo the schema creation.
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
_migrate_missing_columns(conn)
|
||||||
|
|
||||||
|
# Bootstrap defaults (Root user, meta row, config rows).
|
||||||
|
_ensure_defaults(conn)
|
||||||
|
|
||||||
|
# Repair legacy timestamp DEFAULT expressions (old to_char HH:MM pattern).
|
||||||
|
_fix_timestamp_defaults(conn)
|
||||||
|
|
||||||
|
# Stamp the database with the program's schema version.
|
||||||
|
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||||
|
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||||
|
_fix_identity_sequences(conn)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""PostgreSQL compatibility layer for the legacy sqlite3-style data access.
|
||||||
|
|
||||||
|
The rest of the codebase talks to the database through hand-written SQL that
|
||||||
|
was originally written for sqlite3 (``?`` placeholders, ``datetime('now')``,
|
||||||
|
``INSERT OR IGNORE``, ``cursor.lastrowid``, ``conn.total_changes`` ...).
|
||||||
|
This module exposes a sqlite3-like connection/cursor interface on top of
|
||||||
|
psycopg2 and transparently translates the dialect differences, so the
|
||||||
|
business-layer SQL keeps working with minimal changes.
|
||||||
|
|
||||||
|
Translations applied inside :meth:`PgCursor.execute`:
|
||||||
|
- ``?`` placeholders -> psycopg2 ``%s`` placeholders
|
||||||
|
- ``datetime('now')`` -> ``to_char(now(), 'YYYY-MM-DD HH24:MI:SS')``
|
||||||
|
(UTC, byte-identical format to before)
|
||||||
|
- ``INSERT OR IGNORE INTO`` -> ``INSERT INTO ... ON CONFLICT DO NOTHING``
|
||||||
|
- ``BEGIN IMMEDIATE`` -> ``BEGIN``
|
||||||
|
- ``INSERT`` (without RETURNING) -> appends ``RETURNING <pk>`` so that
|
||||||
|
``cursor.lastrowid`` keeps working; the
|
||||||
|
primary key is looked up once from
|
||||||
|
information_schema and cached.
|
||||||
|
|
||||||
|
Rows are returned as a dict subclass that supports both ``row["col"]`` and
|
||||||
|
``row[0]`` (positional) access to match the old ``sqlite3.Row`` behaviour.
|
||||||
|
|
||||||
|
The connection timezone is pinned to UTC so ``now()`` output matches the
|
||||||
|
previous ``datetime('now')`` (UTC) semantics.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
_DATETIME_NOW_RE = re.compile(r"datetime\('now'\)", re.IGNORECASE)
|
||||||
|
_INSERT_OR_IGNORE_RE = re.compile(r"INSERT\s+OR\s+IGNORE\s+INTO", re.IGNORECASE)
|
||||||
|
_INSERT_TABLE_RE = re.compile(
|
||||||
|
r"^\s*INSERT\s+(?:OR\s+IGNORE\s+)?INTO\s+([\"'`\w]+)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_BEGIN_IMMEDIATE_RE = re.compile(r"^\s*BEGIN\s+IMMEDIATE", re.IGNORECASE)
|
||||||
|
|
||||||
|
_PK_CACHE: dict[str, str | None] = {}
|
||||||
|
_PK_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class _Row(dict):
|
||||||
|
"""dict-like row supporting both column-name and positional access."""
|
||||||
|
|
||||||
|
__slots__ = ("_cols",)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._cols = None
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if isinstance(key, int):
|
||||||
|
if self._cols is None or key < 0 or key >= len(self._cols):
|
||||||
|
raise IndexError(key)
|
||||||
|
return dict.__getitem__(self, self._cols[key])
|
||||||
|
return dict.__getitem__(self, key)
|
||||||
|
|
||||||
|
def get_by_index(self, i):
|
||||||
|
return self[i]
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_row(row):
|
||||||
|
"""Convert a psycopg2 result row into a `_Row` (positional access enabled)."""
|
||||||
|
if row is None or isinstance(row, _Row):
|
||||||
|
return row
|
||||||
|
if isinstance(row, dict):
|
||||||
|
r = _Row(row)
|
||||||
|
r._cols = list(row.keys())
|
||||||
|
return r
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_key(conn, table: str) -> str | None:
|
||||||
|
"""Return the primary-key column of `table` (cached per process)."""
|
||||||
|
with _PK_LOCK:
|
||||||
|
if table in _PK_CACHE:
|
||||||
|
return _PK_CACHE[table]
|
||||||
|
pk: str | None = None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT kcu.column_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
AND tc.table_name = %s
|
||||||
|
AND tc.table_schema = current_schema()
|
||||||
|
ORDER BY kcu.ordinal_position
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
pk = row["column_name"] if row else None
|
||||||
|
cur.close()
|
||||||
|
except Exception:
|
||||||
|
pk = None
|
||||||
|
with _PK_LOCK:
|
||||||
|
_PK_CACHE[table] = pk
|
||||||
|
return pk
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_sql(sql: str) -> tuple[str, bool]:
|
||||||
|
"""Return (translated_sql, has_insert_or_ignore)."""
|
||||||
|
sql = _DATETIME_NOW_RE.sub("to_char(now(), 'YYYY-MM-DD HH24:MI:SS')", sql)
|
||||||
|
sql = _BEGIN_IMMEDIATE_RE.sub("BEGIN", sql)
|
||||||
|
has_ignore = bool(_INSERT_OR_IGNORE_RE.search(sql))
|
||||||
|
if has_ignore:
|
||||||
|
sql = _INSERT_OR_IGNORE_RE.sub("INSERT INTO", sql)
|
||||||
|
# `?` -> `%s`. The codebase SQL contains no literal `?` inside strings.
|
||||||
|
sql = sql.replace("?", "%s")
|
||||||
|
return sql, has_ignore
|
||||||
|
|
||||||
|
|
||||||
|
class PgCursor:
|
||||||
|
"""A psycopg2 cursor wrapper exposing the sqlite3 cursor surface."""
|
||||||
|
|
||||||
|
def __init__(self, proxy: "PgConnection", cursor):
|
||||||
|
self._proxy = proxy
|
||||||
|
self._cursor = cursor
|
||||||
|
self._lastrowid = None
|
||||||
|
|
||||||
|
def execute(self, sql: str, params=None) -> "PgCursor":
|
||||||
|
sql, has_ignore = _translate_sql(sql)
|
||||||
|
conn = self._proxy._conn
|
||||||
|
|
||||||
|
m = _INSERT_TABLE_RE.match(sql)
|
||||||
|
table = m.group(1).strip('"`') if m else None
|
||||||
|
returning_pk = None
|
||||||
|
if table is not None and " RETURNING " not in sql.upper():
|
||||||
|
returning_pk = _primary_key(conn, table)
|
||||||
|
|
||||||
|
sql = sql.rstrip().rstrip(";")
|
||||||
|
if has_ignore:
|
||||||
|
sql += " ON CONFLICT DO NOTHING"
|
||||||
|
if returning_pk:
|
||||||
|
sql += f" RETURNING {returning_pk}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._cursor.execute(sql, params if params is not None else ())
|
||||||
|
if returning_pk:
|
||||||
|
row = _wrap_row(self._cursor.fetchone())
|
||||||
|
self._lastrowid = row[0] if row is not None else None
|
||||||
|
rc = self._cursor.rowcount
|
||||||
|
if rc and rc > 0:
|
||||||
|
self._proxy._total_changes += rc
|
||||||
|
except Exception:
|
||||||
|
self._proxy._safe_rollback()
|
||||||
|
raise
|
||||||
|
return self
|
||||||
|
|
||||||
|
# ── sqlite3 cursor surface ───────────────────────────────
|
||||||
|
@property
|
||||||
|
def lastrowid(self):
|
||||||
|
return self._lastrowid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rowcount(self):
|
||||||
|
return self._cursor.rowcount
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return _wrap_row(self._cursor.fetchone())
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return [_wrap_row(r) for r in self._cursor.fetchall()]
|
||||||
|
|
||||||
|
def fetchmany(self, size=None):
|
||||||
|
return [_wrap_row(r) for r in self._cursor.fetchmany(size)]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self._cursor.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PgConnection:
|
||||||
|
"""A sqlite3-like connection backed by psycopg2.
|
||||||
|
|
||||||
|
Note: unlike a raw psycopg2 connection, a failed statement automatically
|
||||||
|
rolls the connection back (sqlite allows continuing after an error; this
|
||||||
|
keeps the connection usable under the same call pattern).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dsn: str):
|
||||||
|
self._conn = psycopg2.connect(dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
self._conn.autocommit = False
|
||||||
|
self._total_changes = 0
|
||||||
|
self._closed = False
|
||||||
|
cur = self._conn.cursor()
|
||||||
|
cur.execute("SET TIME ZONE 'UTC'")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
def execute(self, sql: str, params=None) -> PgCursor:
|
||||||
|
return self.cursor().execute(sql, params)
|
||||||
|
|
||||||
|
def cursor(self) -> PgCursor:
|
||||||
|
return PgCursor(self, self._conn.cursor())
|
||||||
|
|
||||||
|
def executescript(self, sql: str):
|
||||||
|
"""Run a multi-statement DDL script (SCHEMA). No translation."""
|
||||||
|
cur = self._conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(sql)
|
||||||
|
except Exception:
|
||||||
|
self._safe_rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
cur.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
self._conn.rollback()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if not self._closed:
|
||||||
|
try:
|
||||||
|
self._conn.rollback()
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def _safe_rollback(self):
|
||||||
|
try:
|
||||||
|
self._conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── sqlite3 connection surface ───────────────────────────
|
||||||
|
@property
|
||||||
|
def total_changes(self):
|
||||||
|
return self._total_changes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def closed(self):
|
||||||
|
return self._closed
|
||||||
|
|
||||||
|
|
||||||
|
def connect(dsn: str) -> PgConnection:
|
||||||
|
"""Open a PostgreSQL connection with the sqlite3-compatible wrapper."""
|
||||||
|
return PgConnection(dsn)
|
||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
State is stored per user (one row per user_id).
|
State is stored per user (one row per user_id).
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from backend.time_util import now as tz_now
|
from backend.time_util import now as tz_now
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from backend.models import get_db
|
from backend.models import get_db
|
||||||
|
|||||||
+118
-68
@@ -8,13 +8,11 @@ Multi-user rules enforced here:
|
|||||||
the MFA setup page until they configure (and verify) one real login method,
|
the MFA setup page until they configure (and verify) one real login method,
|
||||||
at which point the temp TOTP is invalidated.
|
at which point the temp TOTP is invalidated.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import io
|
import io
|
||||||
import time as _time
|
import time as _time
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, request, jsonify, Response,
|
Blueprint, request, jsonify, Response,
|
||||||
render_template, current_app, stream_with_context,
|
render_template, current_app, stream_with_context,
|
||||||
@@ -23,15 +21,15 @@ from flask import (
|
|||||||
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
|
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
|
||||||
from backend.auth import (
|
from backend.auth import (
|
||||||
login_required, login_or_temp_required, display_login_required, admin_required,
|
login_required, login_or_temp_required, display_login_required, admin_required,
|
||||||
is_authenticated, is_temp_pending, is_admin, current_user_id,
|
is_authenticated, is_temp_pending, current_user_id,
|
||||||
verify_login, verify_totp, verify_recovery_code,
|
verify_login, verify_totp, verify_recovery_code,
|
||||||
check_rate_limit, record_failure, record_success,
|
check_rate_limit, record_failure, record_success,
|
||||||
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password,
|
require_reauth, mark_reauth, generate_recovery_codes,
|
||||||
load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie,
|
load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie,
|
||||||
rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE,
|
rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE,
|
||||||
)
|
)
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
get_auth_config, update_auth_config, set_temp_password, clear_temp_password,
|
get_auth_config, update_auth_config, clear_temp_password,
|
||||||
set_recovery_codes, get_recovery_codes, has_mfa_configured,
|
set_recovery_codes, get_recovery_codes, has_mfa_configured,
|
||||||
get_display_config, set_display_mode, set_custom_html,
|
get_display_config, set_display_mode, set_custom_html,
|
||||||
set_custom_html_template, apply_custom_html,
|
set_custom_html_template, apply_custom_html,
|
||||||
@@ -39,6 +37,10 @@ from backend.models import (
|
|||||||
is_temp_account, is_admin_user, finalize_temp_account,
|
is_temp_account, is_admin_user, finalize_temp_account,
|
||||||
find_user_by_credential_id, bump_all_session_versions,
|
find_user_by_credential_id, bump_all_session_versions,
|
||||||
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
|
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
|
||||||
|
get_db, _read_db_version,
|
||||||
|
)
|
||||||
|
from backend.db_admin import (
|
||||||
|
export_database_json, import_blob, detect_format,
|
||||||
)
|
)
|
||||||
from backend.webauthn_mgr import (
|
from backend.webauthn_mgr import (
|
||||||
begin_registration, complete_registration,
|
begin_registration, complete_registration,
|
||||||
@@ -64,6 +66,14 @@ def _load_token():
|
|||||||
load_auth_context()
|
load_auth_context()
|
||||||
|
|
||||||
|
|
||||||
|
@api.before_request
|
||||||
|
def _adjust_upload_limit():
|
||||||
|
"""Database import accepts large legacy .db files; raise this request's own
|
||||||
|
body limit (per-request setter, no global race) instead of the global 5MB cap."""
|
||||||
|
if request.path == "/api/admin/db/import" and request.method == "POST":
|
||||||
|
request.max_content_length = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@api.before_request
|
@api.before_request
|
||||||
def _check_origin():
|
def _check_origin():
|
||||||
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
|
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
|
||||||
@@ -107,12 +117,6 @@ def login_page():
|
|||||||
return render_template("login.html", next=next_url)
|
return render_template("login.html", next=next_url)
|
||||||
|
|
||||||
|
|
||||||
@api.route("/register-request")
|
|
||||||
def register_request_page():
|
|
||||||
"""Public page where a prospective user fills in their info (admin approves later)."""
|
|
||||||
return render_template("register_request.html")
|
|
||||||
|
|
||||||
|
|
||||||
@api.route("/passkey")
|
@api.route("/passkey")
|
||||||
def passkey_page():
|
def passkey_page():
|
||||||
"""Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet
|
"""Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet
|
||||||
@@ -492,39 +496,6 @@ def api_auth_status():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# USER SELF-SERVICE REQUEST
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# The user fills in their info; the admin confirms it and a temp account
|
|
||||||
# (with a server-held temp TOTP) is generated automatically.
|
|
||||||
|
|
||||||
_REG_REQ_LIMIT: dict[str, list] = {} # ip -> [timestamps]
|
|
||||||
|
|
||||||
|
|
||||||
@api.route("/api/auth/register-request", methods=["POST"])
|
|
||||||
def api_register_request():
|
|
||||||
"""Public: a prospective user submits their remark for admin approval.
|
|
||||||
Rate-limited per IP (5 requests per 10 minutes)."""
|
|
||||||
ip = request.remote_addr or "unknown"
|
|
||||||
now = _time.time()
|
|
||||||
stamps = [t for t in _REG_REQ_LIMIT.get(ip, []) if now - t < 600]
|
|
||||||
if len(stamps) >= 5:
|
|
||||||
_REG_REQ_LIMIT[ip] = stamps
|
|
||||||
return jsonify({"error": "申请过于频繁,请稍后再试"}), 429
|
|
||||||
stamps.append(now)
|
|
||||||
_REG_REQ_LIMIT[ip] = stamps
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
remark = (data.get("remark") or "").strip()
|
|
||||||
if not remark:
|
|
||||||
return jsonify({"error": "请填写你的称呼/备注"}), 400
|
|
||||||
if len(remark) > 64:
|
|
||||||
return jsonify({"error": "备注过长"}), 400
|
|
||||||
|
|
||||||
create_user(remark=remark, role="user", is_temp=0, status="pending")
|
|
||||||
return jsonify({"ok": True, "message": "已提交申请,请等待管理员确认"}), 201
|
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
# ════════════════════════════════════════════
|
||||||
# MFA CONFIGURATION
|
# MFA CONFIGURATION
|
||||||
# ════════════════════════════════════════════
|
# ════════════════════════════════════════════
|
||||||
@@ -842,30 +813,6 @@ def admin_create_user():
|
|||||||
return jsonify({"ok": True, "user": _user_payload(uid)}), 201
|
return jsonify({"ok": True, "user": _user_payload(uid)}), 201
|
||||||
|
|
||||||
|
|
||||||
@api.route("/api/admin/users/confirm", methods=["POST"])
|
|
||||||
@admin_required
|
|
||||||
def admin_confirm_request():
|
|
||||||
"""Confirm a pending self-service request → temp account with temp TOTP."""
|
|
||||||
if not require_reauth():
|
|
||||||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
request_id = data.get("request_id")
|
|
||||||
if request_id is None:
|
|
||||||
return jsonify({"error": "request_id required"}), 400
|
|
||||||
|
|
||||||
u = get_user(request_id)
|
|
||||||
if not u:
|
|
||||||
return jsonify({"error": "申请不存在"}), 404
|
|
||||||
if u.get("status") != "pending":
|
|
||||||
return jsonify({"error": "该申请已处理"}), 400
|
|
||||||
|
|
||||||
import pyotp
|
|
||||||
secret = pyotp.random_base32()
|
|
||||||
update_user(request_id, is_temp=1, temp_totp_secret=secret, status="active")
|
|
||||||
return jsonify({"ok": True, "user": _user_payload(request_id)})
|
|
||||||
|
|
||||||
|
|
||||||
@api.route("/api/admin/users/<int:user_id>", methods=["PUT"])
|
@api.route("/api/admin/users/<int:user_id>", methods=["PUT"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_update_user(user_id):
|
def admin_update_user(user_id):
|
||||||
@@ -1052,6 +999,109 @@ def admin_kick_all_users():
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
# DATABASE ADMINISTRATION (Root)
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
# Importing an old SQLite database, exporting a backup, or importing a backup
|
||||||
|
# replaces ALL current data — Root-only and re-auth required.
|
||||||
|
|
||||||
|
def _require_root():
|
||||||
|
"""Database administration is Root-only (id 0)."""
|
||||||
|
if _cur() != 0:
|
||||||
|
return jsonify({"error": "仅 Root 用户可执行数据库管理操作"}), 403
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/api/admin/db/status", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def admin_db_status():
|
||||||
|
denied = _require_root()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||||
|
conn = get_db(db_path)
|
||||||
|
try:
|
||||||
|
version = _read_db_version(conn) or 0
|
||||||
|
server = conn.execute("SELECT version()").fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT table_name FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = current_schema() ORDER BY table_name"
|
||||||
|
).fetchall()
|
||||||
|
tables = [r["table_name"] for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({
|
||||||
|
"database": "postgresql",
|
||||||
|
"db_version": version,
|
||||||
|
"server": server,
|
||||||
|
"tables": tables,
|
||||||
|
"backup_format": "iot-dboard-backup",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/api/admin/db/export", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def admin_db_export():
|
||||||
|
denied = _require_root()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
if not require_reauth():
|
||||||
|
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||||||
|
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||||
|
try:
|
||||||
|
payload = export_database_json(db_path)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"导出失败: {e}"}), 500
|
||||||
|
from datetime import datetime
|
||||||
|
fname = f"iot-dboard-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
||||||
|
return Response(
|
||||||
|
payload,
|
||||||
|
mimetype="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/api/admin/db/import", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_db_import():
|
||||||
|
denied = _require_root()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
if not require_reauth():
|
||||||
|
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||||||
|
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file or not file.filename:
|
||||||
|
return jsonify({"error": "请选择要导入的数据库文件"}), 400
|
||||||
|
data = file.read()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "文件为空"}), 400
|
||||||
|
if len(data) > 200 * 1024 * 1024:
|
||||||
|
return jsonify({"error": "文件超过 200MB 限制"}), 413
|
||||||
|
|
||||||
|
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||||
|
fmt = detect_format(data)
|
||||||
|
try:
|
||||||
|
stats = import_blob(db_path, data)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"导入失败(已回滚,当前数据未受影响): {e}"}), 500
|
||||||
|
|
||||||
|
total = sum(v for k, v in (stats or {}).items() if isinstance(v, int))
|
||||||
|
skipped = (stats or {}).get("_skipped_tables") or []
|
||||||
|
msg = f"导入成功:{total} 行数据已恢复。所有设备已下线,请重新登录。"
|
||||||
|
if skipped:
|
||||||
|
msg += f" 以下表因结构与当前版本不兼容被跳过:{', '.join(skipped)}。"
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"format": fmt,
|
||||||
|
"stats": stats,
|
||||||
|
"message": msg,
|
||||||
|
"session_invalidated": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
# ════════════════════════════════════════════
|
||||||
# DISPLAY PAGES
|
# DISPLAY PAGES
|
||||||
# ════════════════════════════════════════════
|
# ════════════════════════════════════════════
|
||||||
|
|||||||
+129
-2
@@ -4,6 +4,8 @@ All user-scoped jobs iterate over every active user (with that user's
|
|||||||
timezone/context active) so each user's alarms, reminders, calendars,
|
timezone/context active) so each user's alarms, reminders, calendars,
|
||||||
pomodoro, countdowns and weather are serviced independently.
|
pomodoro, countdowns and weather are serviced independently.
|
||||||
"""
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from backend.calendar_mgr import sync_all_subscriptions, get_upcoming_reminders
|
from backend.calendar_mgr import sync_all_subscriptions, get_upcoming_reminders
|
||||||
from backend.weather_mgr import fetch_weather, fetch_hourly
|
from backend.weather_mgr import fetch_weather, fetch_hourly
|
||||||
@@ -16,6 +18,129 @@ from backend.time_util import set_thread_user, clear_thread_user
|
|||||||
|
|
||||||
_scheduler: BackgroundScheduler | None = None
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
|
||||||
|
# ─── Scheduler single-instance guard ─────────
|
||||||
|
# With Gunicorn every worker imports the app; the background scheduler must run
|
||||||
|
# in exactly ONE process. A PostgreSQL session-level advisory lock decides which
|
||||||
|
# worker owns it (the lock is held by an open connection and released when that
|
||||||
|
# process exits, at which point another worker takes over).
|
||||||
|
_SCHEDULER_LOCK_KEY = 0x494F5444424F4152 # "IOTDBOAR"
|
||||||
|
_scheduler_lock_conn = None
|
||||||
|
_watchdog: "threading.Thread | None" = None
|
||||||
|
_WATCHDOG_INTERVAL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_connect(db_url: str):
|
||||||
|
"""Open the lock connection with TCP keepalive + connect timeout.
|
||||||
|
Without keepalives a network blip could leave PostgreSQL holding the lock
|
||||||
|
for ~2h (default tcp keepalive), blocking every worker from taking over.
|
||||||
|
(libpq URI query params do NOT accept tcp_keepalives_* — pass them as
|
||||||
|
psycopg2 keyword args instead.)"""
|
||||||
|
import psycopg2
|
||||||
|
return psycopg2.connect(
|
||||||
|
db_url,
|
||||||
|
connect_timeout=5,
|
||||||
|
keepalives_idle=30,
|
||||||
|
keepalives_interval=10,
|
||||||
|
keepalives_count=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_acquire_scheduler_lock(db_url: str) -> bool:
|
||||||
|
"""Try to become the scheduler owner. Returns True for the winner."""
|
||||||
|
global _scheduler_lock_conn
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = _lock_connect(db_url)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT pg_try_advisory_lock(%s)", (_SCHEDULER_LOCK_KEY,))
|
||||||
|
ok = bool(cur.fetchone()[0])
|
||||||
|
cur.close()
|
||||||
|
if ok:
|
||||||
|
_scheduler_lock_conn = conn
|
||||||
|
return True
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
if _scheduler_lock_conn:
|
||||||
|
_scheduler_lock_conn.close()
|
||||||
|
_scheduler_lock_conn = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close() # close the partially-used local connection
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def release_scheduler_lock():
|
||||||
|
"""Release the advisory lock (also closes the holding connection)."""
|
||||||
|
global _scheduler_lock_conn
|
||||||
|
if _scheduler_lock_conn:
|
||||||
|
try:
|
||||||
|
_scheduler_lock_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_scheduler_lock_conn = None
|
||||||
|
|
||||||
|
|
||||||
|
def _watchdog_loop(app, db_url: str):
|
||||||
|
"""Keeps the scheduler alive across worker restarts/freezes:
|
||||||
|
- lock owner: periodically checks the lock connection is healthy;
|
||||||
|
- non-owner: periodically retries to take over the lock.
|
||||||
|
"""
|
||||||
|
global _scheduler
|
||||||
|
import time
|
||||||
|
while True:
|
||||||
|
time.sleep(_WATCHDOG_INTERVAL)
|
||||||
|
try:
|
||||||
|
if _scheduler_lock_conn is not None:
|
||||||
|
# Owner: health check on the lock connection, and make sure the
|
||||||
|
# scheduler thread itself is still running (start failure or a
|
||||||
|
# dead thread must not hold the lock forever).
|
||||||
|
cur = _scheduler_lock_conn.cursor()
|
||||||
|
cur.execute("SELECT 1")
|
||||||
|
cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if _scheduler is None or not _scheduler.running:
|
||||||
|
if _scheduler:
|
||||||
|
try:
|
||||||
|
stop_scheduler()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
start_scheduler(app)
|
||||||
|
app.logger.info("Scheduler restarted (lock held but not running)")
|
||||||
|
elif _scheduler is None:
|
||||||
|
# Non-owner: try to take over (previous owner died/froze).
|
||||||
|
if try_acquire_scheduler_lock(db_url):
|
||||||
|
start_scheduler(app)
|
||||||
|
app.logger.info("Scheduler started (watchdog takeover)")
|
||||||
|
except Exception:
|
||||||
|
# Lock connection became unhealthy → drop the lock and re-race.
|
||||||
|
release_scheduler_lock()
|
||||||
|
if _scheduler:
|
||||||
|
try:
|
||||||
|
stop_scheduler()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if try_acquire_scheduler_lock(db_url):
|
||||||
|
start_scheduler(app)
|
||||||
|
app.logger.info("Scheduler re-acquired lock after watchdog failure")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_watchdog(app, db_url: str):
|
||||||
|
"""Start the scheduler watchdog thread (idempotent)."""
|
||||||
|
global _watchdog
|
||||||
|
if _watchdog is not None and _watchdog.is_alive():
|
||||||
|
return
|
||||||
|
_watchdog = threading.Thread(target=_watchdog_loop, args=(app, db_url), daemon=True)
|
||||||
|
_watchdog.start()
|
||||||
|
|
||||||
|
|
||||||
def _each_user(app, fn):
|
def _each_user(app, fn):
|
||||||
"""Run fn(user_id) for every active user inside the app context."""
|
"""Run fn(user_id) for every active user inside the app context."""
|
||||||
@@ -82,5 +207,7 @@ def start_scheduler(app):
|
|||||||
def stop_scheduler():
|
def stop_scheduler():
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if _scheduler:
|
if _scheduler:
|
||||||
_scheduler.shutdown(wait=False)
|
try:
|
||||||
_scheduler = None
|
_scheduler.shutdown(wait=False)
|
||||||
|
finally:
|
||||||
|
_scheduler = None
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ The timezone is cached per-request via Flask app config to avoid DB lookups.
|
|||||||
import time as _time
|
import time as _time
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone, date
|
from datetime import datetime, timedelta, timezone, date
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
@@ -130,14 +129,14 @@ def set_tz(tz_str: str) -> bool:
|
|||||||
# Convert to an IANA-like name: pytz has FixedOffset but easiest is
|
# Convert to an IANA-like name: pytz has FixedOffset but easiest is
|
||||||
# just use a fixed-offset timezone
|
# just use a fixed-offset timezone
|
||||||
import pytz
|
import pytz
|
||||||
tz = pytz.FixedOffset(sign * hours * 60) # FixedOffset expects minutes
|
pytz.FixedOffset(sign * hours * 60) # FixedOffset expects minutes
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
# IANA name — validate by trying to load
|
# IANA name — validate by trying to load
|
||||||
try:
|
try:
|
||||||
import pytz
|
import pytz
|
||||||
tz = pytz.timezone(normalized)
|
pytz.timezone(normalized)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.time_util import now as tz_now, strftime as tz_strftime, today_str as tz_today_str, strptime as tz_strptime, localize as tz_localize
|
from backend.time_util import now as tz_now, strftime as tz_strftime, strptime as tz_strptime, localize as tz_localize
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from backend.models import get_db
|
from backend.models import get_db
|
||||||
from backend.jwt_util import sign_jwt, generate_key_pair
|
from backend.jwt_util import sign_jwt, generate_key_pair
|
||||||
@@ -353,10 +353,8 @@ def get_next_update_times(user_id: int = None) -> dict:
|
|||||||
h_updated = None
|
h_updated = None
|
||||||
h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||||
if h_row and h_row["data"]:
|
if h_row and h_row["data"]:
|
||||||
h_data = json.loads(h_row["data"])
|
|
||||||
# Hourly data is a list; we store fetched_at separately, but for display
|
# Hourly data is a list; we store fetched_at separately, but for display
|
||||||
# use the SQLite fetched_at (converted) or just show the schedule
|
# use the local-computed schedule as last-update signal
|
||||||
# Let's use the local-computed schedule as last-update signal
|
|
||||||
h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||||
if h_row2 and h_row2[0]:
|
if h_row2 and h_row2[0]:
|
||||||
# Keep the UTC string but note it's UTC — we'll just pass it
|
# Keep the UTC string but note it's UTC — we'll just pass it
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ def begin_registration(request, user_id: int, display_name: str = "",
|
|||||||
existing_credential_id: str = None) -> dict:
|
existing_credential_id: str = None) -> dict:
|
||||||
"""Start WebAuthn registration for a specific user. Returns options dict."""
|
"""Start WebAuthn registration for a specific user. Returns options dict."""
|
||||||
rp_id = _get_rp_id(request)
|
rp_id = _get_rp_id(request)
|
||||||
origin = _get_origin(request)
|
|
||||||
|
|
||||||
user_name = f"user_{user_id}"
|
user_name = f"user_{user_id}"
|
||||||
user_handle = user_name.encode("utf-8")
|
user_handle = user_name.encode("utf-8")
|
||||||
@@ -200,7 +199,6 @@ def begin_authentication(request, allow_credential_ids: list = None) -> dict:
|
|||||||
ceremony to (used for re-authentication of the current user).
|
ceremony to (used for re-authentication of the current user).
|
||||||
"""
|
"""
|
||||||
rp_id = _get_rp_id(request)
|
rp_id = _get_rp_id(request)
|
||||||
origin = _get_origin(request)
|
|
||||||
|
|
||||||
allow_credentials = []
|
allow_credentials = []
|
||||||
if allow_credential_ids:
|
if allow_credential_ids:
|
||||||
|
|||||||
+46
-4
@@ -1,4 +1,29 @@
|
|||||||
|
# 部署拓扑:PostgreSQL 与 Web 应用都在 Docker 内。
|
||||||
|
# - db:PostgreSQL 16 (alpine,镜像小),数据持久化在 pgdata 卷
|
||||||
|
# - app:Gunicorn 2 worker × 2 线程(对应 1 物理核心 / 2 逻辑核心),限制 2G 内存
|
||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: cal-display-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=iot
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-iot}
|
||||||
|
- POSTGRES_DB=iot_dboard
|
||||||
|
- TZ=UTC
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U iot -d iot_dboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "0.5"
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: cal-display
|
container_name: cal-display
|
||||||
@@ -6,15 +31,32 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
# 持久化数据库
|
# 挂载目录:可放置待导入的旧 SQLite db 文件(也支持 /control 设置页直接上传导入)
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# 持久化项目代码(首次自动初始化为镜像内的克隆内容,之后 git pull 生效)
|
# 持久化项目代码(首次自动初始化为镜像内代码,之后 git pull 生效)
|
||||||
- app_code:/app
|
- app_code:/app
|
||||||
environment:
|
environment:
|
||||||
- FLASK_BIND=0.0.0.0
|
- DATABASE_URL=postgresql://iot:${POSTGRES_PASSWORD:-iot}@db:5432/iot_dboard
|
||||||
- FLASK_DB_PATH=/app/data/data.db
|
|
||||||
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-}
|
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-}
|
||||||
- FLASK_HTTPS=${FLASK_HTTPS:-}
|
- FLASK_HTTPS=${FLASK_HTTPS:-}
|
||||||
|
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
||||||
|
- GUNICORN_THREADS=${GUNICORN_THREADS:-8}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/api/auth/status', timeout=3)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
# 1 物理核心 = 2 逻辑核心 + 2G 内存
|
||||||
|
cpus: "2"
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
pgdata:
|
||||||
app_code:
|
app_code:
|
||||||
|
|||||||
+111
-35
@@ -1060,10 +1060,115 @@
|
|||||||
var tab = $('users-tab');
|
var tab = $('users-tab');
|
||||||
if (tab) tab.style.display = '';
|
if (tab) tab.style.display = '';
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
// 数据库管理仅 Root (id 0) 可见
|
||||||
|
if (status.user.id === 0) {
|
||||||
|
var dbSec = $('db-admin-section');
|
||||||
|
if (dbSec) dbSec.style.display = '';
|
||||||
|
loadDbStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Database administration (Root only) ───
|
||||||
|
function loadDbStatus() {
|
||||||
|
var el = $('db-status');
|
||||||
|
if (!el) return;
|
||||||
|
api('GET', '/api/admin/db/status', null, function (d) {
|
||||||
|
if (d && d.database) {
|
||||||
|
el.textContent = 'PostgreSQL · schema v' + d.db_version + ' · 表 ' + (d.tables ? d.tables.length : '?') + ' 张';
|
||||||
|
el.style.color = '#2e7d32';
|
||||||
|
} else {
|
||||||
|
el.textContent = (d && d.error) || '获取状态失败';
|
||||||
|
el.style.color = '#E05555';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbMsg(text, isErr) {
|
||||||
|
var el = $('db-admin-msg');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.color = isErr ? '#E05555' : '#2e7d32';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDbAdmin() {
|
||||||
|
var exportBtn = $('db-export-btn');
|
||||||
|
if (exportBtn) {
|
||||||
|
exportBtn.addEventListener('click', function () {
|
||||||
|
exportBtn.disabled = true;
|
||||||
|
dbMsg('正在导出...', false);
|
||||||
|
_reauthThen(function () {
|
||||||
|
// 用 fetch 携带 Authorization 下载备份文件
|
||||||
|
var token = Auth.getAccess && Auth.getAccess();
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/api/admin/db/export');
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
|
||||||
|
xhr.responseType = 'blob';
|
||||||
|
xhr.onload = function () {
|
||||||
|
exportBtn.disabled = false;
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var blob = xhr.response;
|
||||||
|
var disp = xhr.getResponseHeader('Content-Disposition') || '';
|
||||||
|
var m = disp.match(/filename="?([^";]+)"?/);
|
||||||
|
var name = m ? m[1] : 'iot-dboard-backup.json';
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||||||
|
dbMsg('导出成功,文件已开始下载', false);
|
||||||
|
} else if (xhr.status === 403) {
|
||||||
|
dbMsg('需要重新验证身份,请重试', true);
|
||||||
|
} else {
|
||||||
|
dbMsg('导出失败 (HTTP ' + xhr.status + ')', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function () { exportBtn.disabled = false; dbMsg('导出失败:网络错误', true); };
|
||||||
|
xhr.send();
|
||||||
|
}, function () {
|
||||||
|
exportBtn.disabled = false;
|
||||||
|
dbMsg('身份验证未通过,导出已取消', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = $('db-import-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var fileInput = $('db-import-file');
|
||||||
|
var file = fileInput && fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) { dbMsg('请先选择文件', true); return; }
|
||||||
|
var btn = $('db-import-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
dbMsg('正在导入(大文件可能需要一些时间)...', false);
|
||||||
|
_reauthThen(function () {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
api('POST', '/api/admin/db/import', fd, function (res) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (res && res.ok) {
|
||||||
|
dbMsg(res.message || '导入成功', false);
|
||||||
|
// 导入会重建全部数据并清空会话(含当前设备),提示后跳转登录页
|
||||||
|
setTimeout(function () {
|
||||||
|
try { localStorage.removeItem('dashboard_access_token'); } catch (e) {}
|
||||||
|
location.href = '/login?view=control&next=%2Fcontrol';
|
||||||
|
}, 1800);
|
||||||
|
} else {
|
||||||
|
dbMsg((res && res.error) || '导入失败', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
dbMsg('身份验证未通过,导入已取消', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initDbAdmin();
|
||||||
|
|
||||||
// ─── User Management (admin only) ─────────
|
// ─── User Management (admin only) ─────────
|
||||||
var _totpTimer = null;
|
var _totpTimer = null;
|
||||||
|
|
||||||
@@ -1137,30 +1242,13 @@
|
|||||||
function loadUsers() {
|
function loadUsers() {
|
||||||
if (!$('users-tab') || $('users-tab').style.display === 'none') return;
|
if (!$('users-tab') || $('users-tab').style.display === 'none') return;
|
||||||
api('GET', '/api/admin/users', null, function (users) {
|
api('GET', '/api/admin/users', null, function (users) {
|
||||||
var pending = users.filter(function (u) { return u.status === 'pending'; });
|
// 统一渲染全部用户(含历史 pending 申请用户,标记后可删除)
|
||||||
var active = users.filter(function (u) { return u.status === 'active'; });
|
|
||||||
|
|
||||||
// Pending requests
|
|
||||||
var pendHtml = '';
|
|
||||||
if (pending.length > 0) {
|
|
||||||
pendHtml = '<h4 style="margin:0 0 8px;font-size:14px;">⏳ 待确认申请(用户自助填写信息)</h4>';
|
|
||||||
pending.forEach(function (u) {
|
|
||||||
pendHtml += '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--warn-bg);">' +
|
|
||||||
'<div><strong>' + _esc(u.remark) + '</strong> <span style="font-size:12px;color:var(--text-dim);">' + _esc(u.created_at || '') + '</span></div>' +
|
|
||||||
'<div style="display:flex;gap:6px;">' +
|
|
||||||
'<button class="small-btn" data-action="confirm-request" data-id="' + u.id + '" style="color:#2e7d32;">确认并生成临时账户</button>' +
|
|
||||||
'<button class="small-btn" data-action="delete-request" data-id="' + u.id + '" style="color:#E05555;">拒绝</button>' +
|
|
||||||
'</div></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$('pending-requests').innerHTML = pendHtml;
|
|
||||||
|
|
||||||
// Active users
|
|
||||||
var html = '<h4 style="margin:0 0 8px;font-size:14px;">👥 用户列表</h4>';
|
var html = '<h4 style="margin:0 0 8px;font-size:14px;">👥 用户列表</h4>';
|
||||||
if (active.length === 0) html += '<div style="color:var(--text-dim);font-size:13px;">暂无用户</div>';
|
if (users.length === 0) html += '<div style="color:var(--text-dim);font-size:13px;">暂无用户</div>';
|
||||||
active.forEach(function (u) {
|
users.forEach(function (u) {
|
||||||
var badges = [];
|
var badges = [];
|
||||||
if (u.role === 'admin') badges.push('<span class="badge badge-on">管理员</span>');
|
if (u.role === 'admin') badges.push('<span class="badge badge-on">管理员</span>');
|
||||||
|
if (u.status === 'pending') badges.push('<span class="badge badge-off">待确认申请</span>');
|
||||||
if (u.is_temp) badges.push('<span class="badge badge-off">临时账户</span>');
|
if (u.is_temp) badges.push('<span class="badge badge-off">临时账户</span>');
|
||||||
if (u.has_mfa) badges.push('<span class="badge badge-on">已配置 MFA</span>');
|
if (u.has_mfa) badges.push('<span class="badge badge-on">已配置 MFA</span>');
|
||||||
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;flex-wrap:wrap;gap:6px;">' +
|
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;flex-wrap:wrap;gap:6px;">' +
|
||||||
@@ -1183,23 +1271,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindUserActions() {
|
function bindUserActions() {
|
||||||
document.querySelectorAll('#pending-requests [data-action], #user-list [data-action]').forEach(function (btn) {
|
document.querySelectorAll('#user-list [data-action]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
var action = btn.dataset.action;
|
var action = btn.dataset.action;
|
||||||
var id = parseInt(btn.dataset.id, 10);
|
var id = parseInt(btn.dataset.id, 10);
|
||||||
if (action === 'confirm-request') {
|
if (action === 'edit-remark') {
|
||||||
_reauthThen(function () {
|
|
||||||
api('POST', '/api/admin/users/confirm', { request_id: id }, function (res) {
|
|
||||||
if (res.ok) { toast('已生成临时账户'); loadUsers(); } else { toast(res.error || '操作失败'); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (action === 'delete-request') {
|
|
||||||
_reauthThen(function () {
|
|
||||||
api('DELETE', '/api/admin/users/' + id, null, function (res) {
|
|
||||||
if (res.ok) { toast('已拒绝该申请'); loadUsers(); } else { toast(res.error || '操作失败'); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (action === 'edit-remark') {
|
|
||||||
_reauthThen(function () {
|
_reauthThen(function () {
|
||||||
showPrompt('编辑用户备注', '新的备注', '', function (newRemark) {
|
showPrompt('编辑用户备注', '新的备注', '', function (newRemark) {
|
||||||
if (newRemark === null || !newRemark.trim()) return;
|
if (newRemark === null || !newRemark.trim()) return;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
state.events = data.events || [];
|
state.events = data.events || [];
|
||||||
state.alarms = data.alarms || [];
|
state.alarms = data.alarms || [];
|
||||||
state.countdowns = data.countdowns || [];
|
state.countdowns = data.countdowns || [];
|
||||||
|
state.pomodoro = data.pomodoro || state.pomodoro || {};
|
||||||
state.update_schedule = data.update_schedule || {};
|
state.update_schedule = data.update_schedule || {};
|
||||||
state.minutely = data.minutely || null;
|
state.minutely = data.minutely || null;
|
||||||
state.warnings = data.warnings || null;
|
state.warnings = data.warnings || null;
|
||||||
@@ -145,8 +146,79 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Local due-check fallback ─────────────
|
||||||
|
// 多 Gunicorn worker 下,后台 scheduler 与显示端 SSE 连接可能落在不同进程
|
||||||
|
// (进程内 EventBus 无法跨进程投递),SSE 提醒事件可能丢失。这里每秒本地
|
||||||
|
// 检测一次,保证闹钟/倒计时/番茄钟到点必定提醒;SSE 正常时由 fireOnce 去重。
|
||||||
|
state._firedLocal = {};
|
||||||
|
function fireOnce(key) {
|
||||||
|
if (state._firedLocal[key]) return false;
|
||||||
|
state._firedLocal[key] = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDueCheck() {
|
||||||
|
var nowSec = Date.now() / 1000;
|
||||||
|
var todayKey = new Date().toDateString();
|
||||||
|
|
||||||
|
// 1) 闹钟:按账户时区匹配 HH:MM + 星期
|
||||||
|
if (state.timezone) {
|
||||||
|
try {
|
||||||
|
var parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: state.timezone, hour: '2-digit', minute: '2-digit',
|
||||||
|
weekday: 'short', hourCycle: 'h23',
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
var hm = '', wd = '';
|
||||||
|
parts.forEach(function (p) {
|
||||||
|
if (p.type === 'hour') hm = p.value;
|
||||||
|
else if (p.type === 'minute') hm += ':' + p.value;
|
||||||
|
else if (p.type === 'weekday') wd = p.value;
|
||||||
|
});
|
||||||
|
var wdNum = { Sun: 7, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[wd];
|
||||||
|
(state.alarms || []).forEach(function (a) {
|
||||||
|
if (!a.enabled || String(a.time) !== hm) return;
|
||||||
|
var days = String(a.days || '1,2,3,4,5,6,7').split(',');
|
||||||
|
if (days.indexOf(String(wdNum)) === -1) return;
|
||||||
|
if (fireOnce('alarm-' + a.id + '-' + todayKey)) {
|
||||||
|
showAlarmOverlay({ alarms: [a], ts: new Date().toISOString(), source: 'local' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 倒计时:end_time(Unix 秒)到期
|
||||||
|
(state.countdowns || []).forEach(function (cd) {
|
||||||
|
if (!cd.enabled || !cd.end_time) return;
|
||||||
|
if (parseFloat(cd.end_time) > nowSec) return;
|
||||||
|
if (fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''))) {
|
||||||
|
showAlarmOverlay({ countdowns: [cd], ts: new Date().toISOString(), source: 'local' });
|
||||||
|
switchPanel('countdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) 番茄钟:running 且 end_time(Unix 秒)到期
|
||||||
|
var p = state.pomodoro || {};
|
||||||
|
if (p.running && p.end_time && parseFloat(p.end_time) <= nowSec) {
|
||||||
|
if (fireOnce('pomo-' + p.end_time)) {
|
||||||
|
showPomodoroOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLocalDueCheck() {
|
||||||
|
if (!state._localCheckTimer) {
|
||||||
|
state._localCheckTimer = setInterval(localDueCheck, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openSSE(url) {
|
function openSSE(url) {
|
||||||
var es = new EventSource(url);
|
var es = new EventSource(url);
|
||||||
|
// SSE 与后台 scheduler 可能不在同一 Gunicorn worker(进程内事件总线无法跨
|
||||||
|
// 进程投递):本地到期检测 + 周期性重拉完整状态作为兜底,保证最终一致。
|
||||||
|
startLocalDueCheck();
|
||||||
|
if (!state._stateTimer) {
|
||||||
|
state._stateTimer = setInterval(fetchState, 15000);
|
||||||
|
}
|
||||||
es.onmessage = function (e) {
|
es.onmessage = function (e) {
|
||||||
try {
|
try {
|
||||||
var msg = JSON.parse(e.data);
|
var msg = JSON.parse(e.data);
|
||||||
@@ -174,7 +246,11 @@
|
|||||||
function handleEvent(msg) {
|
function handleEvent(msg) {
|
||||||
if (msg.type === 'alarm_triggered') {
|
if (msg.type === 'alarm_triggered') {
|
||||||
fetchState();
|
fetchState();
|
||||||
showAlarmOverlay(msg.data);
|
var todayKey = new Date().toDateString();
|
||||||
|
var anyNew = (msg.data.alarms || []).some(function (a) {
|
||||||
|
return fireOnce('alarm-' + a.id + '-' + todayKey);
|
||||||
|
});
|
||||||
|
if (anyNew) showAlarmOverlay(msg.data);
|
||||||
} else if (msg.type === 'pomodoro_updated') {
|
} else if (msg.type === 'pomodoro_updated') {
|
||||||
state.pomodoro = msg.data || {};
|
state.pomodoro = msg.data || {};
|
||||||
renderPomodoro();
|
renderPomodoro();
|
||||||
@@ -205,8 +281,13 @@
|
|||||||
showCustomNotification(msg.data);
|
showCustomNotification(msg.data);
|
||||||
} else if (msg.type === 'countdown_triggered') {
|
} else if (msg.type === 'countdown_triggered') {
|
||||||
fetchState();
|
fetchState();
|
||||||
showAlarmOverlay(msg.data); // reuse alarm overlay UI
|
var anyCd = (msg.data.countdowns || []).some(function (cd) {
|
||||||
switchPanel('countdown');
|
return fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''));
|
||||||
|
});
|
||||||
|
if (anyCd) {
|
||||||
|
showAlarmOverlay(msg.data); // reuse alarm overlay UI
|
||||||
|
switchPanel('countdown');
|
||||||
|
}
|
||||||
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
|
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else if (msg.type === 'theme_changed') {
|
} else if (msg.type === 'theme_changed') {
|
||||||
@@ -224,13 +305,14 @@
|
|||||||
if (state.alarmActive) return; // already showing
|
if (state.alarmActive) return; // already showing
|
||||||
state.alarmActive = true;
|
state.alarmActive = true;
|
||||||
|
|
||||||
// Build info text (supports both alarm and countdown data)
|
// Build info text (supports both alarm and countdown data).
|
||||||
|
// 用户可控文本(label/time/target_time)一律转义,防止存储型 XSS。
|
||||||
var info = '';
|
var info = '';
|
||||||
(data.alarms || []).forEach(function (a) {
|
(data.alarms || []).forEach(function (a) {
|
||||||
info += a.time + (a.label ? ' ' + a.label : '') + '<br>';
|
info += _esc(a.time) + (a.label ? ' ' + _esc(a.label) : '') + '<br>';
|
||||||
});
|
});
|
||||||
(data.countdowns || []).forEach(function (cd) {
|
(data.countdowns || []).forEach(function (cd) {
|
||||||
info += cd.target_time + (cd.label ? ' ' + cd.label : '') + '<br>';
|
info += _esc(cd.target_time) + (cd.label ? ' ' + _esc(cd.label) : '') + '<br>';
|
||||||
});
|
});
|
||||||
|
|
||||||
var overlay = $('alarm-overlay');
|
var overlay = $('alarm-overlay');
|
||||||
@@ -658,7 +740,7 @@
|
|||||||
html += '<div class="cd-item" data-cd-id="' + cd.id + '">' +
|
html += '<div class="cd-item" data-cd-id="' + cd.id + '">' +
|
||||||
'<div class="cd-info">' +
|
'<div class="cd-info">' +
|
||||||
'<div class="cd-time">' + durStr + '</div>' +
|
'<div class="cd-time">' + durStr + '</div>' +
|
||||||
(cd.label ? '<div class="cd-label">' + cd.label + '</div>' : '') +
|
(cd.label ? '<div class="cd-label">' + _esc(cd.label) + '</div>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="cd-remain' + (overdue ? ' overdue' : '') + '">' + remainStr + '</div>' +
|
'<div class="cd-remain' + (overdue ? ' overdue' : '') + '">' + remainStr + '</div>' +
|
||||||
'<div class="alarm-actions">' +
|
'<div class="alarm-actions">' +
|
||||||
@@ -1297,9 +1379,9 @@
|
|||||||
}
|
}
|
||||||
if (m.error) {
|
if (m.error) {
|
||||||
if (m.cooldown) {
|
if (m.cooldown) {
|
||||||
content.innerHTML = '<div style="color:var(--accent-red);font-size:13px;padding:8px 0;">' + m.error + '</div>';
|
content.innerHTML = '<div style="color:var(--accent-red);font-size:13px;padding:8px 0;">' + _esc(m.error) + '</div>';
|
||||||
} else {
|
} else {
|
||||||
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">' + m.error + '</div>';
|
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">' + _esc(m.error) + '</div>';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1307,7 +1389,7 @@
|
|||||||
var list = m.minutely || [];
|
var list = m.minutely || [];
|
||||||
var summary = m.summary || '';
|
var summary = m.summary || '';
|
||||||
var html = '';
|
var html = '';
|
||||||
if (summary) html += '<div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">' + summary + '</div>';
|
if (summary) html += '<div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">' + _esc(summary) + '</div>';
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
html += '<div class="minutely-bars">';
|
html += '<div class="minutely-bars">';
|
||||||
list.forEach(function (item) {
|
list.forEach(function (item) {
|
||||||
@@ -1355,11 +1437,11 @@
|
|||||||
var fields = '';
|
var fields = '';
|
||||||
for (var key in item) {
|
for (var key in item) {
|
||||||
if (item.hasOwnProperty(key) && item[key]) {
|
if (item.hasOwnProperty(key) && item[key]) {
|
||||||
fields += '<div style="font-size:12px;color:var(--text-dim);margin-top:2px;"><strong>' + key + ':</strong> ' + item[key] + '</div>';
|
fields += '<div style="font-size:12px;color:var(--text-dim);margin-top:2px;"><strong>' + _esc(key) + ':</strong> ' + _esc(item[key]) + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += '<div class="warning-card">' +
|
html += '<div class="warning-card">' +
|
||||||
'<div class="warning-title">' + (item.title || item.type || '预警') + '</div>' +
|
'<div class="warning-title">' + _esc(item.title || item.type || '预警') + '</div>' +
|
||||||
fields +
|
fields +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
@@ -1443,9 +1525,9 @@
|
|||||||
var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' ');
|
var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' ');
|
||||||
html += '<div class="alarm-item" data-alarm-id="' + a.id + '">' +
|
html += '<div class="alarm-item" data-alarm-id="' + a.id + '">' +
|
||||||
'<div class="alarm-info">' +
|
'<div class="alarm-info">' +
|
||||||
'<div class="alarm-time">' + a.time + '</div>' +
|
'<div class="alarm-time">' + _esc(a.time) + '</div>' +
|
||||||
(a.label ? '<div class="alarm-label">' + a.label + '</div>' : '') +
|
(a.label ? '<div class="alarm-label">' + _esc(a.label) + '</div>' : '') +
|
||||||
'<div class="alarm-days">' + days + '</div></div>' +
|
'<div class="alarm-days">' + _esc(days) + '</div></div>' +
|
||||||
'<div class="alarm-actions">' +
|
'<div class="alarm-actions">' +
|
||||||
'<button class="alarm-action-btn edit-btn" data-action="edit-alarm" data-id="' + a.id + '">编辑</button>' +
|
'<button class="alarm-action-btn edit-btn" data-action="edit-alarm" data-id="' + a.id + '">编辑</button>' +
|
||||||
'<button class="alarm-action-btn del-btn" data-action="delete-alarm" data-id="' + a.id + '">删除</button>' +
|
'<button class="alarm-action-btn del-btn" data-action="delete-alarm" data-id="' + a.id + '">删除</button>' +
|
||||||
|
|||||||
@@ -261,7 +261,6 @@
|
|||||||
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
|
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
|
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
|
||||||
<div id="pending-requests" style="margin-bottom:16px;"></div>
|
|
||||||
<div id="user-list"></div>
|
<div id="user-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -315,6 +314,23 @@
|
|||||||
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
|
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
|
||||||
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section" id="db-admin-section" style="display:none;">
|
||||||
|
<h3>数据库管理</h3>
|
||||||
|
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
|
||||||
|
仅 Root 可用。可导入旧版 SQLite 数据库文件(*.db)或本系统导出的备份(JSON),导入会<strong>覆盖当前全部数据(含所有账户的登录凭据)</strong>(单事务执行,失败自动回滚、不影响现有数据),且<strong>所有设备将下线需重新登录</strong>。导出的备份同样包含凭据,请妥善保管。请勿导入不可信的备份文件。
|
||||||
|
</p>
|
||||||
|
<div style="font-size:13px;margin-bottom:10px;">
|
||||||
|
当前状态: <span id="db-status" style="color:var(--text-dim);">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
|
||||||
|
<button type="button" id="db-export-btn" class="btn-secondary">⬇️ 导出现有数据库</button>
|
||||||
|
</div>
|
||||||
|
<form id="db-import-form" class="inline-form">
|
||||||
|
<input type="file" id="db-import-file" accept=".db,.sqlite,.sqlite3,.json" style="max-width:260px;">
|
||||||
|
<button type="submit" id="db-import-btn" class="btn-primary">⬆️ 导入数据库(自动识别 SQLite / 备份 JSON)</button>
|
||||||
|
</form>
|
||||||
|
<div id="db-admin-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||||
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>时区设置</h3>
|
<h3>时区设置</h3>
|
||||||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">
|
<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">
|
||||||
|
|||||||
@@ -206,10 +206,6 @@
|
|||||||
<button id="recovery-submit">验证恢复码</button>
|
<button id="recovery-submit">验证恢复码</button>
|
||||||
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
|
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:16px;font-size:12px;color:var(--text-dim);">
|
|
||||||
<a href="/register-request" style="color:var(--text-dim);">还没有账号?申请账号</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -215,10 +215,6 @@
|
|||||||
<span class="recovery-link" id="show-totp">返回 TOTP</span>
|
<span class="recovery-link" id="show-totp">返回 TOTP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:20px;font-size:13px;">
|
|
||||||
<a href="/register-request" style="color:var(--accent);text-decoration:none;">还没有账号?申请账号</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>申请账号 - Cal Display</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0f1117;
|
|
||||||
--card-bg: #1a1d2e;
|
|
||||||
--text: #e0e0e0;
|
|
||||||
--text-dim: #888;
|
|
||||||
--accent: #4A90D9;
|
|
||||||
--accent-green: #4CAF50;
|
|
||||||
--accent-red: #E05555;
|
|
||||||
--radius: 12px;
|
|
||||||
--input-bg: #242838;
|
|
||||||
--input-border: rgba(255,255,255,0.14);
|
|
||||||
--divider: rgba(255,255,255,0.16);
|
|
||||||
--card-border: rgba(255,255,255,0.10);
|
|
||||||
--soft-bg: rgba(255,255,255,0.06);
|
|
||||||
--shadow: 0 4px 24px rgba(0,0,0,0.30);
|
|
||||||
}
|
|
||||||
/* 浅色主题覆盖 */
|
|
||||||
.theme-light {
|
|
||||||
--bg: #e8edf4;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--text: #1c2333;
|
|
||||||
--text-dim: #5b6a7e;
|
|
||||||
--accent: #2f6fb2;
|
|
||||||
--accent-green: #3a9450;
|
|
||||||
--accent-red: #c74545;
|
|
||||||
--radius: 12px;
|
|
||||||
--input-bg: #fafafa;
|
|
||||||
--input-border: #d7dfe8;
|
|
||||||
--divider: #d7dfe8;
|
|
||||||
--card-border: transparent;
|
|
||||||
--soft-bg: #eef2f7;
|
|
||||||
--shadow: 0 2px 12px rgba(30,40,60,0.08);
|
|
||||||
}
|
|
||||||
/* 主题切换动画 */
|
|
||||||
html.theme-anim, html.theme-anim * {
|
|
||||||
transition: background-color 0.55s ease, color 0.55s ease,
|
|
||||||
border-color 0.55s ease, box-shadow 0.55s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html, body {
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.box {
|
|
||||||
width: min(92vw, 420px);
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 32px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
|
|
||||||
.box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; line-height: 1.6; }
|
|
||||||
input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
background: #fafafa;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 13px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { opacity: 0.9; }
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.msg { min-height: 22px; font-size: 14px; margin-top: 12px; }
|
|
||||||
.msg.ok { color: var(--accent-green); }
|
|
||||||
.msg.err { color: var(--accent-red); }
|
|
||||||
.back { display: inline-block; margin-top: 16px; font-size: 13px; color: var(--accent); text-decoration: none; }
|
|
||||||
|
|
||||||
/* ═══ 主题适配(注册申请页)═══ */
|
|
||||||
body { background: var(--bg); }
|
|
||||||
.box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
|
|
||||||
.box input[type="text"] { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
|
||||||
</style>
|
|
||||||
<script src="/static/js/theme.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<h1>📋 申请账号</h1>
|
|
||||||
<p class="subtitle">填写你的称呼,提交后等待管理员确认。管理员确认后你会收到一个临时验证码,用它登录并配置你自己的登录方式(Passkey 或 TOTP)。</p>
|
|
||||||
<input type="text" id="remark-input" placeholder="你的称呼 / 备注" maxlength="64" autocomplete="off">
|
|
||||||
<button id="submit-btn">提交申请</button>
|
|
||||||
<div class="msg" id="msg"></div>
|
|
||||||
<a class="back" href="/login">← 返回登录</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
var btn = document.getElementById('submit-btn');
|
|
||||||
var input = document.getElementById('remark-input');
|
|
||||||
var msg = document.getElementById('msg');
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
var remark = input.value.trim();
|
|
||||||
if (!remark) { msg.className = 'msg err'; msg.textContent = '请填写你的称呼'; return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
msg.textContent = '提交中...';
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/api/auth/register-request');
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
||||||
xhr.onload = function () {
|
|
||||||
var res = JSON.parse(xhr.responseText || '{}');
|
|
||||||
if (xhr.status === 200 || xhr.status === 201) {
|
|
||||||
msg.className = 'msg ok';
|
|
||||||
msg.textContent = res.message || '✅ 已提交申请,请等待管理员确认';
|
|
||||||
input.disabled = true;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '已提交';
|
|
||||||
} else {
|
|
||||||
msg.className = 'msg err';
|
|
||||||
msg.textContent = res.error || '提交失败,请稍后再试';
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function () {
|
|
||||||
msg.className = 'msg err';
|
|
||||||
msg.textContent = '网络错误,请重试';
|
|
||||||
btn.disabled = false;
|
|
||||||
};
|
|
||||||
xhr.send(JSON.stringify({ remark: remark }));
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', submit);
|
|
||||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -10,3 +10,5 @@ cryptography>=42.0
|
|||||||
webauthn>=2.0
|
webauthn>=2.0
|
||||||
pyotp>=2.9
|
pyotp>=2.9
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
psycopg2-binary>=2.9
|
||||||
|
gunicorn>=21.2
|
||||||
|
|||||||
@@ -1,40 +1,34 @@
|
|||||||
"""Production entry point for the Calendar Display application."""
|
"""Development entrypoint (Flask dev server, port 5001).
|
||||||
|
|
||||||
|
For production use Gunicorn via `start:app` (see start.py and Dockerfile).
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from backend.app import create_app
|
from backend.app import create_app
|
||||||
from backend.scheduler import start_scheduler, stop_scheduler
|
from backend.scheduler import start_scheduler, stop_scheduler, try_acquire_scheduler_lock
|
||||||
from backend.models import _EMITTED_PASSWORDS, get_auth_config
|
|
||||||
|
|
||||||
# Use environment variable to toggle debug, default False for production
|
db_url = os.environ.get("DATABASE_URL",
|
||||||
DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes")
|
"postgresql://iot:iot@localhost:5432/iot_dboard")
|
||||||
|
app = create_app(db_path=db_url)
|
||||||
db_path = os.environ.get("FLASK_DB_PATH", os.path.join(os.path.dirname(__file__), "data.db"))
|
|
||||||
app = create_app(db_path=db_path)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# In debug mode, scheduler runs only in the reloader child process
|
DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# In debug mode the reloader re-imports this module; only the child process
|
||||||
|
# should start the scheduler (advisory lock keeps it single anyway).
|
||||||
is_reloader_child = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
is_reloader_child = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
||||||
|
|
||||||
if not DEBUG or is_reloader_child:
|
if not DEBUG or is_reloader_child:
|
||||||
start_scheduler(app)
|
if try_acquire_scheduler_lock(db_url):
|
||||||
|
start_scheduler(app)
|
||||||
# Print temp password at startup
|
|
||||||
with app.app_context():
|
|
||||||
if not _EMITTED_PASSWORDS:
|
|
||||||
cfg = get_auth_config()
|
|
||||||
pw = cfg.get("temp_password", "")
|
|
||||||
if pw and cfg.get("must_configure_mfa"):
|
|
||||||
print(f"\n{'='*50}", file=sys.stderr)
|
|
||||||
print(f" 🔐 临时密码: {pw}", file=sys.stderr)
|
|
||||||
print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr)
|
|
||||||
print(f"{'='*50}\n", file=sys.stderr)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
||||||
app.run(host=bind_host, port=5001, debug=DEBUG, threaded=True)
|
app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5001")),
|
||||||
|
debug=DEBUG, threaded=True)
|
||||||
finally:
|
finally:
|
||||||
if not DEBUG or is_reloader_child:
|
if not DEBUG or is_reloader_child:
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
|
|||||||
@@ -1,23 +1,72 @@
|
|||||||
import sys, os
|
"""Production WSGI entrypoint for Gunicorn (and plain `python start.py`).
|
||||||
|
|
||||||
|
- Creates the Flask app and initializes the PostgreSQL schema (with the
|
||||||
|
schema-version migration gate).
|
||||||
|
- Starts the background scheduler ONLY in the process that wins the
|
||||||
|
PostgreSQL advisory lock, so multiple Gunicorn workers never duplicate
|
||||||
|
alarms / reminders / weather jobs.
|
||||||
|
- Prints the Root temp password (when one was generated or is still pending).
|
||||||
|
|
||||||
|
Gunicorn usage (see Dockerfile / docker-compose.yml):
|
||||||
|
gunicorn --workers 2 --threads 2 --bind 0.0.0.0:5000 start:app
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from backend.app import create_app
|
from backend.app import create_app
|
||||||
from backend.scheduler import start_scheduler
|
from backend.scheduler import (
|
||||||
|
start_scheduler, stop_scheduler, try_acquire_scheduler_lock, start_watchdog,
|
||||||
|
)
|
||||||
from backend.models import _EMITTED_PASSWORDS, get_auth_config
|
from backend.models import _EMITTED_PASSWORDS, get_auth_config
|
||||||
|
|
||||||
db_path = os.environ.get("FLASK_DB_PATH", os.path.join(os.path.dirname(__file__), "data.db"))
|
# PostgreSQL connection string (kept as the historical DB_PATH config key).
|
||||||
app = create_app(db_path=db_path)
|
db_url = os.environ.get("DATABASE_URL",
|
||||||
start_scheduler(app)
|
"postgresql://iot:iot@db:5432/iot_dboard")
|
||||||
|
|
||||||
# Print temp password if it wasn't already emitted during DB init
|
app = create_app(db_path=db_url)
|
||||||
with app.app_context():
|
|
||||||
if not _EMITTED_PASSWORDS:
|
|
||||||
cfg = get_auth_config()
|
|
||||||
pw = cfg.get("temp_password", "")
|
|
||||||
if pw and cfg.get("must_configure_mfa"):
|
|
||||||
print(f"\n{'='*50}", file=sys.stderr)
|
|
||||||
print(f" 🔐 临时密码 (重启后仍有效): {pw}", file=sys.stderr)
|
|
||||||
print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr)
|
|
||||||
print(f"{'='*50}\n", file=sys.stderr)
|
|
||||||
|
|
||||||
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
|
||||||
app.run(host=bind_host, port=5000, debug=False, threaded=True)
|
def _print_temp_password():
|
||||||
|
with app.app_context():
|
||||||
|
if not _EMITTED_PASSWORDS:
|
||||||
|
cfg = get_auth_config()
|
||||||
|
pw = cfg.get("temp_password", "")
|
||||||
|
if pw and cfg.get("must_configure_mfa"):
|
||||||
|
print(f"\n{'='*50}", file=sys.stderr)
|
||||||
|
print(f" 🔐 临时密码: {pw}", file=sys.stderr)
|
||||||
|
print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr)
|
||||||
|
print(f"{'='*50}\n", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
for pw in _EMITTED_PASSWORDS:
|
||||||
|
print(f"\n{'='*50}", file=sys.stderr)
|
||||||
|
print(f" 🔐 Root 初始临时密码: {pw}", file=sys.stderr)
|
||||||
|
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
|
||||||
|
print(f"{'='*50}\n", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
_print_temp_password()
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_start_scheduler():
|
||||||
|
"""Start the scheduler if this process wins the advisory lock."""
|
||||||
|
if os.environ.get("FLASK_DISABLE_SCHEDULER", "").lower() in ("1", "true", "yes"):
|
||||||
|
return
|
||||||
|
if try_acquire_scheduler_lock(db_url):
|
||||||
|
start_scheduler(app)
|
||||||
|
app.logger.info("Scheduler started (advisory lock acquired)")
|
||||||
|
|
||||||
|
|
||||||
|
_maybe_start_scheduler()
|
||||||
|
if os.environ.get("FLASK_DISABLE_SCHEDULER", "").lower() not in ("1", "true", "yes"):
|
||||||
|
start_watchdog(app, db_url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import atexit
|
||||||
|
atexit.register(stop_scheduler)
|
||||||
|
DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes")
|
||||||
|
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
||||||
|
app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5000")),
|
||||||
|
debug=DEBUG, threaded=True)
|
||||||
|
|||||||
+255
-107
@@ -1,11 +1,18 @@
|
|||||||
"""Multi-user system test suite (dual-token auth).
|
"""Multi-user system test suite (dual-token auth) — PostgreSQL edition.
|
||||||
|
|
||||||
Run: python3 tests/test_multi_user.py
|
Run: python3 tests/test_multi_user.py
|
||||||
Covers: Root bootstrap, legacy/new-DB migration + schema-version gate,
|
Requires a local PostgreSQL (default 127.0.0.1:5432, iot/iot@iot_dboard;
|
||||||
|
override with TEST_PG_HOST / TEST_PG_USER / TEST_PG_PASS / TEST_PG_DB).
|
||||||
|
|
||||||
|
Each test uses its own isolated PostgreSQL schema; the schema is dropped and
|
||||||
|
recreated at startup.
|
||||||
|
|
||||||
|
Covers: Root bootstrap, SQLite legacy import + schema-version gate,
|
||||||
admin registration (both modes), temp-account lifecycle, data isolation,
|
admin registration (both modes), temp-account lifecycle, data isolation,
|
||||||
admin-only enforcement, re-auth (TOTP + passkey fallback), deletion,
|
admin-only enforcement, re-auth (TOTP + passkey fallback), deletion,
|
||||||
kick devices (self account + Root kick-all), access/refresh token lifecycle,
|
kick devices (self account + Root kick-all), access/refresh token lifecycle,
|
||||||
SSE ticket scoping, rate limits, CSRF, per-account timezone/theme.
|
SSE ticket scoping, rate limits, CSRF, per-account timezone/theme,
|
||||||
|
database administration (export / import / root-only / atomicity).
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
@@ -16,10 +23,12 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
os.environ.setdefault("FLASK_SECRET_KEY", "test-secret")
|
os.environ.setdefault("FLASK_SECRET_KEY", "test-secret")
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
import pyotp
|
import pyotp
|
||||||
|
|
||||||
from backend.app import create_app
|
from backend.app import create_app
|
||||||
@@ -30,9 +39,32 @@ from backend.models import (
|
|||||||
create_user, update_auth_config as uac,
|
create_user, update_auth_config as uac,
|
||||||
)
|
)
|
||||||
from backend.events import EventBus
|
from backend.events import EventBus
|
||||||
|
from backend import db_admin
|
||||||
|
|
||||||
PASS, FAIL = 0, 0
|
PASS, FAIL = 0, 0
|
||||||
|
|
||||||
|
# ─── PostgreSQL test plumbing ─────────────────
|
||||||
|
PG_HOST = os.environ.get("TEST_PG_HOST", "127.0.0.1")
|
||||||
|
PG_USER = os.environ.get("TEST_PG_USER", "iot")
|
||||||
|
PG_PASS = os.environ.get("TEST_PG_PASS", "iot")
|
||||||
|
PG_DB = os.environ.get("TEST_PG_DB", "iot_dboard")
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn(schema: str) -> str:
|
||||||
|
"""DSN for one isolated schema (search_path pinned to it)."""
|
||||||
|
opts = urllib.parse.quote(f"-csearch_path={schema}")
|
||||||
|
return f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}/{PG_DB}?options={opts}"
|
||||||
|
|
||||||
|
|
||||||
|
def reset_schema(schema: str):
|
||||||
|
conn = psycopg2.connect(f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}/{PG_DB}")
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
||||||
|
cur.execute(f'CREATE SCHEMA "{schema}"')
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
class TClient:
|
class TClient:
|
||||||
"""Flask test client wrapper that attaches the access token to each request.
|
"""Flask test client wrapper that attaches the access token to each request.
|
||||||
@@ -73,14 +105,14 @@ class TClient:
|
|||||||
|
|
||||||
|
|
||||||
def _groups_for(db):
|
def _groups_for(db):
|
||||||
conn = sqlite3.connect(db)
|
conn = get_db(db)
|
||||||
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0]
|
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _count(db, table, user_id):
|
def _count(db, table, user_id):
|
||||||
conn = sqlite3.connect(db)
|
conn = get_db(db)
|
||||||
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
|
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
return n
|
return n
|
||||||
@@ -96,8 +128,9 @@ def check(name, cond, detail=""):
|
|||||||
print(f" ❌ {name} {detail}")
|
print(f" ❌ {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
def fresh_app(tmp):
|
def fresh_app(schema):
|
||||||
db = os.path.join(tmp, "fresh.db")
|
reset_schema(schema)
|
||||||
|
db = _dsn(schema)
|
||||||
app = create_app(db_path=db)
|
app = create_app(db_path=db)
|
||||||
return app, db
|
return app, db
|
||||||
|
|
||||||
@@ -150,8 +183,7 @@ def b64url(hexstr):
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 1: Fresh DB bootstrap → Root user + temp password + MFA setup")
|
print("TEST 1: Fresh DB bootstrap → Root user + temp password + MFA setup")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
tmp = tempfile.mkdtemp()
|
app, db = fresh_app("t1")
|
||||||
app, db = fresh_app(tmp)
|
|
||||||
_EMITTED_PASSWORDS.clear()
|
_EMITTED_PASSWORDS.clear()
|
||||||
init_db(db)
|
init_db(db)
|
||||||
check("Root user exists with id 0", get_user(0, db) is not None)
|
check("Root user exists with id 0", get_user(0, db) is not None)
|
||||||
@@ -182,12 +214,24 @@ check("Wrong code rejected", r.status_code == 401)
|
|||||||
check("No token → 401", app.test_client().get("/api/theme").status_code == 401)
|
check("No token → 401", app.test_client().get("/api/theme").status_code == 401)
|
||||||
check("Bad token → 401", app.test_client().get("/api/theme", headers={"Authorization": "Bearer nope"}).status_code == 401)
|
check("Bad token → 401", app.test_client().get("/api/theme", headers={"Authorization": "Bearer nope"}).status_code == 401)
|
||||||
|
|
||||||
|
# datetime('now') 翻译正确性:PG to_char 必须用 HH24/MI(HH 是 12 小时制、MM 是月份)
|
||||||
|
import re as _re
|
||||||
|
from backend.pg_compat import _translate_sql
|
||||||
|
_tr, _ = _translate_sql("UPDATE t SET ts=datetime('now') WHERE id=?")
|
||||||
|
check("datetime('now') 翻译为 HH24:MI", "HH24:MI:SS" in _tr and "HH:MM:SS" not in _tr)
|
||||||
|
conn = get_db(db)
|
||||||
|
_trow = conn.execute("SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') AS t").fetchone()
|
||||||
|
check("to_char HH24:MI 输出格式正确", bool(_re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", _trow["t"])))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 2: Legacy single-user DB migration → data becomes Root's")
|
print("TEST 2: Legacy SQLite import → data becomes Root's (db_admin)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
|
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
|
||||||
legacy = os.path.join(tmp, "legacy.db")
|
legacy_fd, legacy = tempfile.mkstemp(suffix=".db")
|
||||||
|
with os.fdopen(legacy_fd, "wb"):
|
||||||
|
pass
|
||||||
_lc = sqlite3.connect(legacy)
|
_lc = sqlite3.connect(legacy)
|
||||||
_lc.executescript("""
|
_lc.executescript("""
|
||||||
CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')));
|
CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')));
|
||||||
@@ -207,47 +251,52 @@ _lc.execute("INSERT INTO auth_config (id, pin) VALUES (1, '0411')")
|
|||||||
_lc.execute("INSERT INTO clock_config (city, timezone, is_primary) VALUES ('Beijing', 'Asia/Shanghai', 1)")
|
_lc.execute("INSERT INTO clock_config (city, timezone, is_primary) VALUES ('Beijing', 'Asia/Shanghai', 1)")
|
||||||
_lc.commit()
|
_lc.commit()
|
||||||
_lc.close()
|
_lc.close()
|
||||||
_EMITTED_PASSWORDS.clear()
|
legacy_bytes = open(legacy, "rb").read()
|
||||||
init_db(legacy)
|
os.unlink(legacy)
|
||||||
check("Root created after legacy migration", get_user(0, legacy) is not None)
|
|
||||||
check("Legacy calendar groups → Root (user 0)", _groups_for(legacy) >= 3)
|
reset_schema("t2")
|
||||||
check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1)
|
db2 = _dsn("t2")
|
||||||
check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0)))
|
init_db(db2)
|
||||||
conn = sqlite3.connect(legacy)
|
check("Legacy file detected as sqlite", db_admin.detect_format(legacy_bytes) == "sqlite")
|
||||||
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
stats = db_admin.import_sqlite(db2, legacy_bytes)
|
||||||
|
check("Import succeeds", stats is not None and stats.get("calendar_groups") == 3)
|
||||||
|
check("Root created after legacy import", get_user(0, db2) is not None)
|
||||||
|
check("Legacy calendar groups → Root (user 0)", _groups_for(db2) >= 3)
|
||||||
|
check("Legacy alarms migrated to user 0", _count(db2, "alarms", 0) >= 1)
|
||||||
|
check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(db2, 0)))
|
||||||
|
conn = get_db(db2)
|
||||||
|
tables = [r["table_name"] for r in conn.execute(
|
||||||
|
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()").fetchall()]
|
||||||
conn.close()
|
conn.close()
|
||||||
check("camera_config table dropped", "camera_config" not in tables)
|
check("camera_config table dropped", "camera_config" not in tables)
|
||||||
check("users table exists", "users" in tables)
|
check("users table exists", "users" in tables)
|
||||||
check("meta table exists", "meta" in tables)
|
check("meta table exists", "meta" in tables)
|
||||||
check("auth_tokens table exists", "auth_tokens" in tables)
|
check("auth_tokens table exists", "auth_tokens" in tables)
|
||||||
check("Legacy DB stamped with program version", _read_db_version(get_db(legacy)) == DB_VERSION)
|
check("Legacy DB stamped with program version", _read_db_version(get_db(db2)) == DB_VERSION)
|
||||||
get_db(legacy).close()
|
get_db(db2).close()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 3: Schema-version gate (same→skip, newer→abort, migrate→stamp)")
|
print("TEST 3: Schema-version gate (same→skip, newer→abort, lower→migrate+stamp)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db3 = os.path.join(tmp, "roundtrip.db")
|
reset_schema("t3")
|
||||||
|
db3 = _dsn("t3")
|
||||||
init_db(db3)
|
init_db(db3)
|
||||||
conn = sqlite3.connect(db3)
|
|
||||||
mtime1 = os.stat(db3).st_mtime_ns
|
|
||||||
conn.close()
|
|
||||||
init_db(db3)
|
init_db(db3)
|
||||||
check("Same-version init skips rebuild", os.stat(db3).st_mtime_ns == mtime1)
|
check("Same-version init idempotent", _read_db_version(get_db(db3)) == DB_VERSION)
|
||||||
conn = get_db(db3)
|
get_db(db3).close()
|
||||||
check("Version still matches", _read_db_version(conn) == DB_VERSION)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# user data preserved across a version-mismatch migration (simulate future bump)
|
# user data preserved across a version-mismatch migration (simulate future bump)
|
||||||
db3b = os.path.join(tmp, "migrate_up.db")
|
db3b = _dsn("t3b")
|
||||||
|
reset_schema("t3b")
|
||||||
init_db(db3b)
|
init_db(db3b)
|
||||||
uid = create_user("Round Trip", db_path=db3b)
|
uid = create_user("Round Trip", db_path=db3b)
|
||||||
conn = sqlite3.connect(db3b)
|
conn = get_db(db3b)
|
||||||
conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
|
conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
# simulate an old DB (version 0 → migration path) by rewriting meta
|
# simulate an old DB (version 0 → migration path) by rewriting meta
|
||||||
conn = sqlite3.connect(db3b)
|
conn = get_db(db3b)
|
||||||
conn.execute("UPDATE meta SET version=0 WHERE id=1")
|
conn.execute("UPDATE meta SET version=0 WHERE id=1")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -258,25 +307,61 @@ check("User data preserved through migration", _count(db3b, "alarms", uid) == 1)
|
|||||||
check("Migrated DB re-stamped to program version", _read_db_version(get_db(db3b)) == DB_VERSION)
|
check("Migrated DB re-stamped to program version", _read_db_version(get_db(db3b)) == DB_VERSION)
|
||||||
get_db(db3b).close()
|
get_db(db3b).close()
|
||||||
|
|
||||||
|
# missing-column migration: drop a column, downgrade version, init adds it back
|
||||||
|
reset_schema("t3c")
|
||||||
|
db3c = _dsn("t3c")
|
||||||
|
init_db(db3c)
|
||||||
|
conn = get_db(db3c)
|
||||||
|
conn.execute("ALTER TABLE users DROP COLUMN theme")
|
||||||
|
conn.execute("UPDATE meta SET version=1 WHERE id=1")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
init_db(db3c)
|
||||||
|
conn = get_db(db3c)
|
||||||
|
theme_cols = conn.execute(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = current_schema() AND table_name='users' AND column_name='theme'").fetchall()
|
||||||
|
conn.close()
|
||||||
|
check("Missing column re-added by migration", len(theme_cols) == 1)
|
||||||
|
check("Migrated version stamped", _read_db_version(get_db(db3c)) == DB_VERSION)
|
||||||
|
get_db(db3c).close()
|
||||||
|
|
||||||
# a DB newer than the program → abort with SystemExit
|
# a DB newer than the program → abort with SystemExit
|
||||||
newer = os.path.join(tmp, "newer.db")
|
reset_schema("t3d")
|
||||||
init_db(newer)
|
db3d = _dsn("t3d")
|
||||||
conn = sqlite3.connect(newer)
|
init_db(db3d)
|
||||||
|
conn = get_db(db3d)
|
||||||
conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1")
|
conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
try:
|
try:
|
||||||
init_db(newer)
|
init_db(db3d)
|
||||||
check("Newer DB aborts startup", False, "no SystemExit raised")
|
check("Newer DB aborts startup", False, "no SystemExit raised")
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
check("Newer DB aborts startup", True)
|
check("Newer DB aborts startup", True)
|
||||||
|
|
||||||
|
# legacy timestamp DEFAULT repair (old build used to_char HH:MM → wrong 12h/month)
|
||||||
|
reset_schema("t3e")
|
||||||
|
db3e = _dsn("t3e")
|
||||||
|
init_db(db3e)
|
||||||
|
conn = get_db(db3e)
|
||||||
|
conn.execute("ALTER TABLE users ALTER COLUMN created_at SET DEFAULT to_char(now(), 'YYYY-MM-DD HH:MM:SS')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
init_db(db3e) # same version → fast path must still repair the DEFAULT
|
||||||
|
conn = get_db(db3e)
|
||||||
|
dflt = conn.execute(
|
||||||
|
"SELECT column_default FROM information_schema.columns "
|
||||||
|
"WHERE table_schema=current_schema() AND table_name='users' AND column_name='created_at'"
|
||||||
|
).fetchone()["column_default"]
|
||||||
|
conn.close()
|
||||||
|
check("Legacy timestamp DEFAULT repaired (HH24:MI)", "HH24:MI:SS" in (dflt or ""))
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 4: MFA-configured DB migration → no new temp password")
|
print("TEST 4: MFA-configured DB → no new temp password")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db4 = os.path.join(tmp, "mfa.db")
|
app4, db4 = fresh_app("t4")
|
||||||
app4 = create_app(db_path=db4)
|
|
||||||
c4 = root_client(app4, db4)
|
c4 = root_client(app4, db4)
|
||||||
_EMITTED_PASSWORDS.clear()
|
_EMITTED_PASSWORDS.clear()
|
||||||
init_db(db4) # same version → fast path, defaults intact
|
init_db(db4) # same version → fast path, defaults intact
|
||||||
@@ -288,8 +373,7 @@ print()
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 5: Admin registration (admin-fills) + TOTP provisioning + login")
|
print("TEST 5: Admin registration (admin-fills) + TOTP provisioning + login")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db5 = os.path.join(tmp, "adm.db")
|
app5, db5 = fresh_app("t5")
|
||||||
app5 = create_app(db_path=db5)
|
|
||||||
root = root_client(app5, db5)
|
root = root_client(app5, db5)
|
||||||
root_secret = get_auth_config(db5, 0)["totp_secret"]
|
root_secret = get_auth_config(db5, 0)["totp_secret"]
|
||||||
# Reauth required first
|
# Reauth required first
|
||||||
@@ -357,7 +441,7 @@ check("Account no longer temp", is_temp_account(temp_id, db5) is False)
|
|||||||
# security: the OLD temp access token must be dead immediately after finalize
|
# security: the OLD temp access token must be dead immediately after finalize
|
||||||
r = tu_client.post("/api/auth/mfa/complete")
|
r = tu_client.post("/api/auth/mfa/complete")
|
||||||
check("Old temp token dead after finalize (401)", r.status_code == 401)
|
check("Old temp token dead after finalize (401)", r.status_code == 401)
|
||||||
conn = sqlite3.connect(db5)
|
conn = get_db(db5)
|
||||||
n_temp = conn.execute("SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND temp=1", (temp_id,)).fetchone()[0]
|
n_temp = conn.execute("SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND temp=1", (temp_id,)).fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
check("No temp tokens remain after finalize", n_temp == 0)
|
check("No temp tokens remain after finalize", n_temp == 0)
|
||||||
@@ -373,27 +457,7 @@ check("New TOTP login works", r.status_code == 200)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 7: Self-service request → admin confirm → temp account")
|
print("TEST 7: Data isolation between users")
|
||||||
print("=" * 60)
|
|
||||||
req_client = app5.test_client()
|
|
||||||
r = req_client.post("/api/auth/register-request", json={"remark": "Bob"})
|
|
||||||
check("Public register request accepted", r.status_code == 201)
|
|
||||||
users_json = root.get("/api/admin/users").get_json()
|
|
||||||
pending = [u for u in users_json if u["status"] == "pending"]
|
|
||||||
check("Request appears as pending", len(pending) == 1 and pending[0]["remark"] == "Bob")
|
|
||||||
req_id = pending[0]["id"]
|
|
||||||
r = root.post("/api/admin/users/confirm", json={"request_id": req_id})
|
|
||||||
check("Admin confirms request → temp account", r.status_code == 200)
|
|
||||||
check("Confirmed account is temp", is_temp_account(req_id, db5))
|
|
||||||
r = root.post("/api/admin/users/confirm", json={"request_id": req_id})
|
|
||||||
check("Double confirm rejected", r.status_code == 400)
|
|
||||||
bob_secret = get_temp_totp_secret(req_id, db5)
|
|
||||||
r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"})
|
|
||||||
check("Bob can log in with temp TOTP", r.status_code == 200)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("TEST 8: Data isolation between users")
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||||
# User A creates data
|
# User A creates data
|
||||||
@@ -423,7 +487,7 @@ check("A sees own group events", len(r) == 1)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 9: Edit remark + delete user + session invalidation")
|
print("TEST 8: Edit remark + delete user + session invalidation")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# Fresh root session → no re-auth marker → admin ops must demand reauth.
|
# Fresh root session → no re-auth marker → admin ops must demand reauth.
|
||||||
fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
||||||
@@ -450,7 +514,7 @@ check("Self-delete guard (root)", r.status_code == 400)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 10: Kick devices (self account, all logged-in users) + Root kick-all-users")
|
print("TEST 9: Kick devices (self account, all logged-in users) + Root kick-all-users")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# Two devices of the same account + one root device
|
# Two devices of the same account + one root device
|
||||||
ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||||
@@ -474,10 +538,9 @@ check("Re-login after kick-all", r.status_code == 200)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)")
|
print("TEST 10: Access/refresh token lifecycle (rotation, replay, refresh)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db11 = os.path.join(tmp, "tok.db")
|
app11, db11 = fresh_app("t10")
|
||||||
app11 = create_app(db_path=db11)
|
|
||||||
r11 = root_client(app11, db11)
|
r11 = root_client(app11, db11)
|
||||||
root11_secret = get_auth_config(db11, 0)["totp_secret"]
|
root11_secret = get_auth_config(db11, 0)["totp_secret"]
|
||||||
# fresh login → access + refresh cookie
|
# fresh login → access + refresh cookie
|
||||||
@@ -503,7 +566,6 @@ r_again = rotate_refresh_token(_ref, db11)
|
|||||||
check("Replay of rotated refresh → None (single-use)", r_again is None)
|
check("Replay of rotated refresh → None (single-use)", r_again is None)
|
||||||
r = raw.post("/api/auth/refresh")
|
r = raw.post("/api/auth/refresh")
|
||||||
check("Live refresh still works after rotation", r.status_code == 200)
|
check("Live refresh still works after rotation", r.status_code == 200)
|
||||||
# access token survives until expiry; no expiry here, skip time travel
|
|
||||||
# logout revokes refresh but NOT other devices' tokens
|
# logout revokes refresh but NOT other devices' tokens
|
||||||
tc2, _ = do_login(app11.test_client(), pyotp.TOTP(root11_secret).now())
|
tc2, _ = do_login(app11.test_client(), pyotp.TOTP(root11_secret).now())
|
||||||
r = tc2.post("/api/auth/logout")
|
r = tc2.post("/api/auth/logout")
|
||||||
@@ -516,7 +578,7 @@ check("Other device unaffected by logout", r.status_code == 200)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 12: SSE ticket auth (one-time, scoped)")
|
print("TEST 11: SSE ticket auth (one-time, scoped)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# no ticket → 401
|
# no ticket → 401
|
||||||
check("SSE without ticket → 401", app5.test_client().get("/api/events").status_code == 401)
|
check("SSE without ticket → 401", app5.test_client().get("/api/events").status_code == 401)
|
||||||
@@ -557,16 +619,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 13: Rate limit on register-request")
|
print("TEST 12: CSRF origin check on admin operations")
|
||||||
print("=" * 60)
|
|
||||||
for i in range(5):
|
|
||||||
app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"})
|
|
||||||
r = app5.test_client().post("/api/auth/register-request", json={"remark": "spam5"})
|
|
||||||
check("6th register request rate-limited", r.status_code == 429)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("TEST 14: CSRF origin check on admin operations")
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) # re-login after kicks
|
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) # re-login after kicks
|
||||||
rr = reauth_totp(root, root_secret)
|
rr = reauth_totp(root, root_secret)
|
||||||
@@ -585,7 +638,7 @@ check("Same-origin refresh allowed", r.status_code == 200)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 15: Passkey flows (mocked ceremonies)")
|
print("TEST 13: Passkey flows (mocked ceremonies)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
from backend import webauthn_mgr
|
from backend import webauthn_mgr
|
||||||
import backend.routes as routes_mod
|
import backend.routes as routes_mod
|
||||||
@@ -618,8 +671,7 @@ routes_mod.complete_authentication = lambda request, credential, stored_credenti
|
|||||||
"ok": True, "credential_id": _cred_hex(credential),
|
"ok": True, "credential_id": _cred_hex(credential),
|
||||||
}
|
}
|
||||||
|
|
||||||
db15 = os.path.join(tmp, "passkey.db")
|
app15, db15 = fresh_app("t13")
|
||||||
app15 = create_app(db_path=db15)
|
|
||||||
root15 = root_client(app15, db15)
|
root15 = root_client(app15, db15)
|
||||||
root15_secret = get_auth_config(db15, 0)["totp_secret"]
|
root15_secret = get_auth_config(db15, 0)["totp_secret"]
|
||||||
rr = reauth_totp(root15, root15_secret)
|
rr = reauth_totp(root15, root15_secret)
|
||||||
@@ -672,14 +724,13 @@ routes_mod.complete_authentication = orig_complete_auth
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 16: Scheduler thread context (per-user timezone, no request context)")
|
print("TEST 14: Scheduler thread context (per-user timezone, no request context)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id
|
from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id
|
||||||
|
|
||||||
db16 = os.path.join(tmp, "sched.db")
|
app16, db16 = fresh_app("t14")
|
||||||
app16 = create_app(db_path=db16)
|
|
||||||
xid = create_user("Sched User", db_path=db16)
|
xid = create_user("Sched User", db_path=db16)
|
||||||
conn = sqlite3.connect(db16)
|
conn = get_db(db16)
|
||||||
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
|
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -711,7 +762,7 @@ check("Scheduler starts and stops cleanly", True)
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 17: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
|
print("TEST 15: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
from backend.url_util import is_safe_public_url, is_safe_https_host
|
from backend.url_util import is_safe_public_url, is_safe_https_host
|
||||||
check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False)
|
check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False)
|
||||||
@@ -725,7 +776,7 @@ check("Host: public host accepted", is_safe_https_host("example.com") is True)
|
|||||||
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
||||||
rr = reauth_totp(root, root_secret)
|
rr = reauth_totp(root, root_secret)
|
||||||
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||||
# User B was deleted in TEST 9 — create a fresh user C for cross-user checks.
|
# User B was deleted in TEST 8 — create a fresh user C for cross-user checks.
|
||||||
rr = reauth_totp(root, root_secret)
|
rr = reauth_totp(root, root_secret)
|
||||||
r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"})
|
r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"})
|
||||||
uc_id = r.get_json()["user"]["id"]
|
uc_id = r.get_json()["user"]["id"]
|
||||||
@@ -765,7 +816,7 @@ g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json()
|
|||||||
fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"})
|
fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"})
|
||||||
fc.post("/api/alarms", json={"label": "a", "time": "10:00"})
|
fc.post("/api/alarms", json={"label": "a", "time": "10:00"})
|
||||||
fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999})
|
fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999})
|
||||||
conn = sqlite3.connect(db5)
|
conn = get_db(db5)
|
||||||
before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0]
|
before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0]
|
||||||
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state", "auth_tokens"]}
|
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state", "auth_tokens"]}
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -773,7 +824,7 @@ check("Cascade test user has data", sum(before.values()) >= 5)
|
|||||||
rr = reauth_totp(root, root_secret)
|
rr = reauth_totp(root, root_secret)
|
||||||
r = root.delete(f"/api/admin/users/{fd_id}")
|
r = root.delete(f"/api/admin/users/{fd_id}")
|
||||||
check("Delete full-data user succeeds", r.status_code == 200)
|
check("Delete full-data user succeeds", r.status_code == 200)
|
||||||
conn = sqlite3.connect(db5)
|
conn = get_db(db5)
|
||||||
after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before}
|
after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before}
|
||||||
conn.close()
|
conn.close()
|
||||||
check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after))
|
check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after))
|
||||||
@@ -781,10 +832,9 @@ check("Deleted user's live tokens invalidated", fc.get("/api/alarms").status_cod
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 18: Passkey stash 一次性参数暂存(小窗传参协商)")
|
print("TEST 16: Passkey stash 一次性参数暂存(小窗传参协商)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db18 = os.path.join(tmp, "stash.db")
|
app18, db18 = fresh_app("t16")
|
||||||
app18 = create_app(db_path=db18)
|
|
||||||
c18 = app18.test_client()
|
c18 = app18.test_client()
|
||||||
opts = {"challenge": "ch123", "rp": {"id": "d.example.com"}, "user": {"id": "u1", "name": "user_1"}}
|
opts = {"challenge": "ch123", "rp": {"id": "d.example.com"}, "user": {"id": "u1", "name": "user_1"}}
|
||||||
r = c18.post("/api/passkey/stash", json={"op": "creation", "options": opts})
|
r = c18.post("/api/passkey/stash", json={"op": "creation", "options": opts})
|
||||||
@@ -829,10 +879,9 @@ check("读取参数后仍可 POST result(修复 410 回归)", r.status_code
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 19: 账户独立主题(dark/light/auto)")
|
print("TEST 17: 账户独立主题(dark/light/auto)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db19 = os.path.join(tmp, "theme.db")
|
app19, db19 = fresh_app("t17")
|
||||||
app19 = create_app(db_path=db19)
|
|
||||||
pw19 = get_temp_password(db19, 0)
|
pw19 = get_temp_password(db19, 0)
|
||||||
c19, _ = do_login(app19.test_client(), pw19)
|
c19, _ = do_login(app19.test_client(), pw19)
|
||||||
setup_totp(c19)
|
setup_totp(c19)
|
||||||
@@ -861,10 +910,9 @@ check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code =
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 20: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
|
print("TEST 18: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
db20 = os.path.join(tmp, "tz.db")
|
app20, db20 = fresh_app("t18")
|
||||||
app20 = create_app(db_path=db20)
|
|
||||||
pw20 = get_temp_password(db20, 0)
|
pw20 = get_temp_password(db20, 0)
|
||||||
c20, _ = do_login(app20.test_client(), pw20)
|
c20, _ = do_login(app20.test_client(), pw20)
|
||||||
setup_totp(c20)
|
setup_totp(c20)
|
||||||
@@ -884,11 +932,10 @@ r = c20.put("/api/time/config", json={"timezone": "Mars/Olympus"})
|
|||||||
check("无效时区拒绝", r.status_code == 400)
|
check("无效时区拒绝", r.status_code == 400)
|
||||||
# 账户独立:另一用户仍是默认时区
|
# 账户独立:另一用户仍是默认时区
|
||||||
ua20 = create_user("TZ A", db_path=db20)
|
ua20 = create_user("TZ A", db_path=db20)
|
||||||
conn = sqlite3.connect(db20)
|
conn = get_db(db20)
|
||||||
conn.execute("UPDATE users SET status='active' WHERE id=?", (ua20,))
|
conn.execute("UPDATE users SET status='active' WHERE id=?", (ua20,))
|
||||||
conn.close()
|
conn.close()
|
||||||
# 给用户 A 配 TOTP 再登录
|
# 给用户 A 配 TOTP 再登录
|
||||||
from backend.auth import generate_temp_password
|
|
||||||
uac(ua20, totp_secret="JBSWY3DPEHPK3PXP", totp_enabled=1, _db_path=db20)
|
uac(ua20, totp_secret="JBSWY3DPEHPK3PXP", totp_enabled=1, _db_path=db20)
|
||||||
a20, _ = do_login(app20.test_client(), pyotp.TOTP("JBSWY3DPEHPK3PXP").now())
|
a20, _ = do_login(app20.test_client(), pyotp.TOTP("JBSWY3DPEHPK3PXP").now())
|
||||||
st = a20.get("/api/display/state").get_json()
|
st = a20.get("/api/display/state").get_json()
|
||||||
@@ -896,7 +943,7 @@ check("用户 B 时区保持默认", st.get("timezone") == "Asia/Shanghai")
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("TEST 21: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
|
print("TEST 19: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie)
|
# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie)
|
||||||
raw_nav = app5.test_client()
|
raw_nav = app5.test_client()
|
||||||
@@ -913,10 +960,14 @@ anon_nav = app5.test_client()
|
|||||||
check("未登录 /control → 302", anon_nav.get("/control").status_code == 302)
|
check("未登录 /control → 302", anon_nav.get("/control").status_code == 302)
|
||||||
check("未登录 / → 302", anon_nav.get("/").status_code == 302)
|
check("未登录 / → 302", anon_nav.get("/").status_code == 302)
|
||||||
check("未登录 /mfa-setup → 302", anon_nav.get("/mfa-setup").status_code == 302)
|
check("未登录 /mfa-setup → 302", anon_nav.get("/mfa-setup").status_code == 302)
|
||||||
# temp 用户(Bob 仍是临时账户)导航
|
# temp 用户导航(用 TEST 6 的 temp 账户,仍是临时账户 → 强制 MFA 设置页)
|
||||||
|
rr = reauth_totp(root, root_secret)
|
||||||
|
r = root.post("/api/admin/users", json={"remark": "Nav Temp", "mode": "user_fills"})
|
||||||
|
nav_temp_id = r.get_json()["user"]["id"]
|
||||||
|
nav_temp_secret = get_temp_totp_secret(nav_temp_id, db5)
|
||||||
bob_raw = app5.test_client()
|
bob_raw = app5.test_client()
|
||||||
r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"})
|
r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(nav_temp_secret).now(), "view": "control"})
|
||||||
check("Bob temp 登录", r.status_code == 200)
|
check("temp 用户登录", r.status_code == 200)
|
||||||
check("temp 用户 /mfa-setup → 200", bob_raw.get("/mfa-setup").status_code == 200)
|
check("temp 用户 /mfa-setup → 200", bob_raw.get("/mfa-setup").status_code == 200)
|
||||||
loc = bob_raw.get("/control").headers.get("Location", "")
|
loc = bob_raw.get("/control").headers.get("Location", "")
|
||||||
check("temp 用户 /control → 302 mfa-setup", "/mfa-setup" in loc)
|
check("temp 用户 /control → 302 mfa-setup", "/mfa-setup" in loc)
|
||||||
@@ -925,6 +976,103 @@ r = bob_raw.post("/api/auth/logout")
|
|||||||
check("登出 ok", r.status_code == 200)
|
check("登出 ok", r.status_code == 200)
|
||||||
check("登出后 /mfa-setup → 302", bob_raw.get("/mfa-setup").status_code == 302)
|
check("登出后 /mfa-setup → 302", bob_raw.get("/mfa-setup").status_code == 302)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 20: 数据库管理(仅 Root、导出/导入 roundtrip、格式识别、原子性)")
|
||||||
|
print("=" * 60)
|
||||||
|
app21, db21 = fresh_app("t20")
|
||||||
|
root21 = root_client(app21, db21)
|
||||||
|
root21_secret = get_auth_config(db21, 0)["totp_secret"]
|
||||||
|
|
||||||
|
# 造点数据
|
||||||
|
c21 = app21.test_client()
|
||||||
|
r = c21.post("/api/auth/login", json={"code": pyotp.TOTP(root21_secret).now(), "view": "control"})
|
||||||
|
root21 = TClient(c21, r.get_json()["access_token"])
|
||||||
|
r = root21.post("/api/calendar/groups", json={"name": "DB Test"})
|
||||||
|
check("Setup: group created", r.status_code == 201)
|
||||||
|
r = root21.post("/api/alarms", json={"label": "DB alarm", "time": "07:30"})
|
||||||
|
check("Setup: alarm created", r.status_code == 201)
|
||||||
|
|
||||||
|
# 非 root 用户(普通 admin 角色也拒绝——仅 root id 0)
|
||||||
|
rr = reauth_totp(root21, root21_secret)
|
||||||
|
r = root21.post("/api/admin/users", json={"remark": "DbUser", "mode": "admin_fills"})
|
||||||
|
dbu_id = r.get_json()["user"]["id"]
|
||||||
|
r = root21.post(f"/api/admin/users/{dbu_id}/totp/begin")
|
||||||
|
dbu_secret = r.get_json()["secret"]
|
||||||
|
root21.post(f"/api/admin/users/{dbu_id}/totp/verify", json={"code": pyotp.TOTP(dbu_secret).now()})
|
||||||
|
dbu, _ = do_login(app21.test_client(), pyotp.TOTP(dbu_secret).now())
|
||||||
|
r = dbu.get("/api/admin/db/status")
|
||||||
|
check("非 root 访问 db 管理 → 403", r.status_code == 403)
|
||||||
|
r = dbu.get("/api/admin/db/export")
|
||||||
|
check("非 root 导出 → 403", r.status_code == 403)
|
||||||
|
|
||||||
|
# 未二次验证 → 403(用全新的 root 会话,无 reauth 标记)
|
||||||
|
fresh_root21, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
|
||||||
|
r = fresh_root21.get("/api/admin/db/export")
|
||||||
|
check("导出未 reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required"))
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
r = root21.get("/api/admin/db/status")
|
||||||
|
d = r.get_json()
|
||||||
|
check("db status 返回 PostgreSQL v" + str(DB_VERSION), d.get("database") == "postgresql" and d.get("db_version") == DB_VERSION)
|
||||||
|
|
||||||
|
# 导出(reauth 后)
|
||||||
|
rr = reauth_totp(root21, root21_secret)
|
||||||
|
r = root21.get("/api/admin/db/export")
|
||||||
|
check("Root 导出成功", r.status_code == 200 and "json" in r.content_type)
|
||||||
|
backup = json.loads(r.data)
|
||||||
|
check("备份格式正确", backup.get("format") == "iot-dboard-backup")
|
||||||
|
check("备份含用户数据", len(backup["tables"]["calendar_groups"]["rows"]) >= 1
|
||||||
|
and len(backup["tables"]["alarms"]["rows"]) >= 1)
|
||||||
|
check("备份不含会话令牌", "auth_tokens" not in backup["tables"])
|
||||||
|
|
||||||
|
# 格式识别
|
||||||
|
check("SQLite magic 识别", db_admin.detect_format(b"SQLite format 3\x00" + b"x" * 8) == "sqlite")
|
||||||
|
check("JSON 备份识别", db_admin.detect_format(json.dumps({"format": "iot-dboard-backup"}).encode()) == "json")
|
||||||
|
check("未知格式识别", db_admin.detect_format(b"garbage") == "unknown")
|
||||||
|
|
||||||
|
# 导入备份 roundtrip(导入会清空会话 → root 需重登)
|
||||||
|
r = root21.post("/api/admin/db/import", data={"file": (io.BytesIO(json.dumps(backup).encode()), "b.json")},
|
||||||
|
content_type="multipart/form-data")
|
||||||
|
check("导入备份成功", r.status_code == 200 and r.get_json().get("ok"), str(r.get_json()))
|
||||||
|
check("导入后会话失效标记", r.get_json().get("session_invalidated") is True)
|
||||||
|
conn = get_db(db21)
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='DB Test'").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
check("导入后数据恢复", n >= 1)
|
||||||
|
# 导入后旧 token 失效
|
||||||
|
check("导入后旧会话失效 → 401", root21.get("/api/admin/db/status").status_code == 401)
|
||||||
|
|
||||||
|
# 导入非法文件 → 400(不破坏数据)
|
||||||
|
root21b, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
|
||||||
|
rr = reauth_totp(root21b, root21_secret)
|
||||||
|
r = root21b.post("/api/admin/db/import", data={"file": (io.BytesIO(b"junk"), "x.bin")},
|
||||||
|
content_type="multipart/form-data")
|
||||||
|
check("导入非法文件 → 400", r.status_code == 400, str(r.get_json()))
|
||||||
|
conn = get_db(db21)
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='DB Test'").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
check("非法导入未破坏数据", n >= 1)
|
||||||
|
|
||||||
|
# 导入失败原子性:构造一个会导致失败的备份(缺少必需列 → 行插入失败回滚)
|
||||||
|
root21c, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
|
||||||
|
rr = reauth_totp(root21c, root21_secret)
|
||||||
|
bad = {
|
||||||
|
"format": "iot-dboard-backup",
|
||||||
|
"db_version": DB_VERSION,
|
||||||
|
"tables": {"calendar_groups": {"columns": ["id", "user_id", "name", "color", "enabled", "created_at"],
|
||||||
|
"rows": [[1, 0, "OK Group", "#fff", 1, "2026-01-01 00:00:00"],
|
||||||
|
[2, 0, None, "#fff", 1, None]]}}, # name NOT NULL → None 会失败
|
||||||
|
}
|
||||||
|
# 直接把 name=None 塞进去会因参数为 None 失败(psycopg2 拒绝 None 绑定到 NOT NULL?其实 None → NULL,NOT NULL 约束报错)
|
||||||
|
r = root21c.post("/api/admin/db/import", data={"file": (io.BytesIO(json.dumps(bad).encode()), "bad.json")},
|
||||||
|
content_type="multipart/form-data")
|
||||||
|
check("含非法行的导入 → 400/500", r.status_code in (400, 500), str(r.get_json()))
|
||||||
|
conn = get_db(db21)
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='OK Group'").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
check("失败导入整体回滚(原子性)", n == 0)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"RESULT: {PASS} passed, {FAIL} failed")
|
print(f"RESULT: {PASS} passed, {FAIL} failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user