New Database

This commit is contained in:
喆OvO
2026-08-11 22:07:55 +08:00
parent cbbb2e6d34
commit ceb4404c6a
28 changed files with 1825 additions and 768 deletions
+9 -5
View File
@@ -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
+99 -76
View File
@@ -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
-1
View File
@@ -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()
+41 -9
View File
@@ -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,9 +66,40 @@ def create_app(db_path: str = "data.db") -> Flask:
return app return app
# Startup weather fetch lock: only one Gunicorn worker may fetch at a time
# (others skip; the cache is then fresh so the regular scheduler job handles it).
_WEATHER_STARTUP_LOCK_KEY = 0x5745415448455253 # "WEATHERS"
def _startup_weather(app, db_path: str): 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: try:
conn = sqlite3.connect(db_path) 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:
lock_conn.close()
except Exception:
pass
return
try:
try:
conn = get_db(db_path)
row = conn.execute( row = conn.execute(
"SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1" "SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1"
).fetchone() ).fetchone()
@@ -81,9 +108,9 @@ def _startup_weather(app, db_path: str):
row = None row = None
should_fetch = True should_fetch = True
if row and row[0]: if row and row["updated_at"]:
try: try:
updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S") updated = tz_strptime(row["updated_at"], "%Y-%m-%d %H:%M:%S")
age = tz_now() - updated age = tz_now() - updated
if age < timedelta(minutes=30): if age < timedelta(minutes=30):
should_fetch = False should_fetch = False
@@ -100,3 +127,8 @@ def _startup_weather(app, db_path: str):
app.logger.info("Startup: weather fetched (cache stale or empty)") app.logger.info("Startup: weather fetched (cache stale or empty)")
except Exception as e: except Exception as e:
app.logger.warning(f"Startup weather fetch failed: {e}") app.logger.warning(f"Startup weather fetch failed: {e}")
finally:
try:
lock_conn.close() # releases the advisory lock
except Exception:
pass
+3 -4
View File
@@ -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 ────────────────
+1 -4
View File
@@ -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
View File
@@ -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
+263
View File
@@ -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)")
-3
View File
@@ -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()})
+259 -186
View File
@@ -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,73 +909,192 @@ 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,)
).fetchall()
return [r["column_name"] for r in rows]
def _table_columns(conn, table_name): def _read_db_version(conn) -> int | None:
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")] """Read the schema version from the `meta` table.
Returns None when the DB is legacy (no meta table / no version row).
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") try:
new_cols = _table_columns(conn_new, "auth_config") row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
if not old_cols: return int(row["version"]) if row else None
return except Exception:
rows = conn_old.execute("SELECT * FROM auth_config").fetchall() conn._safe_rollback()
for row in rows: return None
d = dict(row)
if "user_id" in old_cols:
uid = d.get("user_id") or 0 # ─── SCHEMA parsing for column-level migration ──────
else: _CREATE_TABLE_RE = re.compile(
# Legacy single-user DB: the id=1 row belongs to Root (user 0). r"CREATE TABLE IF NOT EXISTS\s+(\w+)\s*\((.*?)\)\s*;",
old_id = d.get("id", 1) re.IGNORECASE | re.DOTALL,
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"): def _split_top_level(text: str) -> list:
"""Initialize the database with schema-version aware migration. """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
- 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) def _strip_check(s: str) -> str:
and fills missing columns with SCHEMA defaults. """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.
""" """
if not os.path.exists(db_path): for m in _CREATE_TABLE_RE.finditer(SCHEMA):
conn = get_db(db_path) table = m.group(1)
conn.executescript(SCHEMA) try:
_ensure_defaults(conn) existing = set(_table_columns(conn, table))
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,)) except Exception:
conn.commit() conn._safe_rollback()
conn.close() continue
return 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
conn_probe = get_db(db_path)
old_version = _read_db_version(conn_probe) def _fix_identity_sequences(conn):
conn_probe.close() """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: if old_version is not None and old_version > DB_VERSION:
raise SystemExit( raise SystemExit(
@@ -970,90 +1103,30 @@ def init_db(db_path: str = "data.db"):
) )
if old_version is not None and old_version == DB_VERSION: if old_version is not None and old_version == DB_VERSION:
# Same version — no rebuild needed. Just ensure defaults exist. # Same version — no upgrade needed. Just ensure defaults exist.
conn = get_db(db_path)
_ensure_defaults(conn) _ensure_defaults(conn)
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,)) _fix_timestamp_defaults(conn)
conn.commit() conn.commit()
conn.close()
return return
# Migrate by rebuilding: create a fresh DB with the latest schema, # Create any missing tables (CREATE TABLE IF NOT EXISTS is idempotent),
# copy compatible data from the old DB, then replace the old DB. # then add any columns missing from older databases. The DDL is
new_db_path = db_path + ".new" # committed first so a later best-effort column migration (which may
backup_path = db_path + ".backup" # roll back its own transaction) can never undo the schema creation.
conn.executescript(SCHEMA)
conn.commit()
_migrate_missing_columns(conn)
# Clean up any stale temporary database from a previous failed run. # Bootstrap defaults (Root user, meta row, config rows).
if os.path.exists(new_db_path): _ensure_defaults(conn)
os.remove(new_db_path)
conn_new = get_db(new_db_path) # Repair legacy timestamp DEFAULT expressions (old to_char HH:MM pattern).
conn_new.executescript(SCHEMA) _fix_timestamp_defaults(conn)
conn_old = get_db(db_path) # Stamp the database with the program's schema version.
# Disable FK enforcement while copying; rows are copied table by table. conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
conn_old.execute("PRAGMA foreign_keys=OFF") conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
conn_new.execute("PRAGMA foreign_keys=OFF") _fix_identity_sequences(conn)
conn.commit()
tables = conn_old.execute( finally:
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" conn.close()
).fetchall()
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:
"""Read the schema version from the `meta` table.
Returns None when the DB is legacy (no meta table / no version column).
"""
try:
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
return int(row["version"]) if row else None
except Exception:
return None
+252
View File
@@ -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)
-3
View File
@@ -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
View File
@@ -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
# ════════════════════════════════════════════ # ════════════════════════════════════════════
+127
View File
@@ -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:
try:
_scheduler.shutdown(wait=False) _scheduler.shutdown(wait=False)
finally:
_scheduler = None _scheduler = None
+2 -3
View File
@@ -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 -4
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+95 -13
View File
@@ -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();
var anyCd = (msg.data.countdowns || []).some(function (cd) {
return fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''));
});
if (anyCd) {
showAlarmOverlay(msg.data); // reuse alarm overlay UI showAlarmOverlay(msg.data); // reuse alarm overlay UI
switchPanel('countdown'); 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>' +
+17 -1
View File
@@ -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;">
-4
View File
@@ -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>
-4
View File
@@ -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>
-151
View File
@@ -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>
+2
View File
@@ -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
+16 -22
View File
@@ -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:
if try_acquire_scheduler_lock(db_url):
start_scheduler(app) 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()
+57 -8
View File
@@ -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)
def _print_temp_password():
with app.app_context(): with app.app_context():
if not _EMITTED_PASSWORDS: if not _EMITTED_PASSWORDS:
cfg = get_auth_config() cfg = get_auth_config()
pw = cfg.get("temp_password", "") pw = cfg.get("temp_password", "")
if pw and cfg.get("must_configure_mfa"): if pw and cfg.get("must_configure_mfa"):
print(f"\n{'='*50}", file=sys.stderr) print(f"\n{'='*50}", file=sys.stderr)
print(f" 🔐 临时密码 (重启后仍有效): {pw}", file=sys.stderr) print(f" 🔐 临时密码: {pw}", file=sys.stderr)
print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr) print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr)
print(f"{'='*50}\n", 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") bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
app.run(host=bind_host, port=5000, debug=False, threaded=True) app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5000")),
debug=DEBUG, threaded=True)
+255 -107
View File
@@ -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")