commit bb3fa56a481a973923300e6ef2b27320279e9315 Author: SIMKEEP Date: Tue Oct 6 15:49:27 2026 +0800 Initial release of SIMKEEP diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d776ca4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +* +!Dockerfile +!requirements.txt +!index.html +!style.css +!app.js +!api.js +!core.js +!money.js +!countries.js +!favicon.svg +!server/ +!server/*.py +!scripts/ +!scripts/build.py +!scripts/backup.py +!scripts/restore.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7da9f7d --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# 访问地址。远程部署时替换为自己的地址,端口需与 SIMKEEP_PORT 一致。 +SIMKEEP_PUBLIC_URL=http://localhost:5180 +SIMKEEP_SECURE_COOKIES=0 +# 本地运行时的数据库路径;Docker Compose 会覆盖为容器内路径。 +SIMKEEP_DB=data/simkeep.db +# Docker Compose 公网端口;改端口时同步修改 SIMKEEP_PUBLIC_URL。 +SIMKEEP_PORT=5180 + +# 可选的公共默认通知服务;用户也可在网页单独配置自己的服务。 +# 从 BotFather 获取 Token。可选用户名不包含 @;留空会自动查询。 +# 每个 Bot 只运行一个 getUpdates 消费者,不要与其他 Bot 程序共用 Token。 +SIMKEEP_TELEGRAM_TOKEN= +SIMKEEP_TELEGRAM_USERNAME= + +# SMTP_MODE: starttls (通常 587)、ssl (通常 465)、plain (仅用于受控邮件中继)。 +SIMKEEP_SMTP_HOST= +SIMKEEP_SMTP_PORT=587 +SIMKEEP_SMTP_MODE=starttls +SIMKEEP_SMTP_USER= +SIMKEEP_SMTP_PASSWORD= +SIMKEEP_SMTP_FROM= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df57498 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Private runtime configuration (only the empty template is public). +.env* +*.env +*.env.* +!.env.example +# Databases, backups and exports contain account and notification credentials. +data/ +backups/ +docker-backups/ +*.db +*.db-* +*.sqlite +*.sqlite-* +*.sqlite3 +*.sqlite3-* +*.sql +*.backup +*.bak +simkeep-*.json +# Generated builds, browser traces and local tooling. +preview/ +dist/ +output/ +releases/ +node_modules/ +.venv/ +venv/ +__pycache__/ +*.pyc +.playwright-cli/ +*.log +*.zip +*.tar +*.tar.gz +.DS_Store +.idea/ +.vscode/ +# Local files. +.local/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36e7ccd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim AS frontend +WORKDIR /build +COPY index.html style.css app.js api.js core.js money.js countries.js favicon.svg ./ +COPY scripts/build.py ./scripts/build.py +RUN python scripts/build.py + +FROM python:3.12-slim AS runtime +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 SIMKEEP_DB=/app/data/simkeep.db SIMKEEP_STATIC_DIR=/app/dist +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY server ./server +COPY scripts/backup.py scripts/restore.py ./scripts/ +COPY --from=frontend /build/dist ./dist +RUN groupadd --gid 10001 simkeep && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin simkeep && install -d -m 0700 -o simkeep -g simkeep /app/data +USER 10001:10001 +EXPOSE 5180 +CMD ["python", "-m", "uvicorn", "server.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "5180", "--workers", "1", "--no-access-log"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec90c4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rest-rain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a65777 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +

+ SIMKEEP 续卡:记住每张卡的下一次续期 +

+ +# SIMKEEP · 续卡 + +**记住每张卡的下一次续期。** + +SIMKEEP 是一个可自行部署的 SIM / eSIM 管理平台。把开通日期、平台注册关系、多币种余额、续期操作和通知放在同一个地方,让每个人管理自己的卡片。 + +名字来自 **SIM + Keep**:让需要保留的号码持续保持可用。 + +[界面预览](#界面预览) · [Docker 部署](#docker-部署) · [通知配置](#通知配置) · [备份与更新](#备份与更新) + +## 功能 + +- **卡片档案**:记录 SIM / eSIM 类型、运营商、地区、号码、开通时间、套餐及备注,支持归档与恢复。 +- **续期计划**:一张卡设置多条规则,支持重置、购买套餐、打电话、发短信、充值及自定义操作;周期按天或日历月计算。 +- **下次日期自动计算**:记录完成时间后,按完成日顺延或固定周期更新下一次续期,保留操作历史。 +- **多币种余额与费用**:支持 23 种币种,分别记录余额、预计费用与实际费用;完成操作时默认扣除同币种余额,也可只记录费用。充值可计入余额。 +- **平台注册记录**:关联 Telegram、Google、WhatsApp、PayPal 等平台及账号标识,支持搜索和筛选。 +- **Telegram / 邮件提醒**:每个账号在网页填写自己的 Bot Token 或 SMTP,设置时区、提醒时间、提前天数及逾期提醒,查看发送记录。 +- **多人使用**:注册、登录和退出;卡片、费用、平台关系、通知设置及凭据按账号隔离。 +- **桌面与手机**:卡片视图、列表、续期日历、移动端布局和 JSON 导出;界面不依赖外部 CDN。 + +金额使用十进制与整数最小单位计算,不进行汇率换算。续期操作由你实际执行,SIMKEEP 负责提醒、记录和计算。 + +## 界面预览 + +下图使用独立演示账号和虚构资料;号码、邮箱、余额及费用均为示例,运营商周期请以实际条款为准。 + +### 卡片总览 + +查看各币种余额、近期续期金额、卡片和日历。 + +![SIMKEEP 卡片总览](docs/images/overview.png) + +### 卡片详情与续期历史 + +把卡片资料、关联平台、续期规则和实际费用放在一起。 + +![卡片详情与费用历史](docs/images/card-detail.png) + +### 通知设置 + +在网页配置自己的通知服务,再绑定 Telegram 或验证接收邮箱。 + +![Telegram 与邮件通知设置](docs/images/notifications.png) + +
+查看手机界面 + +

+ SIMKEEP 手机卡片管理界面 +

+ +
+ +## Docker 部署 + +需要 Docker Engine 和 Docker Compose 插件。镜像会自动构建前端,服务器无需安装 Node.js 或 Python。 + +下载仓库并进入项目目录后执行: + +```bash +cp .env.example .env +chmod 600 .env +``` + +编辑 `.env`,把 `SIMKEEP_PUBLIC_URL` 改成自己的访问地址,例如 `http://your-server:5180`。本机试用可保留 `http://localhost:5180`。 + +```bash +docker compose up -d --build --wait +docker compose ps +``` + +打开 `http://localhost:5180` 或设置的服务器地址,点击“创建账号”。没有预置管理员、初始密码或演示资料。 + +| 配置项 | 默认值 | 用途 | +| --- | --- | --- | +| `SIMKEEP_PUBLIC_URL` | `http://localhost:5180` | 网页访问地址和提醒中的链接 | +| `SIMKEEP_PORT` | `5180` | 宿主机端口;修改时同步调整访问地址 | +| `SIMKEEP_BIND_ADDRESS` | `0.0.0.0` | 宿主机监听地址 | +| `SIMKEEP_SECURE_COOKIES` | `0` | HTTP 使用 `0`;通过 HTTPS 访问时使用 `1` | + +数据保存于命名卷 `simkeep_simkeep-data` 中的 `/app/data/simkeep.db`。容器重启和镜像更新会保留数据。使用单个 Web worker 和一个提醒调度进程。 + +详见 [Docker 部署与恢复说明](docs/DOCKER.md)。 + +## 通知配置 + +登录后进入 **通知设置**。每个渠道都有配置按钮,保存后立即生效,无需重启 Docker。 + +### Telegram + +1. 在 [BotFather](https://t.me/BotFather) 创建 Bot,获取 Token。 +2. 点击 **配置 Telegram**,填写 Token,然后 **保存并检查**。 +3. 点击 **绑定 Telegram**,打开 Bot 并点击“开始”,回到网页检查绑定。 +4. 开启 Telegram 提醒,选择提醒时间并 **保存设置**。 + +同一个 Bot Token 只用于一个账号,且需要由 SIMKEEP 独占 `getUpdates`。不要同时运行其他消费者或设置其他 webhook。更换 Token 后需要重新绑定。 + +### 邮件 + +1. 点击 **配置邮件**,填写 SMTP 服务器、端口、安全方式、发件邮箱、用户名和密码 / 授权码。 +2. 点击 **保存并检查**。端口 `587` 通常使用 STARTTLS,`465` 通常使用 SSL / TLS;以邮件服务说明为准。 +3. 保存接收邮箱,点击 **发送验证码**,填写邮件中的验证码。 +4. 开启邮箱提醒,选择提醒时间并 **保存设置**。 + +支持无需登录认证的 SMTP,此时用户名留空。发件邮箱需是邮件服务允许使用的地址。 + +**保存并检查**只检查连接与认证,不发送消息。绑定或验证后可以点击 **发送测试** 检查真实投递。已保存的 Token / 密码不会回显,编辑时留空保留原值。 + +默认提前 7、3、1 天及到期当天提醒,也可开启每天一次的逾期提醒。提醒按各账号时区和时间运行,关闭网页后仍有效。 + +`.env` 中的通知凭据也可作为公共默认服务;个人配置优先。清除个人配置后,如果服务器配置了公共服务,会恢复使用公共服务。 + +## 备份与更新 + +使用 SQLite backup API 创建一致的备份: + +```bash +docker compose exec -T web python scripts/backup.py +docker compose cp web:/app/data/backups ./docker-backups +``` + +备份包含账号、卡片、历史和个人通知密钥,应放在受保护的位置。不要只复制正在使用的主数据库文件而遗漏 WAL。 + +更新代码和镜像: + +```bash +docker compose up -d --build --wait +``` + +修改 `.env` 后,需要重建容器加载新环境变量: + +```bash +docker compose up -d --force-recreate --wait web +``` + +`docker compose restart` 不会加载修改后的 `.env`。不要执行 `docker compose down -v`,它会删除数据卷。 + +## 本地开发 + +后端使用 Python / FastAPI / SQLite,前端使用原生 JavaScript、HTML 和 CSS。 + +```bash +python3 -m pip install -r requirements.txt +npm run build +npm start +``` + +本地启动读取进程环境变量;`.env` 由 Docker Compose 加载,本地开发需要自行设置所需变量。 + +验证命令: + +```bash +npm test +python3 -m unittest discover -s tests -p 'test_*.py' -q +node --check app.js +``` + +测试使用临时数据库和模拟投递边界,不需要真实 Telegram / SMTP 凭据。 + +## 使用范围 + +适合小规模单实例部署。当前包含卡片维护与提醒,不自动向运营商拨号、发短信或购买套餐,也不包含共享卡片、密码找回和管理员面板。 + +提醒失败会退避重试,最多尝试 5 次;修复凭据后当天未发送的失败任务可重新排队。外部服务已经接收消息、进程尚未写入成功记录时崩溃,恢复后可能重复一次;停机期间错过的历史提醒不会逐条补发。 diff --git a/api.js b/api.js new file mode 100644 index 0000000..63172bf --- /dev/null +++ b/api.js @@ -0,0 +1,44 @@ +export class ApiError extends Error { + constructor(message,status=0,generation) {super(message);this.status=status;this.sessionGeneration=generation;} +} +export class StaleResponse extends Error { + constructor() {super('账号已切换,忽略旧请求');this.name='StaleResponse';} +} + +export class API { + constructor(transport=globalThis.fetch.bind(globalThis)) { + this.transport=transport; + this.csrf=''; + this.generation=0; + } + clear() {this.csrf='';this.generation++;} + assertCurrent(result) {if(result._sessionGeneration!==this.generation) throw new StaleResponse();} + async request(method,path,payload) { + const generation=this.generation; + const current=()=>{if(generation!==this.generation) throw new StaleResponse();}; + const headers={Accept:'application/json'}; + if (payload !== undefined) headers['Content-Type']='application/json'; + if (!['GET','HEAD'].includes(method) && this.csrf) headers['X-CSRF-Token']=this.csrf; + let response; + try { + response=await this.transport(path,{method,headers,credentials:'same-origin',...(payload!==undefined ? {body:JSON.stringify(payload)} : {})}); + } catch { + current(); + throw new ApiError('无法连接服务器,请检查网络后重试',0,generation); + } + let result; + try {result=await response.json();} catch {current();throw new ApiError('服务器响应异常,请稍后重试',response.status,generation);} + current(); + if (!response.ok) { + const detail=result.detail; + const message=typeof detail==='string' ? detail : Array.isArray(detail) ? detail[0]?.msg?.replace(/^Value error, /,'') : ''; + throw new ApiError(message || '操作未完成,请稍后重试',response.status,generation); + } + if (path==='/api/auth/login' || path==='/api/auth/register') this.generation++; + if (result.csrfToken) this.csrf=result.csrfToken; + Object.defineProperty(result,'_sessionGeneration',{value:this.generation}); + return result; + } +} + +export const api=new API(); diff --git a/app.js b/app.js new file mode 100644 index 0000000..edb2a00 --- /dev/null +++ b/app.js @@ -0,0 +1,625 @@ +import {ACTIONS, addCycle, completeRenewal, createId, cycleText, daysUntil, dueStatus, formatDate, getTasks, parseDate, todayString, validateActivationChange} from './core.js'; +import {COUNTRIES} from './countries.js'; +import {api} from './api.js'; +import {CURRENCIES,COUNTRY_CURRENCY,normalizeAmount,sumMoney,balanceAfter,formatMoney} from './money.js'; + +const paths = { + grid:'', + sim:'', + esim:'', + calendar:'', + layers:'', + bell:'', + search:'', + chevron:'', + left:'', + down:'', + plus:'', + export:'', + more:'', + list:'', + message:'', + phone:'', + bag:'', + refresh:'', + wallet:'', + check:'', + checkCircle:'', + clock:'', + link:'', + telegram:'', + mail:'', + shield:'', + globe:'', + close:'', + edit:'', + settings:'', + archive:'', + trash:'', + menu:'', + help:'', + spark:'', +}; +const icon = name => ``; +const e = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const uid = createId; +const el = selector => document.querySelector(selector); +const NAV = [ + {id:'overview',label:'总览',icon:'grid'}, {id:'cards',label:'我的卡片',icon:'sim'}, + {id:'renewals',label:'续期计划',icon:'calendar'}, {id:'platforms',label:'关联平台',icon:'layers'}, + {id:'settings',label:'通知设置',icon:'bell'}, +]; +const colors = {Telegram:'#63a9d9',WhatsApp:'#4cac88',Google:'#5e8be3',PayPal:'#5b80b7',Apple:'#7e8593',Amazon:'#da9b43',Discord:'#7987d9',WeChat:'#65b684',AlipayHK:'#609ee2',GitHub:'#697487',Microsoft:'#6d9dce',MobiMatter:'#e6935e',My3:'#5c87df',Tello:'#56a294',Airalo:'#ce7689'}; + +let serverUser = null, authMode = 'login', isMutating = false; +let view = 'overview', filter = 'all', layout = 'grid', query = '', showArchived = false, taskFilter = 'all'; +const emptyWorkspace = () => ({version:2,cards:[],events:[],settings:{timezone:'Asia/Shanghai',time:'09:00',offsets:[7,3,1],overdue:true,telegram:false,email:false,emailAddress:'',emailVerified:false,telegramVerified:false,chatId:''},services:{telegram:{configured:false},email:{configured:false}},notifications:[]}); +let data = emptyWorkspace(); +let monthDate = parseDate(`${today().slice(0,7)}-01`), selectedDate = null; +let previousFocus = null, overlayCleanup = null, toastTimer, detailReturn = null, telegramBindingCode = ''; + +function today() {return todayString(data?.settings?.timezone || 'Asia/Shanghai');} +const user = () => serverUser; +const findCard = id => data.cards.find(card => card.id === id); +const tasks = () => getTasks(data.cards); +const nearest = card => [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate))[0]; +function savedMessage(message) {toast(message);} +const configured = channel => Boolean(data.services?.[channel]?.configured); +const verified = channel => Boolean(data.settings[channel === 'telegram' ? 'telegramVerified' : 'emailVerified']); +const channelReady = channel => configured(channel) && verified(channel) && data.settings[channel]; +const channelStatus = channel => !configured(channel) ? '服务待配置' : !verified(channel) ? channel === 'telegram' ? '待绑定' : '待验证' : data.settings[channel] ? '已启用' : '未启用'; +async function mutation(method,path,payload) { + if (isMutating) throw new Error('上一项操作正在保存,请稍候'); + isMutating = true; + try {const result = await api.request(method,path,payload);api.assertCurrent(result);if (result.workspace) data = result.workspace;return result;} + finally {isMutating = false;} +} +function reportError(error,form=null) { + if (error.name === 'StaleResponse') return; + if (error.sessionGeneration !== undefined && error.sessionGeneration !== api.generation) return; + if (error.status === 401 && serverUser) { + closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();renderAuth('登录已过期,请重新登录');return; + } + if (form?.isConnected && form.querySelector('.form-error')) formError(form,error.message);else toast(error.message,true); +} +async function refreshWorkspace() { + const result = await api.request('GET','/api/workspace'); + api.assertCurrent(result);data = result.workspace; +} + +function flag(country) { + const graphics = { + GB:'', + US:'', + HK:'', + JP:'', + CN:'', + DE:'', + SG:'', + }; + return ``; +} +const provider = (card, mini = false) => `${e(card.mark)}`; +const typeTag = card => `${icon(card.type === 'SIM' ? 'sim' : 'esim')}${e(card.type)}`; +const statusBadge = date => {const status = dueStatus(date,today());return `${status.label}`;}; +const platformBadge = registration => `${e(registration.name[0])}${e(registration.name)}`; +const moneyTags = values => values.length ? values.map(value=>`${e(formatMoney(value))}`).join('') : '未记录余额'; +const cardCurrency = card => card?.balances?.[0]?.currency || COUNTRY_CURRENCY[card?.country] || 'USD'; + +function renderMoneySummary() { + const balances = sumMoney(data.cards.filter(card=>!card.archived).flatMap(card=>card.balances || [])); + const upcoming = tasks().filter(task=>daysUntil(task.rule.dueDate,today())<=30); + const costs = sumMoney(upcoming.map(task=>task.rule.cost)); + const unpriced = upcoming.filter(task=>!task.rule.cost).length; + return `

${icon('wallet')}在用卡片余额

${moneyTags(balances)}

按币种分别汇总

${icon('calendar')}近 30 天待办金额

${costs.length ? moneyTags(costs) : '暂无已填写金额的待办'}

含逾期${unpriced ? ` · ${unpriced} 项未填写金额` : ''}

`; +} + +function renderAuth(message = '') { + const registering = authMode === 'register'; + el('#app').innerHTML = `
${icon('sim')}续卡SIMKEEP

每个号码,
都有它的下一次。

从第一次开通,到每一次续期。
把卡片、账号与提醒,放在自己的空间里。

每一条连接,都有迹可循

${registering ? '开启你的卡片空间' : '回来照顾你的连接'}

${registering ? '每个人都有独立的卡片、平台与续期记录。' : '登录后,接着管理你的 SIM 与 eSIM。'}

${registering ? field('auth-name','你的名字',``) : ''}${field('auth-email','邮箱',``)}${field('auth-password','密码',``)}
续卡 SIMKEEP · SIM 与 eSIM 管理
`; +} +async function bootstrap() { + const generation = api.generation; + el('#app').innerHTML = `
${icon('sim')}

正在打开你的卡片空间…

`; + try { + const session = await api.request('GET','/api/auth/me'); + const result = await api.request('GET','/api/workspace'); + api.assertCurrent(session);api.assertCurrent(result); + serverUser = session.user;data = result.workspace; + monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp(); + } catch (error) { + if (generation !== api.generation) return; + if (error.name === 'StaleResponse') return; + if (error.status === 401) renderAuth(); + else el('#app').innerHTML = `
${icon('sim')}

${e(error.message)}

`; + } +} + +function renderApp(keepSearch = false) { + if (!serverUser) {renderAuth();return;} + const pos = keepSearch ? el('#global-search')?.selectionStart : null; + const count = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length; + el('#app').innerHTML = `
+ +
+
+
${renderContent()}
+
`; + if (keepSearch) {el('#global-search').focus();if (pos !== null) el('#global-search').setSelectionRange(pos,pos);} +} + +function heading(title, subtitle, actions = true) { + return `

${title}

${subtitle}

${actions ? `
` : ''}
`; +} +function footer() {return `
续卡 SIMKEEP · 每一条连接,都有迹可循${icon('shield')}资料已保存到你的账号
`;} +function renderContent() { + if (view === 'settings') return renderSettings() + footer(); + if (view === 'renewals') return renderRenewals() + footer(); + if (view === 'platforms') return renderPlatforms() + footer(); + return heading(view === 'overview' ? '卡片总览' : '我的卡片', view === 'overview' ? '管理每一条连接,让重要的号码一直在线。' : '号码、开通时间与平台记录,放在一起更清楚。') + + (view === 'overview' ? renderStats() + renderMoneySummary() + renderUpcoming() : '') + + `
${renderCollection()}
` + footer(); +} +function renderStats() { + const active = data.cards.filter(card => !card.archived); + const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length; + const registrations = active.reduce((sum,card) => sum + card.platforms.length,0); + return `
+
${icon('sim')}卡片总数
${active.length.toString().padStart(2,'0')}张

${active.filter(card => card.type === 'eSIM').length} eSIM / ${active.filter(card => card.type === 'SIM').length} 实体 SIM

+
${icon('calendar')}近期续期
${soon.toString().padStart(2,'0')}项

未来 7 天内需要照顾的连接

+
${icon('layers')}平台绑定
${registrations.toString().padStart(2,'0')}条

重要账号与号码一一对应

+
${icon('bell')}提醒渠道
${['telegram','email'].filter(channelReady).length.toString().padStart(2,'0')}个

Telegram 和邮箱自动提醒

+
`; +} +function renderUpcoming() { + const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7); + return `
${icon(soon.length ? 'calendar' : 'checkCircle')}

${soon.length ? `${soon.length} 项续期,记得安排` : '本周的连接都已照顾好'}

${soon.length ? '做一个小动作,让号码继续在线' : '没有临近到期的任务,安心使用'}

${soon.slice(0,3).map(({card,rule}) => ``).join('')}
`; +} +function matches(card) { + const haystack = [card.name,card.provider,card.phone,COUNTRIES[card.country],...card.platforms.flatMap(platform => [platform.name,platform.account])].join(' ').toLowerCase(); + return haystack.includes(query.trim().toLowerCase()); +} +function visibleCards() {return data.cards.filter(card => (showArchived ? card.archived : !card.archived) && (filter === 'all' || card.type === filter) && matches(card));} +function renderCollection() { + const cards = visibleCards(); + const all = data.cards.filter(card => showArchived ? card.archived : !card.archived); + const count = type => all.filter(card => card.type === type).length; + return `

${showArchived ? '已归档卡片' : '我的卡片'}${cards.length}

${[['all','全部',all.length],['eSIM','eSIM',count('eSIM')],['SIM','SIM',count('SIM')]].map(([value,label,n]) => ``).join('')}
+ ${cards.length ? layout === 'grid' ? `
${cards.map(renderCard).join('')}
` : renderCardTable(cards) : empty(query ? '没有找到匹配的卡片' : showArchived ? '还没有归档的卡片' : '在这里,留下你的第一条连接', query ? '试试搜索运营商、号码尾号或已关联的平台。' : '添加 SIM 或 eSIM,设置续期周期,剩下的日期交给续卡。', !query && !showArchived)} +
${query ? `搜索“${e(query)}” · ` : ''}共 ${cards.length} 张${showArchived ? '归档' : ''}卡片
`; +} +function empty(title, description, add = false) {return `
${icon('sim')}

${title}

${description}

${add ? `` : ''}
`;} +function renderCard(card) { + const rule = nearest(card); + return `
${provider(card)}

${e(card.provider)}

${typeTag(card)}
+
${flag(card.country)}${e(card.phone || '未记录号码')}
${icon('globe')}${e(COUNTRIES[card.country] || COUNTRIES.OTHER)}${icon('clock')}${formatDate(card.activatedAt)} 开通
+
${icon(rule ? ACTIONS[rule.action].icon : 'calendar')}
${rule ? ACTIONS[rule.action].label : '未设置续期'}${rule ? cycleText(rule) : '添加一条规则'}${rule?.cost ? ` · ${e(formatMoney(rule.cost))}` : ''}
${rule ? formatDate(rule.dueDate) : '—'}
+
余额
${moneyTags(card.balances || [])}
+
${icon('link')}关联平台 ${card.platforms.length ? `(${card.platforms.length})` : ''}${card.rules.length > 1 ? `${card.rules.length} 条续期规则` : ''}
${card.platforms.length ? card.platforms.slice(0,3).map(platformBadge).join('') + (card.platforms.length > 3 ? `+${card.platforms.length-3}` : '') : '尚未关联平台'}
+ +
`; +} +function renderCardTable(cards) { + return `
${cards.map(card => {const rule = nearest(card);return ``;}).join('')}
卡片名称类型 / 地区开通日期余额下次操作 / 金额下次续期
${typeTag(card)} ${e(COUNTRIES[card.country])}${formatDate(card.activatedAt)}
${moneyTags(card.balances || [])}
${rule ? ACTIONS[rule.action].label : '尚未设置'}${rule?.cost ? `
${e(formatMoney(rule.cost))}` : ''}
${rule ? `${formatDate(rule.dueDate)}
${statusBadge(rule.dueDate)}` : '—'}
`; +} + +function renderCalendar() { + const year = monthDate.getUTCFullYear(), month = monthDate.getUTCMonth(); + const start = new Date(Date.UTC(year,month,1)); + start.setUTCDate(1 - ((start.getUTCDay() + 6) % 7)); + const dates = Array.from({length:42},(_,i) => {const date = new Date(start);date.setUTCDate(start.getUTCDate()+i);return date;}); + const upcoming = selectedDate ? tasks().filter(task => task.rule.dueDate === selectedDate) : tasks().filter(task => task.rule.dueDate.slice(0,7) === `${year}-${String(month+1).padStart(2,'0')}`).slice(0,3); + return `

${icon('calendar')}续期日历

${year} 年 ${month+1} 月
${['一','二','三','四','五','六','日'].map(day => `${day}`).join('')}${dates.map(date => {const value = date.toISOString().slice(0,10);const n = tasks().filter(task => task.rule.dueDate === value).length;return ``;}).join('')}
今天有续期任务

${selectedDate ? `${formatDate(selectedDate,true)}的续期` : '本月接下来的安排'}

${upcoming.length ? upcoming.map(({card,rule}) => ``).join('') : '

这段时间没有续期任务。
可以安心使用你的号码。

'}
`; +} +function renderReminderPanel() { + return `
${icon('bell')}

重要的续期,有人记得

提前收到提醒,按照步骤完成操作。
让每一张卡保持连接。

${icon('telegram')}Telegram Bot${e(channelStatus('telegram'))}
邮箱提醒${e(channelStatus('email'))}
`; +} +function renderRenewals() { + const all = tasks().filter(task => matches(task.card)); + const shown = all.filter(task => taskFilter === 'all' || (taskFilter === 'soon' ? daysUntil(task.rule.dueDate,today()) <= 7 : daysUntil(task.rule.dueDate,today()) < 0)); + return heading('续期计划','什么时候,做什么。每一次保号与续订都有清楚的安排。') + `

待完成的续期${shown.length}

${[['all','全部计划'],['soon','近 7 天'],['overdue','已逾期']].map(([key,label]) => ``).join('')}
${shown.length ? shown.map(({card,rule}) => `
${Number(rule.dueDate.slice(5,7))}月${Number(rule.dueDate.slice(8))}
${provider(card,true)}

${e(card.name)} / ${e(card.provider)}

${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}${cycleText(rule)}${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}${rule.cost ? `${e(formatMoney(rule.cost))}` : '金额未填写'}

${statusBadge(rule.dueDate)}
`).join('') : empty('这段时间没有待办','完成操作后,新的续期日期会自动出现在这里。')}
`; +} +function renderPlatforms() { + const registrations = data.cards.filter(card => !card.archived && matches(card)).flatMap(card => card.platforms.map(platform => ({card,platform}))).filter(({platform,card}) => !query || `${platform.name} ${platform.account} ${card.name} ${card.phone} ${card.provider}`.toLowerCase().includes(query.toLowerCase())); + return heading('关联平台','记住每个号码注册过什么,让账号归属有迹可循。',false) + `

平台记录${registrations.length}

${registrations.length ? `
${registrations.map(({card,platform}) => ``).join('')}
平台关联卡片账号标识用途
${e(platform.name[0])}${e(platform.name)}${e(platform.account || '未填写')}${e(platform.purpose || '未填写')}
` : empty('还没有匹配的平台记录','在卡片详情中关联平台,也可以点击上方按钮添加。')}`; +} + +function previewMessage(task = tasks()[0]) { + if (!task) return '

添加卡片和续期规则后,就可以预览提醒内容。

'; + const {card,rule} = task; + const balance = (card.balances || []).find(item=>rule.cost && item.currency===rule.cost.currency); + return `
${icon('bell')}续卡提醒 · ${e(card.name)}
卡片${e(card.provider)} / ${e(card.type)}
号码${card.phone.startsWith('+') ? `尾号 ${e(card.phone.replace(/\s/g,'').slice(-4))}` : '数据卡 / 未填写号码'}
到期时间${formatDate(rule.dueDate)}
续期操作${ACTIONS[rule.action].label}
续期金额${e(formatMoney(rule.cost))}
${balance ? `
同币种余额${e(formatMoney(balance))}
` : ''}
${e(rule.instructions || '完成后在续卡记录操作,自动计算下一次日期。')}
`; +} +function renderSettings() { + const s = data.settings; + const zones = [['Asia/Shanghai','中国标准时间 · UTC+8'],['Asia/Hong_Kong','香港时间 · UTC+8'],['Europe/London','伦敦时间'],['America/New_York','纽约时间'],['Asia/Tokyo','东京时间 · UTC+9']]; + if (!zones.some(([zone]) => zone === s.timezone)) zones.push([s.timezone,s.timezone]); + return heading('通知设置','配置自己的通知服务,再绑定接收账号,按时收到续期提醒。',false) + `

${icon('telegram')}通知渠道

Bot Token 和 SMTP 按账号单独保存。可以同时启用两种提醒。

+
${icon('telegram')}
Telegram Bot

在 Bot 中点击开始,绑定你的个人账号

${serviceSetup('telegram')}
${e(channelStatus('telegram'))}${s.telegramVerified ? `` : ''}
+
邮箱提醒

验证接收邮箱,确保提醒送到你手中

${serviceSetup('email')}
修改邮箱后先保存设置,再发送验证码。
${s.emailVerified ? '邮箱已验证' : '邮箱待验证'}${s.emailVerified ? `` : ''}
+

${icon('clock')}提醒时间

按你所在的时区安排提醒。到期当天总会提醒一次。


提前几天提醒
${[...new Set([14,7,3,1,...s.offsets])].sort((a,b) => b-a).map(offset => ``).join('')}${icon('check')}到期当天
+
${renderNotificationLog()}`; +} +function serviceSetup(channel) { + const service = data.services[channel]; + const name = channel === 'telegram' ? 'Telegram' : '邮件'; + const explanation = service.source === 'account' ? '已保存本账号的配置' : service.source === 'server' ? '当前使用公共服务,可配置自己的服务' : channel === 'telegram' ? '先填写 BotFather 提供的 Bot Token' : '先填写发信服务器与 SMTP 授权码'; + return `

${icon(service.configured ? 'checkCircle' : 'settings')}${e(explanation)}

`; +} +function notificationConfigForm(channel) { + const service = data.services[channel], telegram = channel === 'telegram'; + const secretNote = saved => saved ? '已保存,留空保留原值' : '填写后只保存在服务器,不会回显'; + const body = telegram ? `
${icon('telegram')}使用你在 BotFather 创建的 Bot。保存后,回到通知设置绑定 Telegram。
${field('telegram-token','Bot Token',`${secretNote(service.tokenSaved)}`)}

同一个 Bot 只能用于一个账号。更换 Token 后需要重新绑定。请让续卡独占这个 Bot 的更新读取。

` : `
${icon('mail')}配置发送邮件的服务。接收提醒的邮箱仍在通知设置中填写。
${field('smtp-host','SMTP 服务器',``,true)}${field('smtp-port','端口',``)}${field('smtp-mode','安全方式',``)}${field('smtp-from','发件邮箱',`填写邮件服务允许使用的发件地址。`,true)}${field('smtp-user','SMTP 用户名',`服务无需登录认证时留空。`,true)}${field('smtp-password','SMTP 密码 / 授权码',`${secretNote(service.passwordSaved)}`,true)}
`; + openOverlay(telegram ? '配置 Telegram' : '配置邮件','仅用于当前账号。Token 和密码保存后不会回显。',`
${body}

“保存并检查”会测试连接与认证,不发送消息。

${service.source === 'account' ? `` : ''}
`,``,'config-modal'); +} +function renderNotificationLog() { + const labels = {pending:'等待发送',processing:'发送中',sent:'已发送',failed:'发送失败',cancelled:'已取消'}; + return `

发送记录${data.notifications.length}

${data.notifications.length ? `
${data.notifications.map(log => ``).join('')}
提醒渠道安排时间状态尝试次数说明
${log.channel === 'telegram' ? 'Telegram' : '邮箱'}${e(new Intl.DateTimeFormat('zh-CN',{timeZone:data.settings.timezone,dateStyle:'short',timeStyle:'short'}).format(new Date(log.created_at*1000)))}${labels[log.status] || e(log.status)}${log.attempts}${e(log.error || (log.status === 'sent' ? '已提交到通知服务' : '—'))}
` : `
${icon('bell')}还没有发送记录。续期提醒安排发送后,会出现在这里。
`}
`; +} +function emailCodeForm() { + openOverlay('验证接收邮箱',`验证码已发送至 ${e(data.settings.emailAddress)}`,`
${field('email-code','6 位验证码',``)}
`,``,'small'); +} +async function bindTelegram() { + const binding = await mutation('POST','/api/notifications/telegram/binding'); + telegramBindingCode = binding.code; + openOverlay('绑定 Telegram','打开 Bot,点击“开始”,然后回到这里检查绑定。',`
${icon('telegram')}打开 Telegram Bot ${icon('link')}

如果没有自动发送,手动向 Bot 发送:

绑定指令 10 分钟内有效,仅绑定你的私人聊天。

`,``,'small'); +} + +function toast(message, error = false) { + clearTimeout(toastTimer); + el('#toast-root').innerHTML = `
${icon(error ? 'help' : 'checkCircle')}${e(message)}
`; + toastTimer = setTimeout(() => {el('#toast-root').innerHTML = '';},4200); +} +function closeOverlay(restoreFocus = true) { + if (overlayCleanup) {overlayCleanup();overlayCleanup = null;} + el('#overlay-root').innerHTML = ''; + document.body.classList.remove('body-overlay'); + el('#app').inert = false; + if (restoreFocus && previousFocus?.isConnected) previousFocus.focus(); +} +function openOverlay(title, subtitle, body, footerHtml = '', kind = '') { + closeOverlay(false); + previousFocus = document.activeElement; + el('#overlay-root').innerHTML = `
`; + document.body.classList.add('body-overlay'); + el('#app').inert = true; + const dialog = el('[role="dialog"]'); + const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]')); + requestAnimationFrame(() => {(dialog.querySelector('input:not([type="hidden"]),select') || dialog.querySelector('button') || dialog).focus();}); + const onKey = event => { + if (event.key === 'Escape') {event.preventDefault();closeOverlay();} + if (event.key === 'Tab') { + const elements = focusables(), first = elements[0], last = elements.at(-1); + if (!first) {event.preventDefault();dialog.focus();} + else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {event.preventDefault();last.focus();} + else if (!event.shiftKey && document.activeElement === last) {event.preventDefault();first.focus();} + } + }; + document.addEventListener('keydown',onKey); + overlayCleanup = () => document.removeEventListener('keydown',onKey); +} +const formError = (form, message) => {const target = form.querySelector('.form-error');target.textContent = message;target.hidden = false;target.scrollIntoView({block:'nearest'});}; +const option = (value, label, selected) => ``; +const field = (id, label, input, wide = false) => `
${input}
`; +const currencyOptions = selected => Object.entries(CURRENCIES).map(([code,item])=>option(code,`${code} · ${item.label}`,selected)).join(''); +function amountField(id,name,label,value='') { + return field(id,label,``); +} +function costFields(prefix,cost=null,currency='USD',label='预计续期金额') { + return `${amountField(prefix+'-amount','costAmount',label,cost?.amount || '')}${field(prefix+'-currency','金额币种',``)}`; +} +function readCost(form) { + const value = form.elements.costAmount.value.trim(); + if (!value) return null; + const currency = form.elements.costCurrency.value; + return {currency,amount:normalizeAmount(value,currency)}; +} +function balanceRow(balance={currency:'USD',amount:''}) { + const id = 'balance-'+uid(); + return `
${field(id+'-currency','余额币种',``)}${amountField(id+'-amount','balanceAmount','余额金额',balance.amount)}
`; +} +function readBalances(form) { + const balances = [...form.querySelectorAll('.balance-row')].flatMap(row => { + const amount = row.querySelector('[name="balanceAmount"]').value.trim(); + if (!amount) return []; + const currency = row.querySelector('[name="balanceCurrency"]').value; + return [{currency,amount:normalizeAmount(amount,currency)}]; + }); + if (new Set(balances.map(item=>item.currency)).size !== balances.length) throw new Error('同一张卡的每个币种只能记录一条余额'); + return balances; +} + +function ruleFields(rule = {}, minDate = today(), currency = 'USD') { + return `
+ ${field('rule-action','续期操作*',``)} + ${field('rule-anchor','日期计算方式',``)} + ${field('rule-interval','续期周期*',``)} + ${field('rule-unit','周期单位',``)} + ${field('rule-due','下次到期日期*',``,true)} + ${costFields('rule-cost',rule.cost,currency)} + ${field('rule-instructions','操作步骤',``,true)} +
`; +} +function cardForm(card = null) { + const editing = Boolean(card); + const c = card || {name:'',provider:'',phone:'',type:'eSIM',country:'HK',activatedAt:today(),notes:'',plan:''}; + openOverlay(editing ? '编辑卡片' : '添加一张新卡片',editing ? '修改卡片档案;续期规则可在卡片详情中单独调整。' : '记下这条连接,再为它安排第一次续期。',`
+ ${field('card-name','卡片名称*',``,true)} + ${field('card-provider','运营商*',``)} + ${field('card-type','卡片类型',``)} + ${field('card-phone','手机号码',``)} + ${field('card-country','国家或地区',``)} + ${field('card-activation','开通日期*',``)} + ${field('card-plan','套餐说明',``)} +

卡片余额

${(c.balances?.length ? c.balances : [{currency:cardCurrency(c),amount:''}]).map(balanceRow).join('')}

同一张卡可记录多个币种。留空表示未记录,填 0 表示余额为零。

${editing ? '' : `

第一次续期之后可添加更多规则

${ruleFields({},today(),cardCurrency(c))}`} +
${field('card-notes','备注',``)} +
`,``); + const form = el('#card-form'); + if (!editing) { + form.addEventListener('input',event => { + if (!['activatedAt','interval','unit'].includes(event.target.name)) return; + try {form.elements.dueDate.min = form.elements.activatedAt.value;form.elements.dueDate.value = addCycle(form.elements.activatedAt.value,Number(form.elements.interval.value),form.elements.unit.value);} catch { /* Let form validation explain an unfinished value on submit. */ } + }); + } +} +function readRule(form, existing = null) { + const f = form.elements; + const interval = Number(f.interval.value), unit = f.unit.value; + if (!Number.isInteger(interval) || interval < 1 || interval > (unit === 'months' ? 120 : 3650)) throw new Error(`周期必须为 1–${unit === 'months' ? 120 : 3650} 的整数`); + const dueDate = f.dueDate.value; + parseDate(dueDate); + return {action:f.action.value,interval,unit,dueDate,anchor:f.anchor.value,instructions:f.instructions.value.trim(),cost:readCost(form),...(existing ? {version:existing.version} : {})}; +} +function ruleForm(cardId, ruleId = '') { + const card = findCard(cardId), rule = card.rules.find(item => item.id === ruleId); + detailReturn = cardId; + openOverlay(rule ? '编辑续期规则' : '添加续期规则',`${e(card.name)} · 每条规则独立计算下一次日期`, `
${ruleFields(rule,card.activatedAt,cardCurrency(card))}
按完成日期顺延适合保号;固定周期适合套餐账单。修改当前到期日期会建立新的周期基准。
`,``); +} +function completionForm(cardId, ruleId) { + const card = findCard(cardId), rule = card?.rules.find(item => item.id === ruleId); + if (!rule || card.archived) return; + const minDate = rule.lastCompletedAt && rule.lastCompletedAt > card.activatedAt ? rule.lastCompletedAt : card.activatedAt; + const balanceAction = rule.action === 'topup' ? 'credit' : 'deduct'; + openOverlay('记录续期完成','确认已经完成操作,记录花费并自动安排下一次。',`
${provider(card)}

${e(card.name)}

${ACTIONS[rule.action].label} · ${cycleText(rule)} · ${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}

${e(rule.instructions || '请确认续期操作成功后,再记录完成。')}
${field('completed-date','实际完成日期*',``,true)}${costFields('completed-cost',rule.cost,cardCurrency(card),'本次实际金额')}${field('completed-note','操作备注',``,true)}

${balanceAction === 'credit' ? '未勾选时只记录费用,余额保持当前记录。' : '默认扣除本次金额。取消勾选可只记录费用。'}

${balanceAction === 'credit' ? '入账后余额' : '扣除后余额'}只记录费用
完成后,下次续期日期—
`,``,'small'); + const form = el('#completion-form'); + const updateDate = () => {try {el('#next-date-preview').textContent = formatDate(completeRenewal(card,ruleId,form.elements.completedAt.value,today()).card.rules.find(item => item.id === ruleId).dueDate);} catch (error) {el('#next-date-preview').textContent = error.message;}}; + const updateMoney = () => { + const target = el('#balance-after-preview'); + try { + target.textContent = form.elements.updateBalance.checked ? formatMoney(balanceAfter(card.balances || [],readCost(form),balanceAction)) : '只记录费用'; + target.classList.remove('money-error'); + } catch (error) {target.textContent = error.message;target.classList.add('money-error');} + }; + form.dataset.requestId = uid();form.elements.completedAt.addEventListener('input',updateDate);form.addEventListener('input',updateMoney);form.addEventListener('change',updateMoney);updateDate();updateMoney(); +} + +function showDetail(cardId) { + const card = findCard(cardId); + if (!card) return; + const history = data.events.filter(item => item.cardId === cardId).sort((a,b) => b.completedAt.localeCompare(a.completedAt)); + openOverlay('卡片详情','开通、平台与续期,一张卡的完整记录。',`
${provider(card)}

${e(card.name)}

${e(card.provider)} ${typeTag(card)} ${card.archived ? '已归档' : ''}

号码
${e(card.phone)}
国家或地区
${flag(card.country)} ${e(COUNTRIES[card.country])}
开通日期
${formatDate(card.activatedAt)}
套餐说明
${e(card.plan || '未填写')}
卡片余额
${moneyTags(card.balances || [])}

续期规则 ${card.rules.length}

${card.rules.length ? [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate)).map(rule => `
${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}${cycleText(rule)}
预计续期金额 ${e(formatMoney(rule.cost))}

${e(rule.instructions || '尚未填写操作说明')}

下次 ${formatDate(rule.dueDate)}
${rule.anchor === 'scheduled' ? '固定周期' : '按完成日顺延'}
${!card.archived ? `` : ''}
`).join('') : '

添加一条规则,开始记录这张卡的续期计划。

'}

关联平台 ${card.platforms.length}

${card.platforms.length ? card.platforms.map(platform => `
${e(platform.name[0])}
${e(platform.name)}

${e([platform.account,platform.purpose].filter(Boolean).join(' · ') || '未填写账号标识')}

`).join('') : '

记录这张卡注册的平台,下次找账号更方便。

'}

续期历史 ${history.length}

${history.length ? history.map(item => `
${formatDate(item.completedAt)} · ${ACTIONS[item.action]?.label || '续期操作'}

下次续期 ${formatDate(item.newDueDate)}
本次金额 ${e(formatMoney(item.cost))}${item.balanceAfter ? `
${item.balanceAction === 'credit' ? '计入余额后' : '扣除后余额'} ${e(formatMoney(item.balanceAfter))}` : ''}${item.note ? `
${e(item.note)}` : ''}

`).join('') : '

还没有操作记录。完成一次续期后,历史会留在这里。

'}
${card.notes ? `

备注

${e(card.notes)}

` : ''}`,``,'drawer'); +} +function platformForm(cardId = '') { + const card = findCard(cardId); + detailReturn = cardId || null; + const available = data.cards.filter(item => !item.archived); + if (!available.length && !card) {toast('请先添加一张卡片,再关联平台');return;} + openOverlay('添加平台关联',card ? `${e(card.name)} · 记住这个号码注册过什么` : '选择卡片,记录平台和账号归属。',`
${!card ? field('platform-card','关联卡片',``,true) : ''}${field('platform-name','平台名称*',`${Object.keys(colors).map(name => ``,true)}${field('platform-account','账号标识',``,true)}${field('platform-purpose','用途',``,true)}
`,``,'small'); +} +function removePlatform(cardId, platformId) { + const card = findCard(cardId), platform = card?.platforms.find(item => item.id === platformId); + if (!platform) return; + const wasDetail = Boolean(el('.drawer')); + openOverlay('移除平台关联',`${e(card.name)} · ${e(platform.name)}`,`

移除这条平台记录?这只会更新续卡中的记录,不会注销该平台的实际账号。

`,``,'small'); +} +function showAccount() { + openOverlay('我的账号','卡片、规则与通知设置保存在你的个人空间。',``,``,'small'); +} +function showHelp() { + openOverlay('认识续卡','把号码与维护它的那些小事,放在同一个地方。',`

记录卡片

添加运营商、号码、卡片类型和开通日期。数据 eSIM 可以不填写号码。

安排续期

一张卡可以有多条规则,分别管理短信保号、通话、充值、重置和购买套餐。实际完成后点击“记录完成”,自动更新所选规则的下次日期。

找到平台注册记录

打开卡片详情添加平台与账号标识;使用顶部搜索可以按平台找到关联卡片。

接收自动提醒

在通知设置绑定 Telegram 或验证邮箱,开启渠道并保存提醒时间。归档卡片会停止安排提醒,历史资料仍然保留。

`,``); +} + +function showReminders() { + const upcoming = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7); + openOverlay('近期提醒',`${upcoming.length} 项操作需要在未来 7 天内完成。`,upcoming.length ? `
${upcoming.map(({card,rule}) => `
${provider(card,true)}

${e(card.name)}

${formatDate(rule.dueDate)} · ${ACTIONS[rule.action].label}

`).join('')}
` : empty('本周没有待完成的续期','有新的临近到期任务时,会出现在这里。'),``); +} +function exportData() { + const blob = new Blob([JSON.stringify({exportedAt:new Date().toISOString(),user:user(),...data},null,2)],{type:'application/json'}); + const url = URL.createObjectURL(blob), link = document.createElement('a'); + link.href = url;link.download = `simkeep-${serverUser.id}-${today()}.json`;link.click(); + setTimeout(() => URL.revokeObjectURL(url),1000);toast('已导出卡片、平台与续期记录'); +} +function navigate(next) { + closeOverlay(false);view = next;query = '';showArchived = false;filter = 'all'; + location.hash = next; + renderApp();window.scrollTo({top:0,behavior:'instant'}); +} +function showCardActions(cardId) { + const card = findCard(cardId); + openOverlay(e(card.name),'选择要进行的操作。',`
`,'','small'); +} + +document.addEventListener('click',async event => { + if (event.target.classList.contains('overlay-backdrop')) {closeOverlay();return;} + if (event.target.classList.contains('menu-scrim')) {event.target.remove();el('.sidebar').classList.remove('open');return;} + const button = event.target.closest('[data-action],[data-nav],[data-filter],[data-layout],[data-month],[data-date],[data-task-filter]'); + if (!button || button.disabled) return; + if (button.dataset.nav) {event.preventDefault();navigate(button.dataset.nav);return;} + if (button.dataset.filter) {filter = button.dataset.filter;renderApp();return;} + if (button.dataset.layout) {layout = button.dataset.layout;renderApp();return;} + if (button.dataset.taskFilter) {taskFilter = button.dataset.taskFilter;renderApp();return;} + if (button.dataset.month) {monthDate.setUTCMonth(monthDate.getUTCMonth()+Number(button.dataset.month));selectedDate = null;renderApp();return;} + if (button.dataset.date) {selectedDate = selectedDate === button.dataset.date ? null : button.dataset.date;renderApp();return;} + const {action,card:cardId,rule:ruleId,platform:platformId} = button.dataset; + if (button.type !== 'submit') event.preventDefault(); + const busy = ['refresh','retry-bootstrap','confirm-remove-platform','confirm-archive','archive-card','logout','bind-telegram','check-telegram','verify-email','test-email','test-telegram','confirm-unbind-telegram','confirm-clear-service-config'].includes(action); + if (busy) button.disabled = true; + try { + switch (action) { + case 'auth-login':authMode = 'login';renderAuth();break; + case 'auth-register':authMode = 'register';renderAuth();break; + case 'retry-bootstrap':await bootstrap();break; + case 'refresh':await refreshWorkspace();closeOverlay(false);renderApp();toast('资料已刷新');break; + case 'close':closeOverlay();break; + case 'add-card':cardForm();break; + case 'add-balance-row': { + const container = el('#balance-fields'); + const used = [...container.querySelectorAll('[name="balanceCurrency"]')].map(input=>input.value); + const currency = Object.keys(CURRENCIES).find(code=>!used.includes(code)); + if (!currency) throw new Error('所有支持的币种均已添加'); + container.insertAdjacentHTML('beforeend',balanceRow({currency,amount:''}));container.lastElementChild.querySelector('select').focus();break; + } + case 'remove-balance-row':button.closest('.balance-row').remove();break; + case 'edit-card':cardForm(findCard(cardId));break; + case 'detail':showDetail(cardId);break; + case 'card-actions':showCardActions(cardId);break; + case 'complete':completionForm(cardId,ruleId);break; + case 'add-rule':ruleForm(cardId);break; + case 'edit-rule':ruleForm(cardId,ruleId);break; + case 'add-platform':platformForm(cardId);break; + case 'choose-platform':platformForm();break; + case 'remove-platform':removePlatform(cardId,platformId);break; + case 'confirm-remove-platform': { + const card = findCard(cardId); + await mutation('DELETE',`/api/cards/${cardId}/platforms/${platformId}?version=${card.version}`); + closeOverlay(false);renderApp();savedMessage('已移除平台关联');if (button.dataset.return) showDetail(button.dataset.return);break; + } + case 'archive-card': { + const card = findCard(cardId); + if (card.archived) { + await mutation('POST',`/api/cards/${cardId}/archive`,{archived:false,version:card.version});closeOverlay(false);renderApp();savedMessage('卡片已恢复,续期计划重新显示'); + } else openOverlay('归档这张卡片?',e(card.name),'

归档后保留卡片资料、平台关联和操作历史,停止安排续期提醒。你可以随时在“已归档”中恢复。

',``,'small'); + break; + } + case 'confirm-archive':await mutation('POST',`/api/cards/${cardId}/archive`,{archived:true,version:findCard(cardId).version});closeOverlay(false);renderApp();savedMessage('卡片已归档');break; + case 'toggle-archived':showArchived = !showArchived;renderApp();break; + case 'calendar-today':monthDate = parseDate(`${today().slice(0,7)}-01`);selectedDate = null;renderApp();break; + case 'account':showAccount();break; + case 'logout': { + await mutation('POST','/api/auth/logout');closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();view = 'overview';query = '';filter = 'all';showArchived = false;selectedDate = null;location.hash = '';renderAuth();toast('已退出登录');break; + } + case 'bind-telegram':await bindTelegram();break; + case 'configure-service':notificationConfigForm(button.dataset.channel);break; + case 'clear-service-config': { + const channel = button.dataset.channel; + openOverlay(`清除${channel === 'telegram' ? ' Telegram' : '邮件'}配置?`,'只影响当前账号。',`

清除后将停止使用你保存的凭据。${channel === 'telegram' ? 'Telegram 绑定也会解除。' : '已验证的接收邮箱会保留。'}如果服务器提供公共通知服务,将恢复使用公共服务。

`,``,'small');break; + } + case 'confirm-clear-service-config':await mutation('DELETE',`/api/notifications/config/${button.dataset.channel}`);closeOverlay(false);renderApp();toast('本账号的通知服务配置已清除');break; + case 'check-telegram': { + const checked = await mutation('POST','/api/notifications/telegram/binding/check',{code:telegramBindingCode}); + if (checked.bindingStatus === 'bound') {closeOverlay(false);renderApp();toast('Telegram 已绑定,请开启提醒并保存设置');} + else if (checked.bindingStatus === 'expired') toast('绑定指令已失效,请关闭后重新绑定',true); + else toast('还未收到绑定指令,请在 Bot 中点击“开始”后再检查',true); + break; + } + case 'unbind-telegram':openOverlay('解除 Telegram 绑定?','解除后将停止向这个 Telegram 账号发送提醒。','

你的卡片和操作记录仍保留,可以随时重新绑定。

','','small');break; + case 'confirm-unbind-telegram':await mutation('DELETE','/api/notifications/telegram/binding');closeOverlay(false);renderApp();toast('Telegram 绑定已解除');break; + case 'verify-email': { + if (el('#notify-email').value.trim().toLowerCase() !== data.settings.emailAddress) throw new Error('请先保存新的接收邮箱,再发送验证码'); + await mutation('POST','/api/notifications/email/verify');emailCodeForm();break; + } + case 'test-email':case 'test-telegram':await mutation('POST',`/api/notifications/test/${action === 'test-email' ? 'email' : 'telegram'}`);toast('测试提醒已发送,请检查接收消息');break; + case 'preview':openOverlay('续期提醒预览','这是通知内容预览。可在通知设置中发送测试提醒。',previewMessage(),'','small');break; + case 'reminders':showReminders();break; + case 'help':showHelp();break; + case 'export':exportData();break; + case 'menu':el('.sidebar').classList.add('open');el('#app').insertAdjacentHTML('beforeend','');break; + } + } catch (error) {reportError(error);} + finally {if (busy && button.isConnected) button.disabled = false;} +}); +document.addEventListener('input',event => { + if (event.target.id !== 'global-search') return; + query = event.target.value; + if (view === 'settings') view = 'cards'; + renderApp(true); +}); +document.addEventListener('keydown',event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !el('[role="dialog"]')) {event.preventDefault();el('#global-search')?.focus();} + if (event.key === 'Escape' && el('.sidebar.open')) {el('.sidebar').classList.remove('open');el('.menu-scrim')?.remove();} +}); +document.addEventListener('submit',async event => { + const form = event.target; + if (!['auth-form','card-form','rule-form','completion-form','platform-form','settings-form','email-code-form','notification-config-form'].includes(form.id)) return; + event.preventDefault(); + if (form.dataset.pending === 'true') return; + form.dataset.pending = 'true'; + const submits = [...form.querySelectorAll('button[type="submit"]'),...document.querySelectorAll(`button[type="submit"][form="${form.id}"]`)]; + for (const button of submits) button.disabled = true; + const errorBox = form.querySelector('.form-error');if (errorBox) errorBox.hidden = true; + try { + const f = form.elements; + if (form.id === 'auth-form') { + const payload = {email:f.email.value.trim(),password:f.password.value}; + if (authMode === 'register') payload.name = f.name.value.trim(); + const result = await mutation('POST',`/api/auth/${authMode}`,payload); + serverUser = result.user;data = result.workspace;query = '';filter = 'all';showArchived = false;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();toast(authMode === 'register' ? '账号已创建,添加你的第一张卡片吧' : '已登录你的个人空间'); + } else if (form.id === 'card-form') { + const existing = findCard(form.dataset.card); + const activatedAt = f.activatedAt.value; + validateActivationChange(existing,data.events,activatedAt,today()); + if (!f.name.value.trim() || !f.provider.value.trim()) throw new Error('请填写卡片名称和运营商'); + const card = {name:f.name.value.trim(),provider:f.provider.value.trim(),type:f.type.value,country:f.country.value,phone:f.phone.value.trim(),activatedAt,plan:f.plan.value.trim(),notes:f.notes.value.trim(),balances:readBalances(form)}; + if (!existing) {const rule = readRule(form);if (rule.dueDate < activatedAt) throw new Error('首次到期日期不能早于开通日期');card.rules = [rule];} + else card.version = Number(form.dataset.version); + await mutation(existing ? 'PUT' : 'POST',existing ? `/api/cards/${existing.id}` : '/api/cards',card); + closeOverlay(false);renderApp();savedMessage(existing ? '卡片资料已更新' : '卡片已添加,第一次续期已安排'); + } else if (form.id === 'rule-form') { + const card = findCard(form.dataset.card), existing = card.rules.find(item => item.id === form.dataset.rule), rule = readRule(form,existing); + if (rule.dueDate < card.activatedAt) throw new Error('到期日期不能早于卡片开通日期'); + if (existing?.lastCompletedAt && rule.dueDate <= existing.lastCompletedAt) throw new Error('新的到期日期必须晚于上次完成日期'); + if (existing) rule.version = Number(form.dataset.version); + await mutation(existing ? 'PUT' : 'POST',existing ? `/api/rules/${existing.id}` : `/api/cards/${card.id}/rules`,rule); + renderApp();savedMessage('续期规则已保存');showDetail(card.id); + } else if (form.id === 'completion-form') { + const card = findCard(form.dataset.card); + // The local calculation is a preview; the API owns the date and the atomic history write. + completeRenewal(card,form.dataset.rule,f.completedAt.value,today()); + const cost = readCost(form), balanceAction = f.updateBalance.checked ? form.dataset.balanceAction : 'none'; + if (balanceAction !== 'none') balanceAfter(card.balances || [],cost,balanceAction); + await mutation('POST',`/api/rules/${form.dataset.rule}/completions`,{completedAt:f.completedAt.value,note:f.note.value.trim(),requestId:form.dataset.requestId,version:Number(form.dataset.version),cost,balanceAction,cardVersion:Number(form.dataset.cardVersion)}); + const next = findCard(card.id).rules.find(rule => rule.id === form.dataset.rule).dueDate; + closeOverlay(false);renderApp();savedMessage(`已记录完成,下次续期 ${formatDate(next)}`); + } else if (form.id === 'platform-form') { + const card = findCard(form.dataset.card || f.cardId.value); + const name = f.name.value.trim();if (!name) throw new Error('请填写平台名称'); + await mutation('POST',`/api/cards/${card.id}/platforms`,{name,account:f.account.value.trim(),purpose:f.purpose.value.trim(),version:card.version}); + closeOverlay(false);renderApp();savedMessage('平台关联已保存');if (detailReturn) showDetail(card.id); + } else if (form.id === 'settings-form') { + const emailAddress = f.emailAddress.value.trim(); + if (f.email.checked && !emailAddress) throw new Error('启用邮箱提醒时,请填写接收邮箱'); + await mutation('PUT','/api/settings',{telegram:f.telegram.checked,email:f.email.checked,emailAddress,timezone:f.timezone.value,time:f.time.value,offsets:Array.from(form.querySelectorAll('input[name="offset"]:checked')).map(input => Number(input.value)),overdue:f.overdue.checked}); + selectedDate = null;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();savedMessage('通知设置已保存'); + } else if (form.id === 'notification-config-form') { + const channel = form.dataset.channel; + const payload = channel === 'telegram' ? {token:f.token.value.trim()} : {host:f.host.value.trim(),port:Number(f.port.value),mode:f.mode.value,user:f.user.value.trim(),password:f.password.value,from:f.sender.value.trim()}; + const feedback = form.querySelector('.config-feedback');feedback.hidden = true; + await mutation('PUT',`/api/notifications/config/${channel}`,payload); + renderApp(); + const secret = channel === 'telegram' ? f.token : f.password; + secret.value = '';secret.required = false;secret.placeholder = '已保存,留空保留原值'; + if (event.submitter?.value === 'check') { + feedback.textContent = '配置已保存,正在检查连接…';feedback.hidden = false; + try { + const checked = await mutation('POST',`/api/notifications/config/${channel}/check`); + feedback.textContent = channel === 'telegram' ? `配置已保存,已连接 @${checked.botUsername}。下一步绑定 Telegram。` : '配置已保存,SMTP 连接与认证通过。下一步验证接收邮箱。'; + feedback.scrollIntoView({block:'nearest'}); + } catch (error) {feedback.hidden = true;error.message = `配置已保存。${error.message}`;throw error;} + } else {closeOverlay(false);toast(channel === 'telegram' ? 'Telegram 配置已保存,可以绑定账号了' : '邮件配置已保存,可以验证接收邮箱了');} + } else if (form.id === 'email-code-form') { + await mutation('POST','/api/notifications/email/confirm',{code:f.code.value});closeOverlay(false);renderApp();toast('邮箱已验证,请开启提醒并保存设置'); + } + } catch (error) {reportError(error,form);} + finally {form.dataset.pending = 'false';for (const button of submits) if (button.isConnected) button.disabled = false;} +}); + +if (NAV.some(item => `#${item.id}` === location.hash)) view = location.hash.slice(1); +window.addEventListener('hashchange',() => {const next = location.hash.slice(1);if (NAV.some(item => item.id === next) && next !== view) {view = next;closeOverlay(false);query = '';renderApp();}}); +bootstrap(); diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..ac23e98 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +name: simkeep +services: + web: + image: ${SIMKEEP_IMAGE:-simkeep:local} + build: . + init: true + restart: unless-stopped + stop_grace_period: 30s + env_file: ${SIMKEEP_ENV_FILE:-.env} + environment: + SIMKEEP_DB: /app/data/simkeep.db + SIMKEEP_STATIC_DIR: /app/dist + ports: + - "${SIMKEEP_BIND_ADDRESS:-0.0.0.0}:${SIMKEEP_PORT:-5180}:5180" + volumes: + - simkeep-data:/app/data + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5180/api/health',timeout=5)"] + interval: 10s + timeout: 6s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + simkeep-data: diff --git a/core.js b/core.js new file mode 100644 index 0000000..40041b1 --- /dev/null +++ b/core.js @@ -0,0 +1,132 @@ +const DAY = 86_400_000; + +export function createId(cryptoSource = globalThis.crypto) { + if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID(); + const bytes = cryptoSource.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2,'0')).join(''); + return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; +} + +export function parseDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) throw new Error('请输入有效日期'); + const [year, month, day] = value.split('-').map(Number); + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(0, 0, 0, 0); + if (year < 1900 || year > 9999 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw new Error('请输入有效日期'); + return date; +} + +const isoDate = date => date.toISOString().slice(0, 10); + +export function todayString(timeZone = 'Asia/Shanghai', now = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', {timeZone, year:'numeric', month:'2-digit', day:'2-digit'}).formatToParts(now); + const get = type => parts.find(part => part.type === type).value; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +export function addDays(value, count) { + if (!Number.isInteger(count)) throw new Error('周期必须为整数'); + const date = parseDate(value); + date.setUTCDate(date.getUTCDate() + count); + const result = isoDate(date); + parseDate(result); + return result; +} + +export function addCycle(value, interval, unit) { + if (!Number.isInteger(interval) || interval < 1) throw new Error('周期必须为正整数'); + if (unit === 'days') return addDays(value, interval); + if (unit !== 'months') throw new Error('请选择有效周期单位'); + const date = parseDate(value); + const day = date.getUTCDate(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + interval); + const end = new Date(date); + end.setUTCMonth(end.getUTCMonth() + 1, 0); + date.setUTCDate(Math.min(day, end.getUTCDate())); + const result = isoDate(date); + parseDate(result); + return result; +} + +export function daysUntil(value, today = todayString()) { + return Math.round((parseDate(value) - parseDate(today)) / DAY); +} + +export function nextAfterCompletion(rule, completedAt) { + parseDate(completedAt); + parseDate(rule.dueDate); + if (rule.anchor !== 'scheduled') return addCycle(completedAt, rule.interval, rule.unit); + const origin = rule.anchorDate || rule.dueDate; + const after = completedAt > rule.dueDate ? completedAt : rule.dueDate; + // Calculate every occurrence from the original anchor, preserving month-end intent. + for (let cycle = 1; cycle < 50_000; cycle++) { + const candidate = addCycle(origin, rule.interval * cycle, rule.unit); + if (candidate > after) return candidate; + } + throw new Error('周期跨度过大,请调整规则'); +} + +export function completeRenewal(card, ruleId, completedAt, today, note = '') { + parseDate(completedAt); + parseDate(today); + const rule = card.rules.find(item => item.id === ruleId); + if (!rule) throw new Error('找不到这条续期规则'); + if (card.archived) throw new Error('请先恢复这张卡片,再记录续期'); + if (completedAt > today) throw new Error('不能记录未来的完成日期'); + if (completedAt < card.activatedAt) throw new Error('完成日期不能早于开通日期'); + if (rule.lastCompletedAt && completedAt < rule.lastCompletedAt) throw new Error('完成日期不能早于上次完成日期'); + if (rule.lastCompletedAt === completedAt) throw new Error('这一天已经记录过该操作'); + const nextDueDate = nextAfterCompletion(rule, completedAt); + const updated = {...card, rules:card.rules.map(item => item.id === ruleId ? {...item, dueDate:nextDueDate, lastCompletedAt:completedAt} : {...item})}; + const event = { + id:createId(), + cardId:card.id, ruleId, action:rule.action, completedAt, + oldDueDate:rule.dueDate, newDueDate:nextDueDate, note, + }; + return {card:updated, event}; +} + +export function getTasks(cards) { + return cards.filter(card => !card.archived) + .flatMap(card => card.rules.map(rule => ({card, rule}))) + .sort((a, b) => a.rule.dueDate.localeCompare(b.rule.dueDate) || a.card.name.localeCompare(b.card.name)); +} + +export function validateActivationChange(card, events, activatedAt, today) { + parseDate(activatedAt); + parseDate(today); + if (activatedAt > today) throw new Error('开通日期不能是未来的日期'); + if (!card) return; + const conflictsWithRule = card.rules.some(rule => rule.dueDate < activatedAt || (rule.lastCompletedAt && rule.lastCompletedAt < activatedAt)); + const conflictsWithHistory = events.some(event => event.cardId === card.id && event.completedAt < activatedAt); + if (conflictsWithRule || conflictsWithHistory) throw new Error('开通日期不能晚于已有的续期记录或计划日期'); +} + +export const ACTIONS = { + sms:{label:'发送短信', icon:'message'}, + call:{label:'拨打电话', icon:'phone'}, + purchase:{label:'购买套餐', icon:'bag'}, + reset:{label:'重置有效期', icon:'refresh'}, + topup:{label:'账户充值', icon:'wallet'}, + custom:{label:'自定义操作', icon:'check'}, +}; + +export function dueStatus(date, today = todayString()) { + const days = daysUntil(date, today); + if (days < 0) return {label:`逾期 ${-days} 天`, tone:'danger', days}; + if (days === 0) return {label:'今天到期', tone:'danger', days}; + if (days <= 7) return {label:`${days} 天后到期`, tone:'warning', days}; + return {label:`${days} 天后到期`, tone:'success', days}; +} + +export const cycleText = rule => `每 ${rule.interval} ${rule.unit === 'months' ? '个月' : '天'}`; + +export function formatDate(value, short = false) { + if (!value) return '尚未设置'; + const date = parseDate(value); + return short ? `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日` : value.replaceAll('-', '.'); +} diff --git a/countries.js b/countries.js new file mode 100644 index 0000000..8afb552 --- /dev/null +++ b/countries.js @@ -0,0 +1 @@ +export const COUNTRIES={GB:'英国',US:'美国',HK:'中国香港',JP:'日本',CN:'中国大陆',DE:'德国',SG:'新加坡',OTHER:'其他地区'}; diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..51d3017 --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,76 @@ +# Docker Compose 部署 + +需要 Docker Engine 与 Docker Compose 插件。镜像构建会自动打包前端,不需要宿主机安装 Node.js、npm 或 Python。应用使用单个 Uvicorn worker,后台续期提醒与 Web 同时启动。 + +## 在新服务器部署 + +进入包含 Dockerfile 和 compose.yml 的 simkeep 目录,准备配置: + +```bash +test -f .env || cp .env.example .env +chmod 600 .env +``` + +编辑 `.env`,将 `SIMKEEP_PUBLIC_URL` 改为实际访问地址,例如 `http://服务器IP:5180`。`SIMKEEP_PORT` 控制宿主机端口,默认 5180;改端口时同步修改公网地址。保持 HTTP 时使用 `SIMKEEP_SECURE_COOKIES=0`。每个用户登录网页的“通知设置”,点击“配置 Telegram / 配置邮件”保存自己的凭据;不需要修改 `.env` 或重启。`.env` 中的通知凭据仅作为可选公共默认服务。 + +```bash +docker compose up -d --build --wait +docker compose ps +``` + +访问 `http://服务器IP:5180/`,首次使用创建自己的账号。服务器防火墙需要允许选定的 TCP 端口。 + +默认 Compose 项目名为 `simkeep`,持久化卷为 `simkeep_simkeep-data`,数据库位于容器 `/app/data/simkeep.db`。保持项目名即可复用同一个卷。容器重启、镜像更新及普通 `docker compose down` 都会保留卷;`docker compose down -v` 会删除数据卷。 + +## 更新、配置与日志 + +更新代码并重新构建镜像: + +```bash +docker compose up -d --build --wait +``` + +修改 `.env` 后重建容器加载新配置: + +```bash +docker compose up -d --force-recreate --wait web +``` + +仅执行 `docker compose restart` 不会加载修改后的环境配置。日常查看或重启: + +```bash +docker compose logs --tail=100 web +docker compose restart web +docker compose ps +``` + +容器设置 `unless-stopped` 自动重启,Docker 服务需要随服务器启动。镜像以 UID / GID 10001 运行,根文件系统只读,数据卷和临时目录可写。健康检查访问 `/api/health`,日志最多保留三份,每份 10 MB。`.env`、数据库、备份与测试文件均不进入镜像或公开静态目录。 + +## 备份 + +使用 SQLite backup API 获取一致的数据库备份: + +```bash +docker compose exec web python scripts/backup.py +docker compose cp web:/app/data/backups ./docker-backups +``` + +备份保存在卷中的 `/app/data/backups/`,第二条命令复制到宿主机。复制出的备份包含账号、卡片、余额、历史、渠道设置以及用户在网页保存的 Bot Token / SMTP 密码,应存放在受保护的位置。迁移到另一台服务器时同时保留 `.env`,其中的可选公共服务密钥不在数据库备份中。个人配置会随数据卷或数据库备份一起恢复;旧版备份没有配置表时,应用启动自动补建。 + +## 恢复或导入已有 SQLite + +先创建当前库备份,再停止应用。将选定的一致备份命名为当前项目目录下的 `restore.db`。不要直接复制运行中的主数据库而遗漏 WAL。 + +```bash +docker compose stop web +docker compose run --rm --no-deps -T --entrypoint python web scripts/restore.py --replace < restore.db +docker compose up -d --wait +``` + +恢复容器以应用用户读取标准输入,不需要 root 或修改宿主机备份权限。它检查数据库完整性与 SIMKEEP 表结构,临时生成恢复库后替换;未传 --replace 时会拒绝覆盖现有库。恢复容器只执行数据库操作,不启动提醒进程。使用 `docker compose -p 其他项目名` 时,以上所有命令也须带相同项目名。 + +## 从其他运行方式迁移 + +先用 SQLite backup API 创建一致性备份,保留原运行配置,然后停止旧进程。使用上面的“恢复或导入已有 SQLite”步骤导入 Docker 命名卷,再启动 web 容器。确认数据与通知设置后停用旧服务的自动启动;Docker 启动后的新数据以容器数据卷为准。 + +同一份 Telegram Bot 配置只运行一个提醒进程。不要同时运行旧服务和 Docker 容器,以免重复消费 Telegram 更新或重复安排提醒。 diff --git a/docs/images/card-detail.png b/docs/images/card-detail.png new file mode 100644 index 0000000..e8eead2 Binary files /dev/null and b/docs/images/card-detail.png differ diff --git a/docs/images/cover.svg b/docs/images/cover.svg new file mode 100644 index 0000000..db1cde8 --- /dev/null +++ b/docs/images/cover.svg @@ -0,0 +1,25 @@ + + SIMKEEP · 续卡 + 记住每张卡的下一次续期。SIM 与 eSIM 管理,余额、续期与提醒。 + + + + + + + + + SIMKEEP 续卡 + 记住每张卡的下一次续期。 + SIM / eSIM 管理 · 多币种余额 · Telegram 与邮件提醒 + + + + + + + 记录开通 + 安排续期 + 准时提醒 + + diff --git a/docs/images/mobile.png b/docs/images/mobile.png new file mode 100644 index 0000000..8eea9ee Binary files /dev/null and b/docs/images/mobile.png differ diff --git a/docs/images/notifications.png b/docs/images/notifications.png new file mode 100644 index 0000000..76745d2 Binary files /dev/null and b/docs/images/notifications.png differ diff --git a/docs/images/overview.png b/docs/images/overview.png new file mode 100644 index 0000000..2db379c Binary files /dev/null and b/docs/images/overview.png differ diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..cb714ee --- /dev/null +++ b/favicon.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..7f88d24 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + 续卡 SIMKEEP · 卡片管理 + + + + +
+
+
+ + + diff --git a/money.js b/money.js new file mode 100644 index 0000000..8ad32f3 --- /dev/null +++ b/money.js @@ -0,0 +1,47 @@ +export const CURRENCIES={ + CNY:{label:'人民币',places:2},USD:{label:'美元',places:2},HKD:{label:'港币',places:2}, + GBP:{label:'英镑',places:2},EUR:{label:'欧元',places:2},JPY:{label:'日元',places:0}, + TWD:{label:'新台币',places:2},SGD:{label:'新加坡元',places:2},AUD:{label:'澳元',places:2}, + CAD:{label:'加元',places:2},CHF:{label:'瑞士法郎',places:2},NZD:{label:'新西兰元',places:2}, + KRW:{label:'韩元',places:0},THB:{label:'泰铢',places:2},MYR:{label:'马来西亚林吉特',places:2}, + AED:{label:'阿联酋迪拉姆',places:2},SAR:{label:'沙特里亚尔',places:2},IDR:{label:'印尼盾',places:2}, + INR:{label:'印度卢比',places:2},PHP:{label:'菲律宾比索',places:2},VND:{label:'越南盾',places:0}, + KWD:{label:'科威特第纳尔',places:3},BHD:{label:'巴林第纳尔',places:3}, +}; +export const COUNTRY_CURRENCY={GB:'GBP',US:'USD',HK:'HKD',JP:'JPY',CN:'CNY',DE:'EUR',SG:'SGD',OTHER:'USD'}; + +export function normalizeAmount(value,currency) { + const places=CURRENCIES[currency]?.places; + if(places===undefined) throw new Error('请选择支持的币种'); + if(typeof value!=='string'||value.length>30||!/^\d+(?:\.\d+)?$/.test(value)) throw new Error('金额须为不小于 0 的数字'); + const [rawInteger,fraction='']=value.split('.'); + const integer=rawInteger.replace(/^0+(?=\d)/,''); + if(BigInt(integer)>999999999n||(BigInt(integer)===999999999n&&/[1-9]/.test(fraction))) throw new Error('金额不能超过 999999999'); + if(/[1-9]/.test(fraction.slice(places))) throw new Error(`${currency} 金额最多保留 ${places} 位小数`); + return integer+(places ? '.'+fraction.slice(0,places).padEnd(places,'0') : ''); +} +const units = money => BigInt(normalizeAmount(money.amount,money.currency).replace('.','')); +function fromUnits(value,currency) { + const places=CURRENCIES[currency].places; + const digits=value.toString().padStart(places+1,'0'); + return places ? digits.slice(0,-places)+'.'+digits.slice(-places) : digits; +} +export function sumMoney(values) { + const sums=new Map(); + for(const money of values.filter(Boolean)) sums.set(money.currency,(sums.get(money.currency)||0n)+units(money)); + return [...sums].sort(([a],[b])=>a.localeCompare(b)).map(([currency,value])=>({currency,amount:fromUnits(value,currency)})); +} +export function balanceAfter(balances,cost,operation) { + if(!cost||!['deduct','credit'].includes(operation)) throw new Error('更新余额时须填写本次金额和币种'); + const current=balances.find(item=>item.currency===cost.currency); + if(!current&&operation==='deduct') throw new Error(`尚未记录 ${cost.currency} 余额,请先在卡片资料中添加`); + const before=current ? units(current) : 0n; + const after=operation==='deduct' ? before-units(cost) : before+units(cost); + if(after<0n) throw new Error(`${cost.currency} 余额不足,可取消更新余额后单独记录费用`); + return {currency:cost.currency,amount:normalizeAmount(fromUnits(after,cost.currency),cost.currency)}; +} +export function formatMoney(money) { + if(!money) return '未填写'; + const [integer,fraction]=money.amount.split('.'); + return `${money.currency} ${integer.replace(/\B(?=(\d{3})+(?!\d))/g,',')}${fraction===undefined ? '' : '.'+fraction}`; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ae96d7 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "simkeep", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "python3 -m uvicorn server.main:create_app --factory --host 0.0.0.0 --port 5180 --workers 1", + "build": "python3 scripts/build.py", + "test": "node tests/core.test.js && node tests/api.test.js && node tests/money.test.js" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d8d1f18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.141.1 +uvicorn==0.53.0 +pydantic==2.12.4 +httpx==0.28.1 diff --git a/scripts/backup.py b/scripts/backup.py new file mode 100644 index 0000000..de2371b --- /dev/null +++ b/scripts/backup.py @@ -0,0 +1,17 @@ +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +root=Path(__file__).resolve().parent.parent +source=Path(os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db'))).resolve() +if not source.is_file(): + raise SystemExit('数据库尚未创建') +directory=source.parent/'backups' +directory.mkdir(mode=0o700,exist_ok=True) +target=directory/f"simkeep-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')}.db" +fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) +os.close(fd) +with sqlite3.connect(f'{source.as_uri()}?mode=ro',uri=True) as original,sqlite3.connect(target) as backup: + original.backup(backup) +print(f'备份完成:{target}') diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..572238a --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,25 @@ +"""Publish only browser assets. Server files, databases and .env are never copied.""" +import argparse +import shutil +from pathlib import Path + +ROOT=Path(__file__).resolve().parent.parent +ASSETS=('index.html','style.css','app.js','api.js','core.js','money.js','countries.js','favicon.svg') +parser=argparse.ArgumentParser() +parser.add_argument('--output',default='dist') +args=parser.parse_args() +target=(ROOT/args.output).resolve() +if target.parent!=ROOT or target.name not in ('dist','preview'): + parser.error('output must be dist or preview in this project') +for asset in ASSETS: + if not (ROOT/asset).is_file(): + raise SystemExit(f'Missing asset: {asset}') +target.mkdir(exist_ok=True) +for asset in target.iterdir(): + if asset.is_file() and asset.name not in ASSETS: + asset.unlink() +for asset in ASSETS: + temp=target/(asset+'.next') + shutil.copyfile(ROOT/asset,temp) + temp.replace(target/asset) +print(f'Published {len(ASSETS)} browser assets to {target}') diff --git a/scripts/restore.py b/scripts/restore.py new file mode 100644 index 0000000..2d07ca4 --- /dev/null +++ b/scripts/restore.py @@ -0,0 +1,60 @@ +"""Restore a consistent SIMKEEP backup from stdin while the application is stopped.""" +import argparse +import os +import shutil +import sqlite3 +import sys +import tempfile +from contextlib import closing +from pathlib import Path + +TABLES={'users','sessions','cards','events','bindings','notification_jobs','system_state'} + + +def restore_database(destination,stream,replace=False): + destination=Path(destination).resolve() + if destination.exists() and not replace: + raise ValueError('数据库已存在;先备份并停止应用,确认替换后使用 --replace') + staged=None + try: + with tempfile.TemporaryDirectory(prefix='simkeep-import-') as directory: + incoming=Path(directory)/'backup.db' + with incoming.open('wb') as target: + shutil.copyfileobj(stream,target) + if incoming.stat().st_size==0: + raise ValueError('备份为空,数据库未修改') + with closing(sqlite3.connect(f'{incoming.as_uri()}?mode=ro',uri=True)) as source: + if source.execute('PRAGMA integrity_check').fetchone()[0]!='ok': + raise ValueError('备份完整性检查失败,数据库未修改') + tables={row[0] for row in source.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if not TABLES.issubset(tables): + raise ValueError('请选择完整的 SIMKEEP 数据库备份,数据库未修改') + destination.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + with tempfile.NamedTemporaryFile(prefix='.simkeep-restore-',suffix='.db',dir=destination.parent,delete=False) as target: + staged=Path(target.name) + with closing(sqlite3.connect(staged)) as target: + source.backup(target) + if target.execute('PRAGMA integrity_check').fetchone()[0]!='ok': + raise ValueError('恢复文件完整性检查失败,数据库未修改') + staged.chmod(0o600) + for suffix in ('-wal','-shm'): + Path(str(destination)+suffix).unlink(missing_ok=True) + staged.replace(destination) + except sqlite3.DatabaseError: + raise ValueError('备份格式无效或已损坏,数据库未修改') from None + finally: + if staged is not None: + staged.unlink(missing_ok=True) + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--replace',action='store_true',help='明确替换现有数据库;须先备份并停止应用') + args=parser.parse_args() + root=Path(__file__).resolve().parent.parent + destination=os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db')) + try: + restore_database(destination,sys.stdin.buffer,args.replace) + except (ValueError,OSError) as error: + parser.exit(1,f'恢复失败:{error}\n') + print('数据库恢复完成') diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..831594c --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ +"""SIMKEEP application services.""" diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..6fc7369 --- /dev/null +++ b/server/auth.py @@ -0,0 +1,45 @@ +import hashlib +import hmac +import secrets +import threading +import time +from fastapi import HTTPException + + +def digest(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def hash_password(password): + salt=secrets.token_bytes(16) + key=hashlib.scrypt(password.encode(),salt=salt,n=16384,r=8,p=1,dklen=32) + return f'scrypt${salt.hex()}${key.hex()}' + + +def verify_password(password,encoded): + try: + algorithm,salt,key=encoded.split('$') + if algorithm != 'scrypt': + return False + actual=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1,dklen=32) + return hmac.compare_digest(actual,bytes.fromhex(key)) + except (ValueError,TypeError): + return False + + +DUMMY_HASH=hash_password('never-a-real-account-password') + + +class RateLimit: + def __init__(self): + self.entries={} + self.lock=threading.Lock() + + def check(self,key,limit=20,window=60): + now=time.monotonic() + with self.lock: + self.entries={k:v for k,v in self.entries.items() if now < v[0]} + expiry,count=self.entries.get(key,(now+window,0)) + if count >= limit: + raise HTTPException(429,'操作过于频繁,请稍后重试') + self.entries[key]=(expiry,count+1) diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..f653f83 --- /dev/null +++ b/server/db.py @@ -0,0 +1,53 @@ +from __future__ import annotations +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path + +SCHEMA = ''' +CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,name TEXT NOT NULL,password_hash TEXT NOT NULL,settings TEXT NOT NULL,created_at REAL NOT NULL); +CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf TEXT NOT NULL,expires_at REAL NOT NULL); +CREATE TABLE IF NOT EXISTS cards(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,document TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS cards_user ON cards(user_id); +CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,request_id TEXT NOT NULL,document TEXT NOT NULL,UNIQUE(user_id,request_id)); +CREATE INDEX IF NOT EXISTS events_user ON events(user_id); +CREATE TABLE IF NOT EXISTS bindings(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),kind TEXT NOT NULL,destination TEXT,expires_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,created_at REAL NOT NULL); +CREATE TABLE IF NOT EXISTS notification_jobs(id TEXT PRIMARY KEY,dedupe_key TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES users(id),card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,rule_version INTEGER NOT NULL,channel TEXT NOT NULL,destination TEXT NOT NULL,payload TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,ready_at REAL NOT NULL,created_at REAL NOT NULL,sent_at REAL,error TEXT); +CREATE INDEX IF NOT EXISTS notification_ready ON notification_jobs(status,ready_at); +CREATE TABLE IF NOT EXISTS system_state(key TEXT PRIMARY KEY,value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS notification_configs(user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,channel TEXT NOT NULL CHECK(channel IN ('telegram','email')),document TEXT NOT NULL,telegram_identity TEXT UNIQUE,PRIMARY KEY(user_id,channel)); +''' + + +class Database: + def __init__(self,path): + self.path=Path(path) + self.path.parent.mkdir(parents=True,exist_ok=True) + with self.connect() as conn: + conn.execute('PRAGMA journal_mode=WAL') + conn.executescript(SCHEMA) + + @contextmanager + def connect(self,write=False): + conn=sqlite3.connect(str(self.path),timeout=15) + conn.row_factory=sqlite3.Row + conn.execute('PRAGMA foreign_keys=ON') + conn.execute('PRAGMA busy_timeout=15000') + try: + if write: + conn.execute('BEGIN IMMEDIATE') + yield conn + conn.commit() + except BaseException: + conn.rollback() + raise + finally: + conn.close() + + +def encode(value): + return json.dumps(value,ensure_ascii=False,separators=(',',':')) + + +def decode(value): + return json.loads(value) diff --git a/server/domain.py b/server/domain.py new file mode 100644 index 0000000..3c10aea --- /dev/null +++ b/server/domain.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import calendar +import re +from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +ACTIONS = {'sms':'发送短信','call':'拨打电话','purchase':'购买套餐','reset':'重置有效期','topup':'账户充值','custom':'自定义操作'} + + +def parse_date(value: str) -> date: + if not isinstance(value,str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}',value): + raise ValueError('请输入有效日期') + try: + result = date.fromisoformat(value) + if result.year < 1900: + raise ValueError() + return result + except ValueError: + raise ValueError('请输入有效日期') from None + + +def add_cycle(value: str, interval: int, unit: str) -> str: + start = parse_date(value) + if type(interval) is not int or interval < 1: + raise ValueError('周期必须为正整数') + try: + if unit == 'days': + return (start + timedelta(days=interval)).isoformat() + if unit != 'months': + raise ValueError('请选择有效周期单位') + year, month = divmod(start.year * 12 + start.month - 1 + interval,12) + month += 1 + return date(year,month,min(start.day,calendar.monthrange(year,month)[1])).isoformat() + except (OverflowError,ValueError): + raise ValueError('周期或日期超出允许范围') from None + + +def next_due(rule: dict, completed: str) -> str: + parse_date(completed) + if rule['anchor'] != 'scheduled': + return add_cycle(completed,rule['interval'],rule['unit']) + origin = rule.get('anchorDate') or rule['dueDate'] + after = max(completed,rule['dueDate']) + for cycle in range(1,50000): + candidate = add_cycle(origin,rule['interval']*cycle,rule['unit']) + if candidate > after: + return candidate + raise ValueError('周期跨度过大,请调整规则') + + +def business_today(zone: str, now: datetime | None = None) -> str: + return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo(zone)).date().isoformat() + + +def check_activation(card: dict | None, events: list[dict], activated: str, today: str): + parse_date(activated) + if activated > today: + raise ValueError('开通日期不能是未来日期') + if not card: + return + if any(r['dueDate'] < activated or (r.get('lastCompletedAt') and r['lastCompletedAt'] < activated) for r in card['rules']): + raise ValueError('开通日期不能晚于已有的续期记录或计划日期') + if any(ev['cardId'] == card['id'] and ev['completedAt'] < activated for ev in events): + raise ValueError('开通日期不能晚于已有的续期记录或计划日期') + + +def check_completion(card: dict, rule: dict, completed: str, today: str): + parse_date(completed) + if card['archived']: + raise ValueError('请先恢复卡片,再记录续期') + if completed > today: + raise ValueError('不能记录未来的完成日期') + if completed < card['activatedAt']: + raise ValueError('完成日期不能早于开通日期') + if rule.get('lastCompletedAt') and completed <= rule['lastCompletedAt']: + raise ValueError('完成日期须晚于上次完成日期,这一天可能已经记录过') diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..3013f94 --- /dev/null +++ b/server/main.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import asyncio +import hmac +import os +import secrets +import sqlite3 +import time +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +from fastapi import Depends,FastAPI,HTTPException,Request,Response +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from fastapi.staticfiles import StaticFiles +from .auth import DUMMY_HASH,RateLimit,digest,hash_password,verify_password +from .db import Database,decode,encode +from .domain import business_today,check_activation,check_completion,next_due +from .models import ArchiveInput,CardInput,Completion,PlatformInput,Register,Login,RuleInput,SettingsInput +from .money import apply_balance + +ROOT=Path(__file__).resolve().parent.parent +COLORS={'giffgaff':'#242C40','MobiMatter':'#F38B4A','Ultra Mobile':'#8562CA','3HK':'#457DE7','Tello':'#30A593','Airalo':'#D65870'} + + +def create_app(db_path=None,start_worker=True,config=None): + db=Database(db_path or os.getenv('SIMKEEP_DB',str(ROOT/'data'/'simkeep.db'))) + rate=RateLimit() + from .notifications import Config,Notifier + configuration=config or Config.from_env() + notifier=Notifier(db,configuration) + + @asynccontextmanager + async def lifespan(app): + worker=asyncio.create_task(notifier.run()) if start_worker else None + try: + yield + finally: + if worker: + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + + app=FastAPI(title='续卡 SIMKEEP',lifespan=lifespan,docs_url=None,redoc_url=None) + app.state.db=db + app.state.notifier=notifier + + @app.exception_handler(RequestValidationError) + async def invalid_request(request,exception): + errors=[{key:error[key] for key in ('loc','msg','type')} for error in exception.errors()] + return JSONResponse({'detail':errors},status_code=422) + + @app.exception_handler(ValueError) + async def invalid_value(request,exception): + return JSONResponse({'detail':str(exception)},status_code=422) + + @app.middleware('http') + async def protect_response(request,call_next): + if request.url.path.startswith('/api/'): + try: + length=int(request.headers.get('content-length','0') or 0) + except ValueError: + return JSONResponse({'detail':'请求长度无效'},status_code=400) + if length < 0: + return JSONResponse({'detail':'请求长度无效'},status_code=400) + if length > 1024*1024: + return JSONResponse({'detail':'请求内容过大'},status_code=413) + if request.method not in ('GET','HEAD','OPTIONS'): + origin=request.headers.get('origin') + allowed={str(request.base_url).rstrip('/')} + if configuration.public_url: + allowed.add(configuration.public_url.rstrip('/')) + if origin and origin.rstrip('/') not in allowed: + return JSONResponse({'detail':'请求来源不匹配'},status_code=403) + response=await call_next(request) + response.headers['X-Content-Type-Options']='nosniff' + response.headers['Referrer-Policy']='same-origin' + response.headers['X-Frame-Options']='DENY' + if request.url.path.startswith('/api/'): + response.headers['Cache-Control']='no-store' + return response + + def session(request:Request): + token=request.cookies.get('simkeep_session','') + with db.connect() as conn: + row=conn.execute('SELECT users.*,sessions.csrf,sessions.token_hash FROM sessions JOIN users ON users.id=sessions.user_id WHERE token_hash=? AND expires_at>?',(digest(token),time.time())).fetchone() + if not row: + raise HTTPException(401,'请先登录') + if request.method not in ('GET','HEAD','OPTIONS'): + supplied=request.headers.get('x-csrf-token','') + if not hmac.compare_digest(supplied,row['csrf']): + raise HTTPException(403,'请求校验失败,请刷新页面后重试') + return dict(row) + + def public_user(row): + return {'id':row['id'],'email':row['email'],'name':row['name'],'initial':row['name'][0]} + + def workspace(user_id): + with db.connect() as conn: + person=conn.execute('SELECT * FROM users WHERE id=?',(user_id,)).fetchone() + cards=[decode(row['document']) for row in conn.execute('SELECT document FROM cards WHERE user_id=? ORDER BY rowid',(user_id,))] + events=[decode(row['document']) for row in conn.execute('SELECT document FROM events WHERE user_id=? ORDER BY rowid',(user_id,))] + logs=[dict(row) for row in conn.execute('SELECT id,channel,status,attempts,created_at,sent_at,error FROM notification_jobs WHERE user_id=? ORDER BY created_at DESC LIMIT 30',(user_id,))] + return {'version':2,'cards':cards,'events':events,'settings':decode(person['settings']),'services':notifier.configs.status(user_id,notifier.config),'notifications':logs} + + def result(user_id,**extra): + return {'workspace':workspace(user_id),**extra} + + def card_for(conn,user_id,card_id): + row=conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(card_id,user_id)).fetchone() + if not row: + raise HTTPException(404,'找不到这张卡片') + return decode(row['document']) + + def rule_for(conn,user_id,rule_id): + for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(user_id,)): + card=decode(row['document']) + for rule in card['rules']: + if rule['id']==rule_id: + return card,rule + raise HTTPException(404,'找不到这条续期规则') + + def expected(actual,supplied): + if actual != supplied: + raise HTTPException(409,'资料已更新,请刷新后再操作') + + def save_card(conn,owner,card): + card['version']+=1 + conn.execute('UPDATE cards SET document=? WHERE id=? AND user_id=?',(encode(card),card['id'],owner)) + + def cancel_jobs(conn,owner,card_id,rule_id=None): + query="UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND card_id=? AND status IN ('pending','processing')" + args=[owner,card_id] + if rule_id: + query+=' AND rule_id=?' + args.append(rule_id) + conn.execute(query,args) + + def new_rule(payload,card,old=None): + rule=payload.model_dump(exclude={'version'}) + if old and 'cost' not in payload.model_fields_set: + rule['cost']=old.get('cost') + if rule['unit']=='months' and rule['interval']>120: + raise ValueError('月份周期须为 1–120') + if rule['dueDate']=1000: + raise HTTPException(422,'每个账号最多保存 1000 张卡片') + conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],owner,encode(card))) + return result(owner) + + @app.put('/api/cards/{card_id}') + def update_card(card_id:str,payload:CardInput,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card=card_for(conn,owner,card_id) + expected(card['version'],payload.version) + if payload.rules: + raise HTTPException(422,'请通过续期规则入口修改规则') + events=[decode(r['document']) for r in conn.execute('SELECT document FROM events WHERE user_id=? AND card_id=?',(owner,card_id))] + check_activation(card,events,payload.activatedAt,business_today(decode(person['settings'])['timezone'])) + card.update(payload.model_dump(exclude={'rules','version','balances'})) + if payload.balances is not None: + card['balances']=[item.model_dump() for item in payload.balances] + card.update(color=COLORS.get(payload.provider,'#5360EA'),mark='3' if payload.provider=='3HK' else payload.provider[0]) + save_card(conn,owner,card) + return result(owner) + + @app.post('/api/cards/{card_id}/archive') + def archive(card_id:str,payload:ArchiveInput,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card=card_for(conn,owner,card_id) + expected(card['version'],payload.version) + card['archived']=payload.archived + save_card(conn,owner,card) + cancel_jobs(conn,owner,card_id) + return result(owner) + + @app.post('/api/cards/{card_id}/rules',status_code=201) + def add_rule(card_id:str,payload:RuleInput,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card=card_for(conn,owner,card_id) + if len(card['rules'])>=20: + raise HTTPException(422,'每张卡片最多设置 20 条续期规则') + card['rules'].append(new_rule(payload,card)) + save_card(conn,owner,card) + return result(owner) + + @app.put('/api/rules/{rule_id}') + def update_rule(rule_id:str,payload:RuleInput,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card,rule=rule_for(conn,owner,rule_id) + expected(rule['version'],payload.version) + replacement=new_rule(payload,card,rule) + card['rules']=[replacement if r['id']==rule_id else r for r in card['rules']] + save_card(conn,owner,card) + cancel_jobs(conn,owner,card['id'],rule_id) + return result(owner) + + @app.post('/api/rules/{rule_id}/completions') + def complete(rule_id:str,payload:Completion,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card,rule=rule_for(conn,owner,rule_id) + previous=conn.execute('SELECT rule_id,document FROM events WHERE user_id=? AND request_id=?',(owner,payload.requestId)).fetchone() + if previous: + if previous['rule_id']!=rule_id: + raise HTTPException(409,'这次提交已用于另一条规则') + recorded=decode(previous['document']) + requested_cost=payload.cost.model_dump() if payload.cost else None + changed=(payload.completedAt!=recorded['completedAt'] or payload.note!=recorded.get('note','') or payload.balanceAction!=recorded.get('balanceAction','none')) + if 'cost' in payload.model_fields_set: + changed=changed or requested_cost!=recorded.get('cost') + if changed: + raise HTTPException(409,'本次续期已保存,但重试内容有变化。请刷新卡片查看已保存的记录') + else: + expected(rule['version'],payload.version) + check_completion(card,rule,payload.completedAt,business_today(decode(person['settings'])['timezone'])) + due=next_due(rule,payload.completedAt) + cost=payload.cost.model_dump() if payload.cost else None + if 'cost' not in payload.model_fields_set: + cost=rule.get('cost') + before=after=None + if payload.balanceAction!='none': + if payload.cardVersion is None: + raise ValueError('更新余额时须提交当前卡片版本,请刷新后重试') + expected(card['version'],payload.cardVersion) + if payload.balanceAction=='credit' and rule['action']!='topup': + raise ValueError('只有账户充值操作可以将金额计入余额') + if payload.balanceAction=='deduct' and rule['action']=='topup': + raise ValueError('账户充值请使用计入余额,或只记录费用') + card['balances'],before,after=apply_balance(card.get('balances',[]),cost,payload.balanceAction) + event={'id':str(uuid4()),'cardId':card['id'],'ruleId':rule_id,'action':rule['action'],'completedAt':payload.completedAt,'oldDueDate':rule['dueDate'],'newDueDate':due,'note':payload.note,'cost':cost,'balanceAction':payload.balanceAction,'balanceBefore':before,'balanceAfter':after} + rule.update(dueDate=due,lastCompletedAt=payload.completedAt,version=rule['version']+1) + save_card(conn,owner,card) + conn.execute('INSERT INTO events VALUES(?,?,?,?,?,?)',(event['id'],owner,card['id'],rule_id,payload.requestId,encode(event))) + cancel_jobs(conn,owner,card['id'],rule_id) + return result(owner) + + @app.post('/api/cards/{card_id}/platforms',status_code=201) + def add_platform(card_id:str,payload:PlatformInput,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card=card_for(conn,owner,card_id) + expected(card['version'],payload.version) + if len(card['platforms'])>=200: + raise HTTPException(422,'每张卡片最多保存 200 条平台关联') + if any(p['name'].casefold()==payload.name.casefold() and p['account'].casefold()==payload.account.casefold() for p in card['platforms']): + raise HTTPException(409,'这条平台与账号关联已经存在') + card['platforms'].append({'id':str(uuid4()),**payload.model_dump(exclude={'version'})}) + save_card(conn,owner,card) + return result(owner) + + @app.delete('/api/cards/{card_id}/platforms/{platform_id}') + def delete_platform(card_id:str,platform_id:str,version:int,person=Depends(session)): + owner=person['id'] + with db.connect(write=True) as conn: + card=card_for(conn,owner,card_id) + expected(card['version'],version) + if not any(p['id']==platform_id for p in card['platforms']): + raise HTTPException(404,'找不到这条平台关联') + card['platforms']=[p for p in card['platforms'] if p['id']!=platform_id] + save_card(conn,owner,card) + return result(owner) + + @app.put('/api/settings') + def settings(payload:SettingsInput,person=Depends(session)): + if payload.email and not payload.emailAddress: + raise HTTPException(422,'启用邮箱提醒时须填写接收邮箱') + owner=person['id'] + with db.connect(write=True) as conn: + old=decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()['settings']) + updated={**old,**payload.model_dump()} + if old['emailAddress']!=payload.emailAddress: + updated['emailVerified']=False + conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(owner,)) + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(updated),owner)) + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status IN ('pending','processing')",(owner,)) + return result(owner) + + notifier.install_routes(app,session,result,rate) + static_path=Path(os.getenv('SIMKEEP_STATIC_DIR',str(ROOT/'dist'))) + if static_path.exists(): + app.mount('/',StaticFiles(directory=static_path,html=True),name='frontend') + return app diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..6f99f9e --- /dev/null +++ b/server/models.py @@ -0,0 +1,161 @@ +import re +from typing import Literal +from zoneinfo import ZoneInfo,ZoneInfoNotFoundError +from pydantic import BaseModel,ConfigDict,Field,StrictInt,field_validator,model_validator +from .domain import parse_date +from .money import CURRENCIES,normalize_amount + + +class Model(BaseModel): + model_config=ConfigDict(extra='forbid',str_strip_whitespace=True) + + +class Money(Model): + currency:str=Field(min_length=3,max_length=3) + amount:str=Field(min_length=1,max_length=30) + + @model_validator(mode='after') + def valid_money(self): + self.currency=self.currency.upper() + self.amount=normalize_amount(self.amount,self.currency) + return self + + +class Login(Model): + model_config=ConfigDict(extra='forbid',str_strip_whitespace=False) + email:str=Field(min_length=3,max_length=120) + password:str=Field(min_length=1,max_length=128) + + @field_validator('email') + @classmethod + def email_format(cls,value): + value=value.strip() + if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+',value): + raise ValueError('请输入有效邮箱') + return value.lower() + +class Register(Login): + name:str=Field(min_length=1,max_length=45) + password:str=Field(min_length=10,max_length=128) + + @field_validator('name') + @classmethod + def valid_name(cls,value): + value=value.strip() + if not value: + raise ValueError('请填写名字') + return value + + +class RuleInput(Model): + action:Literal['sms','call','purchase','reset','topup','custom'] + interval:StrictInt=Field(ge=1,le=3650) + unit:Literal['days','months'] + anchor:Literal['completion','scheduled'] + dueDate:str + instructions:str=Field(default='',max_length=1000) + version:StrictInt|None=Field(default=None,ge=1) + cost:Money|None=None + + @field_validator('dueDate') + @classmethod + def valid_date(cls,value): + parse_date(value) + return value + + +class CardInput(Model): + name:str=Field(min_length=1,max_length=45) + provider:str=Field(min_length=1,max_length=45) + type:Literal['SIM','eSIM'] + country:Literal['GB','US','HK','JP','CN','DE','SG','OTHER'] + phone:str=Field(default='',max_length=35) + activatedAt:str + notes:str=Field(default='',max_length=1000) + plan:str=Field(default='',max_length=60) + rules:list[RuleInput]=Field(default_factory=list,max_length=20) + version:StrictInt|None=Field(default=None,ge=1) + balances:list[Money]|None=Field(default=None,max_length=len(CURRENCIES)) + + @field_validator('balances') + @classmethod + def unique_currencies(cls,value): + if value is not None and len({item.currency for item in value})!=len(value): + raise ValueError('同一张卡的每个币种只能记录一条余额') + return value + + @field_validator('activatedAt') + @classmethod + def valid_date(cls,value): + parse_date(value) + return value + + +class ArchiveInput(Model): + archived:bool + version:StrictInt=Field(ge=1) + + +class Completion(Model): + completedAt:str + note:str=Field(default='',max_length=1000) + requestId:str=Field(min_length=8,max_length=100) + version:StrictInt=Field(ge=1) + cost:Money|None=None + balanceAction:Literal['none','deduct','credit']='none' + cardVersion:StrictInt|None=Field(default=None,ge=1) + + +class PlatformInput(Model): + name:str=Field(min_length=1,max_length=45) + account:str=Field(default='',max_length=120) + purpose:str=Field(default='',max_length=80) + version:StrictInt=Field(ge=1) + + +class SettingsInput(Model): + timezone:str + time:str + offsets:list[StrictInt]=Field(max_length=12) + overdue:bool + telegram:bool + email:bool + emailAddress:str=Field(default='',max_length=120) + + @field_validator('timezone') + @classmethod + def valid_zone(cls,value): + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError,ValueError): + raise ValueError('请选择有效时区') from None + return value + + @field_validator('time') + @classmethod + def valid_time(cls,value): + if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d',value): + raise ValueError('请输入有效提醒时间') + return value + + @field_validator('offsets') + @classmethod + def valid_offsets(cls,value): + if any(v < 1 or v > 365 for v in value): + raise ValueError('提前提醒天数须为 1–365') + return sorted(set(value),reverse=True) + + @field_validator('emailAddress') + @classmethod + def valid_email(cls,value): + if value: + return Login.email_format(value) + return value + + +class EmailCode(Model): + code:str=Field(min_length=6,max_length=6) + + +class TelegramCode(Model): + code:str=Field(min_length=20,max_length=100) diff --git a/server/money.py b/server/money.py new file mode 100644 index 0000000..27b92d3 --- /dev/null +++ b/server/money.py @@ -0,0 +1,56 @@ +"""Exact, nonnegative amounts in their original currency. No FX conversion.""" +import re +from decimal import Decimal, InvalidOperation + +CURRENCIES={ + 'CNY':2,'USD':2,'HKD':2,'GBP':2,'EUR':2,'JPY':0,'TWD':2,'SGD':2, + 'AUD':2,'CAD':2,'CHF':2,'NZD':2,'KRW':0,'THB':2,'MYR':2,'AED':2, + 'SAR':2,'IDR':2,'INR':2,'PHP':2,'VND':0,'KWD':3,'BHD':3, +} +MAX_AMOUNT=Decimal('999999999') + + +def normalize_amount(value,currency): + if currency not in CURRENCIES: + raise ValueError('请选择支持的币种') + if not isinstance(value,str) or len(value)>30 or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)?',value): + raise ValueError('金额须为不小于 0 的数字') + try: + amount=Decimal(value) + if amount>MAX_AMOUNT: + raise ValueError('金额不能超过 999999999') + places=CURRENCIES[currency] + normalized=amount.quantize(Decimal(1).scaleb(-places)) + if normalized!=amount: + raise ValueError(f'{currency} 金额最多保留 {places} 位小数') + return format(normalized,f'.{places}f') + except InvalidOperation: + raise ValueError('请输入有效金额') from None + + +def format_money(money): + if money is None: + return '未填写' + amount=normalize_amount(money['amount'],money['currency']) + return f"{money['currency']} {Decimal(amount):,f}" + + +def apply_balance(balances,cost,operation): + if operation not in ('deduct','credit') or not cost: + raise ValueError('更新余额时须填写本次金额和币种') + currency=cost['currency'] + amount=Decimal(normalize_amount(cost['amount'],currency)) + current=next((item for item in balances if item['currency']==currency),None) + if operation=='deduct' and current is None: + raise ValueError(f'尚未记录 {currency} 余额,请先在卡片资料中添加') + before={'currency':currency,'amount':normalize_amount(current['amount'] if current else '0',currency)} + remaining=Decimal(before['amount'])-amount if operation=='deduct' else Decimal(before['amount'])+amount + if remaining<0: + raise ValueError(f'{currency} 余额不足,可取消更新余额后单独记录费用') + after={'currency':currency,'amount':normalize_amount(format(remaining,'f'),currency)} + updated=[dict(item) for item in balances if item['currency']!=currency] + if current: + updated=[dict(after) if item['currency']==currency else dict(item) for item in balances] + else: + updated.append(dict(after)) + return updated,before,after diff --git a/server/notification_config.py b/server/notification_config.py new file mode 100644 index 0000000..ad509ba --- /dev/null +++ b/server/notification_config.py @@ -0,0 +1,135 @@ +"""Owned notification credentials. Only status and non-secret fields leave this module.""" +import re +import sqlite3 +from dataclasses import replace + +from fastapi import HTTPException +from pydantic import ConfigDict, Field, StrictInt, field_validator +from typing import Literal + +from .auth import digest +from .db import decode, encode +from .models import Login, Model + + +class TelegramConfigInput(Model): + token: str = Field(default='', max_length=160) + + @field_validator('token') + @classmethod + def token_format(cls, value): + if value and not re.fullmatch(r'[0-9]{1,20}:[A-Za-z0-9_-]{20,120}', value): + raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token') + if not value: + return value + bot_id, secret = value.split(':', 1) + if int(bot_id) == 0: + raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token') + return f'{int(bot_id)}:{secret}' + + +class EmailConfigInput(Model): + # SMTP passwords may intentionally start or end with spaces. + model_config = ConfigDict(extra='forbid', str_strip_whitespace=False) + host: str = Field(min_length=1, max_length=253) + port: StrictInt = Field(ge=1, le=65535) + mode: Literal['starttls', 'ssl', 'plain'] + user: str = Field(default='', max_length=254) + password: str = Field(default='', max_length=1024) + sender: str = Field(alias='from', min_length=3, max_length=120) + + @field_validator('host') + @classmethod + def host_format(cls, value): + value = value.strip() + if not re.fullmatch(r'[A-Za-z0-9_.:\[\]-]+', value): + raise ValueError('SMTP 服务器填写主机名或 IP,不包含网址、路径或端口') + return value + + @field_validator('user') + @classmethod + def user_format(cls, value): + value = value.strip() + if '\r' in value or '\n' in value: + raise ValueError('SMTP 用户名不能包含换行') + return value + + @field_validator('sender') + @classmethod + def sender_format(cls, value): + return Login.email_format(value) + + +class NotificationConfigs: + def __init__(self, db): + self.db = db + + def read(self, conn, owner): + return {row['channel']: decode(row['document']) for row in conn.execute('SELECT channel,document FROM notification_configs WHERE user_id=?', (owner,))} + + def load(self, owner, defaults, conn=None): + if conn is None: + with self.db.connect() as connection: + return self.load(owner, defaults, connection) + personal = self.read(conn, owner) + fields = {} + if 'telegram' in personal: + fields.update(telegram_token=personal['telegram']['token'], telegram_username='') + if 'email' in personal: + fields.update({f'smtp_{key}': value for key, value in personal['email'].items()}) + return replace(defaults, **fields) + + def status(self, owner, defaults): + with self.db.connect() as conn: + personal = self.read(conn, owner) + config = self.load(owner, defaults, conn) + result = config.status() + for channel in ('telegram', 'email'): + result[channel]['source'] = 'account' if channel in personal else 'server' if config.configured(channel) else 'none' + result['telegram']['tokenSaved'] = bool(personal.get('telegram', {}).get('token')) + email = personal.get('email', {}) + result['email'].update({key: email.get(key, default) for key, default in [('host',''),('port',587),('mode','starttls'),('user',''),('from','')]}) + result['email']['passwordSaved'] = bool(email.get('password')) + return result + + def invalidate(self, conn, owner, channel, bot_changed=False): + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel=? AND status IN ('pending','processing','failed') AND sent_at IS NULL", (owner, channel)) + if bot_changed: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (owner,)).fetchone()[0]) + settings.update(chatId='', telegramVerified=False, telegram=False) + conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), owner)) + conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'", (owner,)) + + def save(self, owner, channel, payload, defaults): + try: + with self.db.connect(write=True) as conn: + personal = self.read(conn, owner) + old = personal.get(channel, {}) + before = self.load(owner, defaults, conn) + document = payload.model_dump(by_alias=True) + identity = None + if channel == 'telegram': + document['token'] = document['token'] or old.get('token', '') + if not document['token']: + raise HTTPException(422, '请先填写 Bot Token') + # A guessed Bot ID with a fake secret must not reserve a real Bot. + identity = digest(document['token']) + if defaults.telegram_token and document['token'] == defaults.telegram_token: + raise HTTPException(409, '这个 Bot 已用于公共服务,请为本账号使用独立的 Bot') + else: + document['password'] = (document['password'] or old.get('password', '')) if document['user'] else '' + if document['user'] and not document['password']: + raise HTTPException(422, '填写 SMTP 用户名后,请填写密码或授权码') + if document == old: + return + conn.execute('INSERT INTO notification_configs(user_id,channel,document,telegram_identity) VALUES(?,?,?,?) ON CONFLICT(user_id,channel) DO UPDATE SET document=excluded.document,telegram_identity=excluded.telegram_identity', (owner, channel, encode(document), identity)) + self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != document['token']) + except sqlite3.IntegrityError: + raise HTTPException(409, '这个 Bot 已由其他账号使用,请使用自己的 Bot') from None + + def clear(self, owner, channel, defaults): + with self.db.connect(write=True) as conn: + before = self.load(owner, defaults, conn) + conn.execute('DELETE FROM notification_configs WHERE user_id=? AND channel=?', (owner, channel)) + after = self.load(owner, defaults, conn) + self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != after.telegram_token) diff --git a/server/notifications.py b/server/notifications.py new file mode 100644 index 0000000..756de3b --- /dev/null +++ b/server/notifications.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import asyncio +import hmac +import logging +import os +import re +import secrets +import smtplib +import ssl +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from email.message import EmailMessage +from typing import Literal +from uuid import uuid4 +from zoneinfo import ZoneInfo + +import httpx +from fastapi import Depends, HTTPException + +from .auth import digest +from .db import decode, encode +from .domain import ACTIONS, parse_date +from .models import EmailCode, TelegramCode +from .money import format_money +from .notification_config import EmailConfigInput, NotificationConfigs, TelegramConfigInput + +logger = logging.getLogger('simkeep.notifications') +SCHEDULE_FIELDS = ('timezone','time','offsets','overdue') + + +@dataclass(frozen=True) +class Config: + public_url: str = '' + secure_cookies: bool = False + telegram_token: str = '' + telegram_username: str = '' + smtp_host: str = '' + smtp_port: int = 587 + smtp_user: str = '' + smtp_password: str = '' + smtp_from: str = '' + smtp_mode: str = 'starttls' + + @classmethod + def from_env(cls): + mode = os.getenv('SIMKEEP_SMTP_MODE','starttls').lower() + if mode not in ('starttls','ssl','plain'): + raise ValueError('SIMKEEP_SMTP_MODE 须为 starttls、ssl 或 plain') + return cls( + public_url=os.getenv('SIMKEEP_PUBLIC_URL','').rstrip('/'), + secure_cookies=os.getenv('SIMKEEP_SECURE_COOKIES','0')=='1', + telegram_token=os.getenv('SIMKEEP_TELEGRAM_TOKEN',''), + telegram_username=os.getenv('SIMKEEP_TELEGRAM_USERNAME','').lstrip('@'), + smtp_host=os.getenv('SIMKEEP_SMTP_HOST',''), + smtp_port=int(os.getenv('SIMKEEP_SMTP_PORT','465' if mode=='ssl' else '587')), + smtp_user=os.getenv('SIMKEEP_SMTP_USER',''), + smtp_password=os.getenv('SIMKEEP_SMTP_PASSWORD',''), + smtp_from=os.getenv('SIMKEEP_SMTP_FROM',os.getenv('SIMKEEP_SMTP_USER','')), + smtp_mode=mode, + ) + + def configured(self, channel): + if channel == 'telegram': + return bool(self.telegram_token) + return bool(self.smtp_host and self.smtp_from and (not self.smtp_user or self.smtp_password)) + + def status(self): + # Only capability flags are public; destinations and secrets stay on the server. + return {'telegram':{'configured':self.configured('telegram')},'email':{'configured':self.configured('email')}} + + +class Transport: + def __init__(self, config): + self.config = config + self.username = config.telegram_username + + async def telegram(self, method, payload): + async with httpx.AsyncClient(timeout=20) as client: + response = await client.post(f'https://api.telegram.org/bot{self.config.telegram_token}/{method}',json=payload) + if response.status_code != 200: + raise RuntimeError('Telegram 暂时无法发送') + result = response.json() + if not result.get('ok'): + raise RuntimeError('Telegram 暂时无法发送') + return result['result'] + + async def bot_username(self): + if not self.username: + self.username = (await self.telegram('getMe',{}))['username'] + if not re.fullmatch(r'[A-Za-z0-9_]{5,32}', self.username): + raise RuntimeError('Bot 用户名无效') + return self.username + + async def updates(self, offset): + return await self.telegram('getUpdates',{'offset':offset,'timeout':0,'allowed_updates':['message']}) + + def smtp(self, destination=None, text=''): + context = ssl.create_default_context() + if self.config.smtp_mode == 'ssl': + connection = smtplib.SMTP_SSL(self.config.smtp_host,self.config.smtp_port,timeout=20,context=context) + else: + connection = smtplib.SMTP(self.config.smtp_host,self.config.smtp_port,timeout=20) + with connection: + connection.ehlo_or_helo_if_needed() + if self.config.smtp_mode == 'starttls': + connection.starttls(context=context) + connection.ehlo() + if self.config.smtp_user: + connection.login(self.config.smtp_user,self.config.smtp_password) + if destination is None: + return + message = EmailMessage() + message['Subject'] = '续卡 SIMKEEP · 续期提醒' if '验证码' not in text else '续卡 SIMKEEP · 验证邮箱' + message['From'] = self.config.smtp_from + message['To'] = destination + message.set_content(text) + refused = connection.send_message(message) + if refused: + raise RuntimeError('邮箱服务暂时无法发送') + + async def send(self, channel, destination, text): + if channel == 'telegram': + await self.telegram('sendMessage',{'chat_id':destination,'text':text[:4000],'disable_web_page_preview':True}) + else: + await asyncio.to_thread(self.smtp,destination,text) + + +def destination(settings, channel): + if channel == 'telegram': + return settings.get('chatId','') if settings.get('telegramVerified') else '' + return settings.get('emailAddress','') if settings.get('emailVerified') else '' + + +class Notifier: + def __init__(self, db, config, transport=None): + self.db = db + self.config = config + self.transport = transport or Transport(config) + self.configs = NotificationConfigs(db) + + def transport_for(self, owner, config=None): + config = config or self.configs.load(owner, self.config) + return self.transport if config == self.config else Transport(config) + + def recover(self): + with self.db.connect(write=True) as conn: + conn.execute("UPDATE notification_jobs SET status='pending',error='发送中断,等待重试' WHERE status='processing'") + + def enqueue(self, now=None): + now = now or datetime.now(timezone.utc) + timestamp = now.timestamp() + with self.db.connect(write=True) as conn: + for person in conn.execute('SELECT id,settings FROM users').fetchall(): + config = self.configs.load(person['id'], self.config, conn) + settings = decode(person['settings']) + local = now.astimezone(ZoneInfo(settings['timezone'])) + if local.strftime('%H:%M') < settings['time']: + continue + day = local.date() + for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(person['id'],)).fetchall(): + card = decode(row['document']) + if card['archived']: + continue + for rule in card['rules']: + remaining = (parse_date(rule['dueDate'])-day).days + if remaining not in [0,*settings['offsets']] and not (remaining < 0 and settings['overdue']): + continue + payload = {'dueDate':rule['dueDate'],'day':day.isoformat(),'remaining':remaining,'schedule':{k:settings[k] for k in SCHEDULE_FIELDS}} + for channel in ('telegram','email'): + target = destination(settings,channel) + if not settings[channel] or not target or not config.configured(channel): + continue + key = ':'.join((person['id'],card['id'],rule['id'],str(rule['version']),channel,day.isoformat())) + values = (str(uuid4()),key,person['id'],card['id'],rule['id'],rule['version'],channel,target,encode(payload),timestamp,timestamp) + conn.execute('INSERT OR IGNORE INTO notification_jobs(id,dedupe_key,user_id,card_id,rule_id,rule_version,channel,destination,payload,ready_at,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',values) + # Restore only unsent, cancelled jobs. Sent keys remain final even after settings change. + conn.execute("UPDATE notification_jobs SET status='pending',attempts=0,error=NULL,destination=?,payload=?,ready_at=? WHERE dedupe_key=? AND status='cancelled' AND sent_at IS NULL",(target,encode(payload),timestamp,key)) + + def valid(self, conn, job, now): + person = conn.execute('SELECT settings FROM users WHERE id=?',(job['user_id'],)).fetchone() + row = conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(job['card_id'],job['user_id'])).fetchone() + if not person or not row: + return None + settings, card, payload = decode(person[0]), decode(row[0]), decode(job['payload']) + rule = next((r for r in card['rules'] if r['id']==job['rule_id']),None) + if card['archived'] or not rule or rule['version']!=job['rule_version'] or rule['dueDate']!=payload['dueDate']: + return None + if not settings[job['channel']] or destination(settings,job['channel'])!=job['destination']: + return None + if {k:settings[k] for k in SCHEDULE_FIELDS}!=payload['schedule']: + return None + if now.astimezone(ZoneInfo(settings['timezone'])).date().isoformat()!=payload['day']: + return None + return card, rule, payload + + def message(self, card, rule, payload): + remaining = payload['remaining'] + timing = f'还有 {remaining} 天' if remaining > 0 else '今天到期' if remaining == 0 else f'已逾期 {-remaining} 天' + action = ACTIONS[rule['action']] + phone = f"号码尾号 {re.sub(r'\s','',card['phone'])[-4:]}" if card['phone'].startswith('+') else '数据卡 / 未填写号码' + cost = rule.get('cost') + balance = next((item for item in card.get('balances',[]) if cost and item['currency']==cost['currency']),None) + money = f"续期金额:{format_money(cost)}\n" + if balance is not None: + money += f"同币种余额:{format_money(balance)}\n" + return f"续卡 SIMKEEP · 续期提醒\n\n{card['name']} · {card['provider']}\n{phone}\n{action} · {timing}\n到期日期:{rule['dueDate']}\n{money}操作说明:{rule['instructions'] or '请按运营商的要求完成续期'}\n\n完成操作后,请在续卡记录完成,更新下次续期日期。\n{self.config.public_url}/#renewals" + + async def deliver(self, now=None): + now = now or datetime.now(timezone.utc) + timestamp = now.timestamp() + with self.db.connect(write=True) as conn: + # Expire stale dates even when their channel is currently unavailable. + for person in conn.execute('SELECT id,settings FROM users').fetchall(): + local_day = now.astimezone(ZoneInfo(decode(person['settings'])['timezone'])).date().isoformat() + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status='pending' AND json_extract(payload,'$.day')<>?",(person['id'],local_day)) + configurations = {} + pending = [] + # Apply availability before the batch limit, including per-account services. + for row in conn.execute("SELECT * FROM notification_jobs WHERE status='pending' AND ready_at<=? ORDER BY ready_at", (timestamp,)): + owner = row['user_id'] + if owner not in configurations: + configurations[owner] = self.configs.load(owner, self.config, conn) + if configurations[owner].configured(row['channel']): + pending.append(dict(row)) + if len(pending) == 50: + break + for job in pending: + with self.db.connect(write=True) as conn: + config = self.configs.load(job['user_id'], self.config, conn) + if not config.configured(job['channel']): + continue + current = conn.execute("SELECT * FROM notification_jobs WHERE id=? AND status='pending'",(job['id'],)).fetchone() + if not current: + continue + valid = self.valid(conn,current,now) + if not valid: + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE id=?",(job['id'],)) + continue + attempts = current['attempts']+1 + conn.execute("UPDATE notification_jobs SET status='processing',attempts=? WHERE id=?",(attempts,job['id'])) + try: + await self.transport_for(job['user_id'],config).send(job['channel'],job['destination'],self.message(*valid)) + except Exception: + # Raw transport exceptions can contain credentials. Persist only a safe, actionable message. + error = '发送失败,请检查服务配置或接收地址' if attempts>=5 else '发送失败,稍后自动重试' + with self.db.connect(write=True) as conn: + conn.execute("UPDATE notification_jobs SET status=?,error=?,ready_at=? WHERE id=? AND status='processing'",('failed' if attempts>=5 else 'pending',error,timestamp+60*2**(attempts-1),job['id'])) + else: + with self.db.connect(write=True) as conn: + # An edit during the network call cannot recall an accepted message; record the actual outcome. + conn.execute("UPDATE notification_jobs SET status='sent',sent_at=?,error=NULL WHERE id=?",(timestamp,job['id'])) + + def process_update(self, update, user_id=None, token=None): + message = update.get('message',{}) + chat = message.get('chat',{}) + match = re.fullmatch(r'/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)',message.get('text','').strip()) + if chat.get('type')!='private' or not isinstance(chat.get('id'),int) or not match: + return False + chat_id = str(chat['id']) + with self.db.connect(write=True) as conn: + binding = conn.execute("SELECT * FROM bindings WHERE token_hash=? AND kind='telegram' AND destination IS NULL AND expires_at>?",(digest(match[1]),time.time())).fetchone() + if not binding: + return False + expected = token if token is not None else self.configs.load(user_id, self.config, conn).telegram_token if user_id else self.config.telegram_token + current = self.configs.load(binding['user_id'], self.config, conn) + if (user_id is not None and binding['user_id'] != user_id) or current.telegram_token != expected: + return False + for row in conn.execute('SELECT id,settings FROM users'): + existing = decode(row['settings']) + same_bot = self.configs.load(row['id'],self.config,conn).telegram_token == current.telegram_token + if same_bot and row['id']!=binding['user_id'] and existing.get('telegramVerified') and existing.get('chatId')==chat_id: + return False + row = conn.execute('SELECT settings FROM users WHERE id=?',(binding['user_id'],)).fetchone() + settings = decode(row[0]) + settings.update(chatId=chat_id,telegramVerified=True) + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),binding['user_id'])) + conn.execute('UPDATE bindings SET destination=? WHERE token_hash=?',(chat_id,binding['token_hash'])) + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(binding['user_id'],)) + return True + + async def poll(self): + with self.db.connect() as conn: + people = [row['id'] for row in conn.execute('SELECT id FROM users')] + personal = [(owner,self.configs.load(owner,self.config,conn)) for owner in people] + targets = [(owner,config) for owner,config in personal if config.telegram_token and config.telegram_token != self.config.telegram_token] + if self.config.configured('telegram'): + targets.append((None,self.config)) + semaphore = asyncio.Semaphore(4) + + async def guarded(owner, config): + async with semaphore: + try: + await self.poll_bot(owner, config) + except Exception: + logger.warning('Telegram 绑定暂时不可用,请检查本账号的 Bot 配置与网络') + await asyncio.gather(*(guarded(owner,config) for owner,config in targets)) + + async def poll_bot(self, owner, config): + key = f'telegram_offset:{owner}:{digest(config.telegram_token)}' if owner else 'telegram_offset' + with self.db.connect() as conn: + row = conn.execute('SELECT value FROM system_state WHERE key=?', (key,)).fetchone() + offset = int(row[0]) if row else 0 + updates = await self.transport_for(owner,config).updates(offset) + for update in updates: + self.process_update(update,user_id=owner,token=config.telegram_token) + with self.db.connect(write=True) as conn: + conn.execute('INSERT OR REPLACE INTO system_state VALUES(?,?)',(key,str(update['update_id']+1))) + + async def run(self): + self.recover() + while True: + try: + await self.poll() + except Exception: + logger.warning('Telegram 绑定暂时不可用,请检查 Bot 配置与网络') + try: + self.enqueue() + await self.deliver() + with self.db.connect(write=True) as conn: + conn.execute('DELETE FROM bindings WHERE expires_at?",(digest(payload.code),person['id'],time.time())).fetchone() + status = 'expired' if not binding else 'bound' if binding['destination'] else 'waiting' + return result(person['id'],bindingStatus=status) + + @app.delete('/api/notifications/telegram/binding') + def unbind_telegram(person=Depends(session)): + with self.db.connect(write=True) as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0]) + settings.update(chatId='',telegramVerified=False,telegram=False) + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id'])) + conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],)) + conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(person['id'],)) + return result(person['id']) + + @app.post('/api/notifications/email/verify') + async def verify_email(person=Depends(session)): + transport = require_config('email',person['id']) + rate.check(('email_verify',person['id']),3,600) + code = f'{secrets.randbelow(1000000):06d}' + nonce = secrets.token_urlsafe(24) + token_hash = digest(nonce+code) + with self.db.connect(write=True) as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0]) + address = settings['emailAddress'] + if not address: + raise HTTPException(422,'请先保存接收邮箱') + conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(person['id'],)) + conn.execute('INSERT INTO bindings(token_hash,user_id,kind,destination,expires_at,created_at) VALUES(?,?,?,?,?,?)',(token_hash,person['id'],'email',encode({'address':address,'nonce':nonce}),time.time()+600,time.time())) + try: + await transport.send('email',address,f'续卡 SIMKEEP · 验证邮箱\n\n验证码:{code}\n10 分钟内有效。请在通知设置输入验证码,确认这是你的接收邮箱。') + except Exception: + with self.db.connect(write=True) as conn: + conn.execute('DELETE FROM bindings WHERE token_hash=?',(token_hash,)) + raise HTTPException(503,'验证码发送失败,请检查邮件服务配置') from None + return {'sent':True,'expiresIn':600} + + @app.post('/api/notifications/email/confirm') + def confirm_email(payload:EmailCode,person=Depends(session)): + rate.check(('email_confirm',person['id']),20,600) + valid = False + with self.db.connect(write=True) as conn: + binding = conn.execute("SELECT * FROM bindings WHERE user_id=? AND kind='email' ORDER BY created_at DESC LIMIT 1",(person['id'],)).fetchone() + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0]) + if binding and binding['expires_at']>time.time() and binding['attempts']<5: + stored = decode(binding['destination']) + valid = stored['address']==settings['emailAddress'] and hmac.compare_digest(binding['token_hash'],digest(stored['nonce']+payload.code)) + if valid: + settings['emailVerified'] = True + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id'])) + conn.execute('DELETE FROM bindings WHERE token_hash=?',(binding['token_hash'],)) + else: + conn.execute('UPDATE bindings SET attempts=attempts+1 WHERE token_hash=?',(binding['token_hash'],)) + if not valid: + raise HTTPException(422,'验证码不正确或已失效,请重新发送') + return result(person['id']) + + @app.post('/api/notifications/test/{channel}') + async def test_notification(channel:Literal['email','telegram'],person=Depends(session)): + transport = require_config(channel,person['id']) + rate.check(('notification_test',person['id']),5,600) + with self.db.connect() as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0]) + target = destination(settings,channel) + if not target: + raise HTTPException(422,'请先验证邮箱或绑定 Telegram') + try: + await transport.send(channel,target,'续卡 SIMKEEP · 测试提醒\n\n通知渠道已连接。开启提醒并保存设置后,续期提醒将按你选择的时间发送。') + except Exception: + raise HTTPException(503,'测试提醒发送失败,请检查服务配置或接收地址') from None + return {'sent':True} diff --git a/style.css b/style.css new file mode 100644 index 0000000..26f4563 --- /dev/null +++ b/style.css @@ -0,0 +1,667 @@ +@charset "UTF-8"; + +:root { + --canvas:#f5f7fc; --surface:#fff; --ink:#20283e; --muted:#77839a; + --accent:#5360ea; --accent-dark:#414dd1; --accent-soft:#eff1ff; + --border:#e9edf5; --success:#168576; --warning:#a96616; --danger:#c4495a; + font-family:"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",Arial,sans-serif; + font-size:14px; font-synthesis:none; color:var(--ink); background:var(--canvas); + -webkit-font-smoothing:antialiased; +} +* {box-sizing:border-box} +body {margin:0} +.bootstrap {min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:22px;color:var(--muted)} +.auth-shell {min-height:100vh;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(420px,1fr);background:white} +.auth-story {padding:48px 58px 30px;background:#f0f3fc;display:flex;flex-direction:column;border-right:1px solid #e2e7f4} +.auth-story .brand {margin:0;align-self:flex-start} +.auth-story-content {max-width:560px;margin:auto 0;padding:60px 0} +.auth-story h1 {font-size:43px;line-height:1.4;letter-spacing:1px;font-weight:700;margin-bottom:23px} +.auth-story-content>p {font-size:14px;line-height:2.1;color:#6f7e9c} +.connection-illustration {display:flex;align-items:center;gap:40px;margin-top:48px;padding:28px 0} +.illustration-sim {width:146px;height:178px;border:1px solid #c6ceee;flex:none;background:white;clip-path:polygon(0 0,76% 0,100% 20%,100% 100%,0 100%);border-radius:9px;position:relative;display:flex;flex-direction:column;padding:21px;transform:rotate(-6deg)} +.illustration-sim>.icon {color:var(--accent);width:27px;height:27px;margin-bottom:14px} +.illustration-sim>span {font-size:11px;color:#6e7daa} +.sim-chip {display:grid;grid-template-columns:repeat(3,1fr);gap:2px;width:62px;height:47px;margin-top:20px;background:#e2e6ff;border:1px solid #9faaf2;border-radius:6px;overflow:hidden} +.sim-chip i {background:#eff1ff;border:1px solid #c7cefa} +.illustration-timeline {display:grid;gap:27px} +.illustration-timeline>div {position:relative;padding-left:23px} +.illustration-timeline>div:not(:last-child)::after {content:"";position:absolute;top:15px;bottom:-30px;left:4px;width:1px;background:#c7d0ee} +.timeline-dot {position:absolute;left:0;top:5px;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 5px #e2e6ff;z-index:1} +.timeline-dot.done {background:#39a390;box-shadow:none} +.timeline-dot.hollow {background:#f0f3fc;box-shadow:none;border:1px solid #99a8c8} +.illustration-timeline strong {font-size:12px;font-weight:550;display:block} +.illustration-timeline small {font-size:10px;display:block;color:#8c98b2;margin-top:6px;line-height:1.8} +.auth-story-foot {font-size:11px;color:#8d9ab5} +.auth-panel {position:relative;display:flex;align-items:center;justify-content:center;padding:60px 42px 90px} +.auth-form-wrap {width:100%;max-width:350px} +.auth-tabs {display:flex;gap:26px;border-bottom:1px solid var(--border);margin-bottom:34px} +.auth-tabs button {padding:11px 0;font-size:13px;color:var(--muted);position:relative} +.auth-tabs button.active {color:var(--accent);font-weight:650} +.auth-tabs button.active::after {content:"";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--accent)} +.auth-form-wrap h2 {font-size:23px;font-weight:650;margin-bottom:10px} +.auth-intro {font-size:12px;line-height:1.8;color:var(--muted);margin-bottom:28px} +#auth-form {display:grid;gap:21px} +#auth-form .field input {padding:12px 14px;font-size:13px} +.auth-submit {width:100%;padding:13px;margin-top:6px;justify-content:space-between} +.auth-account-note {display:flex;align-items:center;justify-content:center;gap:6px;margin-top:25px;color:#95a0b5;font-size:11px} +.auth-account-note>.icon {width:14px;height:14px} +.auth-footer {position:absolute;bottom:30px;font-size:10px;color:#a1aabc} +.channel-actions {display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:17px} +.channel-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:12px} +.service-status {display:grid;gap:10px;border-top:1px solid var(--border);padding-top:17px;margin-top:17px;font-size:10px;color:var(--muted)} +.service-status>span {display:flex;justify-content:space-between} +.service-status strong {font-weight:500;color:var(--ink)} +.binding-steps {display:grid;gap:19px} +.binding-steps a {text-decoration:none} +.binding-steps>p {font-size:12px;line-height:1.9;color:var(--muted)} +.binding-steps .field input {font-size:10px} +.notification-log {margin-top:35px} +.notification-log-empty {display:flex;align-items:center;gap:12px;background:white;border:1px dashed #dce2f0;border-radius:9px;padding:27px;font-size:11px;color:var(--muted);line-height:1.9} +.money-summary {display:grid;grid-template-columns:1fr 1fr;margin:17px 0 22px;padding:19px 24px;background:white;border:1px solid var(--border);border-radius:10px;gap:24px} +.money-summary>div {min-width:0} +.money-summary>div+div {border-left:1px solid var(--border);padding-left:24px} +.money-summary h2 {display:flex;align-items:center;gap:7px;font-size:11px;font-weight:500;color:var(--muted);margin-bottom:12px} +.money-summary h2 .icon {width:15px;height:15px} +.money-summary p {font-size:10px;color:var(--muted);line-height:1.8;margin-top:9px} +.money-tags {display:flex;flex-wrap:wrap;gap:5px 8px;align-items:center;min-width:0} +.money-tag {display:inline-block;background:#f3f5fc;color:#576482;border:1px solid #e8edf7;border-radius:5px;padding:4px 7px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap} +.money-unknown {color:var(--muted);font-size:10px} +.card-balance {display:flex;align-items:flex-start;gap:8px;margin:0 0 14px;padding-top:4px;min-width:0} +.card-balance>span {font-size:10px;color:var(--muted);line-height:25px;flex:none} +.card-balance .money-tag {font-size:10px;padding:3px 6px} +.card-rule-name {min-width:0} +.card-rule-name>span {white-space:normal;line-height:1.8} +.task-info p {flex-wrap:wrap} +.task-info .renewal-cost {font-variant-numeric:tabular-nums;color:var(--accent);font-weight:550} +.detail-money {grid-column:1/-1} +.detail-rule-cost {font-size:11px;display:flex;gap:12px;align-items:center;color:var(--muted);margin-top:12px} +.detail-rule-cost strong {font-weight:500;color:var(--ink);font-variant-numeric:tabular-nums} +#balance-fields {display:grid;gap:12px} +.balance-row {display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 30px;gap:12px;align-items:end} +.balance-row>.icon-button {height:39px;padding:5px} +.form-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:11px} +.form-section-title>h3 {font-size:12px;font-weight:600} +.balance-action {margin-top:19px;align-items:flex-start} +.balance-action input {margin-top:3px;flex:none} +.calculation-preview.balance-preview strong {font-size:12px;text-align:right;line-height:1.7;max-width:65%;white-space:normal;overflow-wrap:anywhere} +.balance-preview .money-error {color:var(--danger);font-size:10px} +@media(max-width:600px) { + .money-summary {grid-template-columns:1fr;padding:18px 20px;gap:15px} + .money-summary>div+div {border-left:0;border-top:1px solid var(--border);padding:15px 0 0} + .balance-row {gap:8px;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 26px} + .balance-row .field select,.balance-row .field input {padding:10px 7px;font-size:11px} +} +.status.success {background:#eaf8f3;color:#168576} +.status.danger {background:#fff0f1;color:#c4495a} +.user-text {min-width:0} +.user-text strong {overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +@media(max-width:1000px) { + .auth-story {padding:38px 35px 25px} + .auth-story h1 {font-size:33px} + .connection-illustration {gap:25px} + .illustration-sim {width:116px;height:157px;padding:17px} +} +@media(max-width:760px) { + .auth-shell {grid-template-columns:1fr} + .auth-story {padding:26px;border-right:0;border-bottom:1px solid #e2e7f4} + .auth-story .brand {gap:9px} + .auth-story .brand-symbol {width:33px;height:36px} + .auth-story .brand-name {font-size:18px} + .auth-story .brand-name {display:block} + .auth-story-content {padding:29px 0 0;max-width:none} + .auth-story h1 {font-size:27px;line-height:1.5;margin-bottom:12px} + .auth-story h1 br {display:initial} + .auth-story-content>p {font-size:12px;line-height:1.9} + .connection-illustration,.auth-story-foot {display:none} + .auth-panel {padding:30px 26px 76px;align-items:flex-start} + .auth-form-wrap {max-width:420px} + .auth-tabs {margin-bottom:24px} + .auth-form-wrap h2 {font-size:21px} + .auth-intro {margin-bottom:24px} + .auth-footer {bottom:22px} +} +button,input,select,textarea {font:inherit} +button {cursor:pointer} +button,a,input,select,textarea { -webkit-tap-highlight-color:transparent } +button {color:inherit} +button:disabled {cursor:not-allowed;opacity:.5} +button:focus-visible,a:focus-visible {outline:3px solid #8b94ff;outline-offset:3px} +input:focus,select:focus,textarea:focus {outline:2px solid #9aa3ff;outline-offset:1px;border-color:var(--accent)} +button {border:0;background:none} +h1,h2,h3,h4,p {margin:0} +svg.icon {width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle} +.small-icon svg.icon {width:16px;height:16px} +.app-shell {display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh} +.sidebar {position:fixed;inset:0 auto 0 0;width:222px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:30;padding:33px 17px 18px} +.brand {display:flex;align-items:center;gap:11px;margin:0 15px 45px;text-decoration:none;color:var(--ink)} +.brand-symbol {width:39px;height:42px;background:var(--accent);border-radius:11px;display:grid;place-items:center;color:white;box-shadow:0 4px 9px #5360ea21} +.brand-symbol svg {width:26px;height:30px} +.brand-name {font-size:21px;font-weight:750;letter-spacing:1.2px;line-height:1.3} +.brand-name small {display:block;font-size:10px;letter-spacing:1.8px;font-weight:550;color:#97a0b4;margin-top:4px} +.nav-caption {font-size:11px;color:#939daf;padding-left:20px;margin-bottom:13px} +.nav-list {display:grid;gap:7px} +.nav-button {position:relative;display:flex;align-items:center;gap:13px;text-align:left;width:100%;padding:12px 18px;border-radius:8px;color:#7b8498;font-size:13px;font-weight:500;transition:background .15s} +.nav-button:hover {background:#f7f8fc;color:var(--ink)} +.nav-button.active {background:var(--accent-soft);color:var(--accent);font-weight:650} +.nav-button.active::before {content:"";position:absolute;left:0;top:16px;bottom:16px;width:3px;border-radius:4px;background:var(--accent)} +.nav-count {margin-left:auto;font-size:10px;line-height:19px;min-width:20px;border-radius:5px;text-align:center;background:#e0e4ff;color:var(--accent);font-weight:700} +.sidebar-spacer {flex:1;min-height:32px} +.sidebar-note {margin:14px 9px 23px;padding:16px 14px 14px;background:#f8f9ff;border:1px solid #eceefb;border-radius:10px;position:relative;overflow:hidden} +.sidebar-note::after {content:"";position:absolute;width:65px;height:65px;border:1px solid #e2e6ff;border-radius:50%;right:-28px;top:-24px} +.note-icon {color:var(--accent);margin-bottom:9px} +.sidebar-note h4 {font-size:12px;font-weight:600;margin-bottom:6px} +.sidebar-note p {font-size:10px;line-height:1.9;color:#8a93a8} +.sidebar-note button {font-size:11px;color:var(--accent);display:flex;align-items:center;gap:8px;margin-top:11px;padding:0} +.sidebar-user {border-top:1px solid var(--border);padding:18px 9px 1px;display:flex;align-items:center;gap:10px;width:100%;text-align:left} +.avatar {width:34px;height:34px;border-radius:11px;background:#eee7e0;color:#8b6c55;display:grid;place-items:center;flex:none;font-size:12px;font-weight:600} +.user-text {flex:1} +.user-text strong {font-size:12px;font-weight:600;display:block} +.user-text small {font-size:10px;color:#99a1b2;display:block;margin-top:3px} +.main-shell {grid-column:2;min-width:0} +.topbar {height:77px;display:flex;align-items:center;justify-content:space-between;padding:0 34px;background:#fff;border-bottom:1px solid var(--border);gap:20px} +.breadcrumbs {display:flex;align-items:center;gap:13px;font-size:12px;color:#99a1b3;white-space:nowrap} +.breadcrumbs strong {font-weight:500;color:#657087} +.breadcrumbs svg.icon {width:13px;height:13px} +.topbar-right {display:flex;align-items:center;gap:19px} +.global-search {display:flex;align-items:center;gap:9px;padding:9px 11px;width:245px;border:1px solid var(--border);border-radius:7px;background:#f9fafc;color:#a3acc0} +.global-search svg {width:16px;height:16px} +.global-search input {border:0;outline:none;background:none;width:100%;font-size:11px;min-width:0;color:var(--ink)} +.global-search input:focus {outline:none} +.global-search:focus-within {outline:2px solid #abb2ff} +.global-search input::placeholder {color:#99a3b5} +kbd {font:10px Arial;color:#a5adbc;border:1px solid #e2e6ef;padding:1px 4px;border-radius:3px;white-space:nowrap} +.icon-button {display:inline-flex;align-items:center;justify-content:center;padding:7px;color:#818b9e;border-radius:7px;gap:4px} +.icon-button:hover {background:var(--accent-soft);color:var(--accent)} +.notification-button {position:relative} +.notification-button::after {content:"";position:absolute;width:5px;height:5px;background:var(--accent);border:2px solid white;border-radius:50%;top:6px;right:8px;box-sizing:content-box} +.topbar-divider {height:23px;width:1px;background:var(--border);margin:0 -4px} +.topbar-avatar {display:flex;align-items:center;gap:7px;padding:0} +.topbar-avatar .avatar {width:32px;height:32px;border-radius:50%} +.mobile-menu {display:none} +.content {padding:29px 34px 30px;max-width:1550px;margin:auto} +.page-heading {display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:27px} +.heading-title {display:flex;align-items:center;gap:12px;margin-bottom:8px} +h1 {font-size:27px;font-weight:650;letter-spacing:-.5px} +.demo-badge {font-size:9px;border:1px solid #dfe4f1;color:#8893a9;padding:3px 6px;border-radius:4px;background:#fff;white-space:nowrap;letter-spacing:.3px} +.page-heading p {font-size:12px;color:var(--muted);line-height:1.8} +.heading-actions {display:flex;gap:10px;flex:none} +.btn {display:inline-flex;align-items:center;justify-content:center;gap:7px;border-radius:7px;padding:10px 15px;font-size:12px;line-height:1.4;font-weight:500;white-space:nowrap;border:1px solid var(--border);background:white} +.btn svg.icon {width:16px;height:16px} +.btn:hover {border-color:#c5ccdd;background:#fafbfe} +.btn-primary {color:#fff;background:var(--accent);border-color:var(--accent);box-shadow:0 3px 7px #5360ea16} +.btn-primary:hover {background:var(--accent-dark);border-color:var(--accent-dark)} +.btn-soft {background:var(--accent-soft);color:var(--accent);border-color:#e3e7ff} +.btn-danger {color:var(--danger);border-color:#f0d9de;background:#fff6f8} +.btn-small {font-size:11px;padding:7px 11px;border-radius:6px} +.text-button {padding:0;color:var(--accent);display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap} +.text-button svg.icon {width:14px;height:14px} +.stat-strip {display:grid;grid-template-columns:repeat(4,1fr);background:#fff;border:1px solid var(--border);border-radius:12px;padding:23px 0;margin-bottom:23px} +.stat {padding:0 24px;position:relative;border-right:1px solid var(--border)} +.stat:last-child {border-right:0} +.stat-label {font-size:11px;color:#78849a;display:flex;align-items:center;gap:7px} +.stat-label svg.icon {width:15px;height:15px;color:#949eb3} +.stat-line {display:flex;align-items:baseline;gap:8px;margin-top:8px} +.stat-value {font-size:31px;line-height:1.2;font-weight:650;letter-spacing:-1px;font-variant-numeric:tabular-nums} +.stat-unit {font-size:11px;color:#8e98ac} +.stat-foot {font-size:10px;color:#98a1b2;line-height:1.7;margin-top:8px} +.stat-foot .stat-colored {color:var(--accent);font-weight:600} +.stat:nth-child(2) .stat-value {color:#d59a3e} +.stat:nth-child(2) .stat-foot {color:#b68e54} +.renewal-strip {border-radius:10px;background:#edf0ff;border:1px solid #e4e8fc;padding:17px 21px;margin-bottom:27px;display:flex;align-items:center;gap:19px;position:relative} +.renewal-strip-icon {width:39px;height:39px;border-radius:10px;background:#fff;display:grid;place-items:center;color:var(--accent);flex:none} +.renewal-intro {flex:none;min-width:125px} +.renewal-intro h3 {font-size:12px;font-weight:650;margin-bottom:5px} +.renewal-intro p {font-size:10px;color:#8690b0} +.renewal-pills {display:flex;flex:1;gap:18px;justify-content:space-around;min-width:0} +.renewal-pill {display:flex;align-items:center;gap:9px;min-width:0;text-align:left;padding:0} +.renewal-pill .provider-mini {width:29px;height:29px;font-size:15px;border-radius:8px} +.renewal-pill strong {font-size:10px;display:block;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px} +.renewal-pill small {font-size:9px;color:#8992af;display:block;margin-top:4px;white-space:nowrap} +.renewal-strip>.text-button {flex:none;margin-left:5px;font-size:10px} +.workspace-grid {display:grid;grid-template-columns:minmax(0,1fr) 274px;gap:24px;align-items:start} +.section-header {display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:14px} +.section-title {font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px} +.section-title .count {font-size:10px;font-weight:500;color:#929bb0;background:#e9edf5;padding:2px 6px;border-radius:4px} +.section-controls {display:flex;gap:8px;align-items:center} +.segmented {display:flex;gap:2px;background:#e9edf5;padding:3px;border-radius:7px;align-items:center} +.segmented button {font-size:10px;color:#8c95a9;padding:5px 11px;border-radius:5px;white-space:nowrap} +.segmented button.active {color:var(--ink);background:white;box-shadow:0 1px 3px #1720390b;font-weight:550} +.segmented button span {font-size:9px;margin-left:4px;color:#a3acbc} +.view-toggle {display:flex;gap:0;border:1px solid #e2e6ee;border-radius:6px;overflow:hidden;background:white} +.view-toggle button {padding:5px 7px;color:#a2abbd;display:flex} +.view-toggle button svg {width:15px;height:15px} +.view-toggle button.active {background:#eef0ff;color:var(--accent)} +.card-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px} +.sim-card {border:1px solid #e4e9f2;background:white;border-radius:12px;padding:19px 19px 0;min-width:0;position:relative;transition:border-color .16s,box-shadow .16s} +.sim-card:hover {border-color:#c9cff3;box-shadow:0 5px 19px #30395007} +.card-top {display:flex;align-items:center;gap:11px;margin-bottom:17px} +.provider-logo,.provider-mini {display:grid;place-items:center;background:var(--brand-color);color:white;font-size:22px;font-weight:750;border-radius:10px;width:39px;height:40px;font-family:Arial,sans-serif;flex:none;line-height:1} +.provider-logo[data-provider="giffgaff"] {background:#f8fafc;color:#263044;border:1px solid #e4e9f2;font-size:31px;font-weight:800} +.provider-logo[data-provider="3HK"] {font-size:29px;font-weight:800} +.provider-mini {width:31px;height:31px;font-size:16px;border-radius:8px} +.card-heading {flex:1;min-width:0} +.card-heading button {display:block;padding:0;max-width:100%;text-align:left} +.card-heading h3 {font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.card-heading p {font-size:11px;color:#7c889e;margin-top:5px} +.type-tag {font-size:9px;padding:3px 6px;border-radius:4px;background:#eef0ff;color:#5863c5;letter-spacing:.2px;font-weight:650;display:inline-flex;align-items:center;gap:4px;white-space:nowrap} +.type-tag.physical {background:#eef2f6;color:#8b96a7} +.type-tag svg {width:11px;height:11px} +.card-top>.icon-button {padding:3px;margin-right:-6px;margin-left:-2px} +.card-top>.icon-button svg {width:16px;height:16px} +.card-number {display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:17px;letter-spacing:.3px;font-variant-numeric:tabular-nums;font-weight:500;min-height:24px} +.card-number.data-number {font-size:13px;letter-spacing:0;color:#525e75} +.flag {width:18px;height:13px;flex:none;display:inline-block;border-radius:2px;overflow:hidden;box-shadow:0 0 0 1px #00000007;position:relative} +.flag svg {display:block;width:100%;height:100%} +.card-meta {display:flex;gap:13px;font-size:10px;color:#7c889e;margin-bottom:17px} +.card-meta span {display:flex;align-items:center;gap:4px} +.card-meta svg {width:12px;height:12px} +.card-rule {padding:11px 12px;background:#f7f8fc;border-radius:7px;display:flex;align-items:center;gap:8px;margin-bottom:14px;position:relative} +.card-rule>.icon {width:15px;height:15px;color:#7f8aaa} +.card-rule-name {font-size:11px;font-weight:500;flex:1;display:flex;align-items:center;gap:7px} +.card-rule-name span {font-size:10px;color:#7e8ba3;font-weight:400} +.card-rule-date {font-size:11px;font-weight:600;color:#536482;font-variant-numeric:tabular-nums} +.platform-label {font-size:10px;color:#7e8ba3;margin-bottom:9px;display:flex;align-items:center;gap:5px} +.platform-label svg {width:12px;height:12px} +.platform-tags {display:flex;gap:5px;flex-wrap:wrap;min-height:21px;margin-bottom:16px} +.platform-tag {font-size:9px;color:#68758e;border:1px solid #e4e9f2;background:white;border-radius:4px;padding:4px 6px;display:inline-flex;align-items:center;gap:4px;line-height:1.25} +.platform-tag .platform-dot {width:9px;height:9px;border-radius:3px;background:var(--platform-color,#7885a7);display:grid;place-items:center;color:#fff;font:bold 7px Arial} +.card-footer {border-top:1px solid #edf0f6;display:flex;align-items:center;justify-content:space-between;margin:0 -19px;padding:12px 19px;gap:8px;font-size:9px} +.status {display:inline-flex;align-items:center;gap:5px;font-size:10px;color:var(--success);white-space:nowrap} +.status::before {content:"";width:5px;height:5px;background:currentColor;border-radius:50%;flex:none} +.status.warning {color:var(--warning)} +.status.danger {color:var(--danger)} +.status.neutral {color:#939daf} +.card-footer .text-button {font-size:10px;color:#78869f} +.card-footer .text-button:hover {color:var(--accent)} +.card-footer .text-button svg {width:12px;height:12px} +.collection-foot {display:flex;justify-content:space-between;align-items:center;margin-top:18px;color:#a0a8b9;font-size:10px;gap:12px} +.collection-foot button {font-size:10px} +.right-rail {display:grid;gap:20px;padding-top:1px} +.panel {border:1px solid var(--border);background:white;border-radius:11px;overflow:hidden} +.panel-heading {display:flex;align-items:center;justify-content:space-between;padding:18px 18px 14px} +.panel-heading h2 {font-size:13px;font-weight:600;display:flex;align-items:center;gap:7px} +.panel-heading svg {width:15px;height:15px;color:#8b95b0} +.calendar-toolbar {display:flex;justify-content:space-between;align-items:center;padding:0 18px 14px} +.calendar-toolbar strong {font-size:12px;font-weight:550} +.calendar-controls {display:flex;gap:3px} +.calendar-controls .icon-button {padding:4px;border:1px solid #edf0f6;border-radius:5px} +.calendar-controls svg {width:12px;height:12px} +.calendar-grid {display:grid;grid-template-columns:repeat(7,1fr);padding:0 12px 16px;row-gap:4px} +.weekday {text-align:center;font-size:9px;color:#a2aabc;line-height:25px;margin-bottom:3px} +.calendar-day {height:30px;font-size:10px;color:#66738d;position:relative;margin:0 2px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums} +.calendar-day.other {color:#c4cada} +.calendar-day:hover {background:#f2f4ff} +.calendar-day.today {background:var(--accent);color:white;box-shadow:0 2px 4px #5360ea20} +.calendar-day.selected:not(.today) {background:var(--accent-soft);box-shadow:inset 0 0 0 1px #cfd5ff;color:var(--accent)} +.calendar-day.has-task::after {content:"";width:3px;height:3px;border-radius:50%;background:#e7a047;position:absolute;bottom:3px;left:calc(50% - 1.5px)} +.calendar-day.today.has-task::after {background:white} +.calendar-legend {font-size:9px;color:#a0a9ba;border-top:1px solid #eff1f6;padding:11px 18px;display:flex;gap:16px} +.calendar-legend span {display:flex;align-items:center;gap:5px} +.legend-dot {width:5px;height:5px;background:var(--accent);border-radius:50%} +.legend-dot.orange {background:#e7a047} +.calendar-events {border-top:1px solid #eff1f6;padding:16px 18px} +.calendar-events h4 {font-size:10px;color:#8995ab;font-weight:500;margin-bottom:13px} +.calendar-event {display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:0;margin:12px 0} +.calendar-event:last-child {margin-bottom:0} +.calendar-event-day {background:#f5f6fc;border:1px solid #eceef6;border-radius:6px;width:32px;height:35px;display:flex;flex-direction:column;align-items:center;justify-content:center;flex:none;color:#717d97;line-height:1.3;font-size:12px;font-weight:650} +.calendar-event-day small {font-size:7px;font-weight:400;color:#9ea7ba} +.calendar-event-text {flex:1} +.calendar-event-text strong {display:block;font-size:11px;font-weight:500} +.calendar-event-text small {display:block;font-size:10px;color:#7e8ba3;margin-top:4px} +.calendar-event .icon {width:13px;height:13px;color:#b1b9c9} +.empty-calendar {font-size:10px;color:#a2aabb;line-height:1.8;padding:5px 0} +.reminder-panel {background:#fff} +.reminder-head {padding:18px 18px 12px} +.reminder-head .reminder-icon {width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:#f0f2ff;color:var(--accent);margin-bottom:12px} +.reminder-head h3 {font-size:12px;font-weight:600;line-height:1.7;margin-bottom:6px} +.reminder-head p {font-size:11px;color:#7e8ba3;line-height:1.9} +.channel-line {display:flex;gap:9px;align-items:center;margin:0 18px;padding:11px 0;border-bottom:1px solid #f0f2f7} +.channel-line:last-of-type {border-bottom:0} +.channel-symbol {width:24px;height:24px;border-radius:7px;display:grid;place-items:center;background:#eaf5fe;color:#4b9acd} +.channel-symbol.email {background:#f0effe;color:#8a7aca} +.channel-symbol svg {width:13px;height:13px} +.channel-line strong {font-size:11px;font-weight:500;flex:1} +.channel-label {font-size:8px;color:#92a0b6;display:flex;align-items:center;gap:3px} +.channel-label svg {width:10px;height:10px;color:var(--success)} +.reminder-panel .panel-actions {display:flex;justify-content:space-between;align-items:center;padding:13px 18px 17px} +.reminder-panel .text-button {font-size:9px} +.rail-tip {font-size:9px;color:#a0a9bb;text-align:center;display:flex;justify-content:center;align-items:center;gap:5px;line-height:1.7} +.rail-tip svg {width:11px;height:11px} +.page-footer {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:9px;color:#a5aebf;margin-top:34px;padding-top:17px;border-top:1px solid #e9edf5} +.page-footer span {display:flex;align-items:center;gap:5px} +.page-footer svg {width:12px;height:12px} +.card-list {background:white;border:1px solid var(--border);border-radius:11px;overflow-x:auto} +.card-list table {width:100%;border-collapse:collapse;min-width:610px} +th {font-size:10px;font-weight:500;color:#8e99ae;text-align:left;background:#fafbfe;padding:14px 16px;border-bottom:1px solid var(--border)} +td {font-size:11px;padding:17px 16px;border-bottom:1px solid #eef1f7;white-space:nowrap} +tr:last-child td {border-bottom:0} +.list-card-name {display:flex;align-items:center;gap:9px;text-align:left;padding:0} +.list-card-name strong {font-size:11px;font-weight:550;display:block} +.list-card-name small {font-size:9px;color:#a0a8bb;display:block;margin-top:4px} +.empty-state {border:1px dashed #dce2f0;border-radius:12px;padding:55px 20px;background:#ffffff90;text-align:center;grid-column:1/-1} +.empty-state>.icon {width:36px;height:36px;color:#acb4cd;margin-bottom:15px} +.empty-state h3 {font-size:14px;font-weight:550;margin-bottom:8px} +.empty-state p {font-size:11px;color:#98a2b7;line-height:1.9;margin-bottom:20px} +.section-full {margin-top:8px} +.task-list {display:grid;gap:12px} +.task-row {background:white;border:1px solid var(--border);border-radius:10px;padding:19px 21px;display:flex;align-items:center;gap:17px} +.task-date {flex:none;width:62px;border-right:1px solid var(--border);padding-right:16px;text-align:center;font-size:21px;font-weight:650;color:#5360a5;line-height:1.2} +.task-date small {display:block;font-size:9px;font-weight:400;color:#9aa3b7;margin-bottom:5px} +.task-row .provider-mini {width:36px;height:36px;border-radius:9px} +.task-info {flex:1;min-width:0} +.task-info h3 {font-size:12px;font-weight:550;margin-bottom:7px} +.task-info p {font-size:10px;color:#939db2;display:flex;gap:15px;align-items:center;line-height:1.8} +.task-info p span {display:flex;gap:5px;align-items:center} +.task-info p svg {width:12px;height:12px} +.task-row>.status {margin-right:10px} +.platform-table td {white-space:normal} +.platform-name-cell {display:flex;gap:10px;align-items:center} +.platform-initial {width:29px;height:29px;border-radius:8px;background:var(--platform-color);color:white;display:grid;place-items:center;font-weight:650;font-family:Arial;font-size:14px} +.muted {color:var(--muted)} +.settings-layout {display:grid;grid-template-columns:minmax(0,1fr) 305px;gap:23px;align-items:start} +.settings-main {display:grid;gap:19px} +.settings-panel {padding:25px} +.settings-panel h2 {font-size:14px;font-weight:600;margin-bottom:7px;display:flex;align-items:center;gap:9px} +.settings-panel h2 svg {width:17px;height:17px;color:#7e8bb2} +.settings-panel>p {font-size:11px;color:#98a1b5;line-height:1.8;margin-bottom:23px} +.settings-channel {border:1px solid var(--border);border-radius:9px;margin-top:13px;padding:17px} +.settings-channel-head {display:flex;align-items:center;gap:10px} +.settings-channel-head>div:nth-child(2) {flex:1} +.settings-channel-head strong {display:block;font-size:12px;font-weight:550} +.settings-channel-head p {font-size:10px;color:#98a2b6;margin-top:5px} +.switch {appearance:none;width:31px;height:18px;border-radius:12px;background:#cfd5e3;cursor:pointer;position:relative;flex:none;transition:background .15s;border:0} +.switch::after {content:"";position:absolute;top:3px;left:3px;width:12px;height:12px;background:white;border-radius:50%;transition:transform .15s;box-shadow:0 1px 3px #0001} +.switch:checked {background:var(--accent)} +.switch:checked::after {transform:translateX(13px)} +.settings-channel .field {margin-top:16px} +.channel-setup {display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 12px;padding:12px 0;border-block:1px solid var(--border);flex-wrap:wrap} +.channel-setup p {display:flex;align-items:center;gap:7px;line-height:1.7;font-size:11px;color:var(--muted);margin:0;flex:1;min-width:140px} +.channel-setup p svg {width:14px;height:14px;flex:none} +.service-config-button {color:var(--accent);border-color:#d4dafb;background:var(--accent-soft);flex:none} +.config-intro {display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.8;color:#637087;margin-bottom:20px} +.config-intro svg {width:18px;height:18px;color:var(--accent);flex:none;margin-top:3px} +.config-check-note {font-size:11px;color:var(--muted);line-height:1.8;margin:18px 0 0} +.config-clear {color:var(--danger);margin-top:18px} +.config-feedback {font-size:12px;color:var(--success);padding:12px 14px;border:1px solid #cce5df;background:#f2faf7;border-radius:7px;line-height:1.8;margin-bottom:18px} +.config-feedback[hidden] {display:none} +.config-modal .modal-footer {flex-wrap:wrap} +.fields-grid {display:grid;grid-template-columns:1fr 1fr;gap:18px} +.field {display:flex;flex-direction:column;gap:8px;min-width:0} +.field>label,.field-label {font-size:11px;color:#637087;font-weight:500;display:block} +.field label span {color:#cb6573;margin-left:3px} +.field input,.field select,.field textarea {width:100%;border:1px solid #e0e5ef;border-radius:7px;background:white;padding:10px 11px;font-size:12px;line-height:1.5;color:var(--ink);min-width:0} +.field input::placeholder,.field textarea::placeholder {color:#aab2c1} +.field textarea {resize:vertical;min-height:75px;line-height:1.8} +.field small {font-size:10px;color:#99a3b7;line-height:1.7} +.field.wide {grid-column:1/-1} +.form-divider {border:0;border-top:1px solid var(--border);margin:22px 0} +.form-section-title {font-size:12px;font-weight:600;margin-bottom:15px;display:flex;justify-content:space-between} +.offsets {display:flex;flex-wrap:wrap;gap:9px;margin:10px 0 17px} +.offset-check {display:flex;align-items:center;gap:7px;background:#f6f7fc;border:1px solid #e8ecf5;border-radius:7px;padding:9px 13px;font-size:11px;color:#78869f;cursor:pointer} +.offset-check:has(input:checked) {background:#eff1ff;border-color:#cfd5ff;color:var(--accent)} +input[type=checkbox]:not(.switch) {accent-color:var(--accent);width:13px;height:13px;margin:0} +.checkbox-line {display:flex;gap:9px;align-items:center;font-size:11px;color:#7a879e;line-height:1.7} +.settings-actions {display:flex;justify-content:flex-end;gap:11px;padding-top:5px} +.notification-preview {padding:22px;background:#eef1ff;border-color:#e0e5fc} +.notification-preview h3 {font-size:12px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:7px;color:#616fb7} +.preview-message {background:white;border-radius:9px;padding:18px;box-shadow:0 3px 10px #5963a108;font-size:11px;line-height:2.1;color:#66748e} +.preview-message strong {display:flex;align-items:center;gap:6px;color:var(--ink);font-size:12px;margin-bottom:8px} +.preview-message strong .icon {width:15px;height:15px;color:var(--accent)} +.preview-message .preview-label {color:#99a4b8;display:inline-block;width:58px} +.preview-message .preview-instructions {border-top:1px solid #eef0f6;margin-top:9px;padding-top:10px;font-size:10px;line-height:1.9} +.preview-message .preview-link {display:block;text-align:center;color:var(--accent);background:#f2f4ff;border:1px solid #e5e9fc;padding:7px 10px;border-radius:6px;margin-top:15px;font-size:10px} +.preview-note {font-size:10px;color:#8995b7;line-height:1.9;margin-top:15px} +.overlay-backdrop {position:fixed;inset:0;background:#222c4657;backdrop-filter:blur(3px);z-index:100;display:flex;justify-content:center;align-items:center;padding:24px;animation:backdrop-in .15s ease} +.modal {background:white;border-radius:15px;width:570px;max-width:100%;max-height:calc(100dvh - 48px);display:flex;flex-direction:column;box-shadow:0 16px 80px #1d29402b;overflow:hidden;animation:modal-in .18s ease} +.modal.small {width:440px} +.modal-header {padding:23px 25px 18px;display:flex;align-items:flex-start;justify-content:space-between;gap:15px;border-bottom:1px solid #edf0f6} +.modal-header h2 {font-size:17px;font-weight:600;margin-bottom:6px} +.modal-header p {font-size:10px;color:#98a2b6;line-height:1.8} +.modal-header .icon-button {padding:3px} +.modal-body {padding:24px 25px;overflow-y:auto} +.modal-footer {border-top:1px solid var(--border);padding:17px 25px;display:flex;align-items:center;justify-content:flex-end;gap:9px;background:#fcfcfe} +.form-error {font-size:11px;color:var(--danger);padding:10px 12px;border:1px solid #f0d2d8;background:#fff5f7;border-radius:6px;margin-bottom:16px;line-height:1.8} +.form-error[hidden] {display:none} +.calculation-preview {background:#f2f4ff;border:1px solid #e3e7ff;border-radius:8px;padding:13px 15px;margin-top:18px;font-size:11px;color:#7c88ac;display:flex;justify-content:space-between;gap:12px} +.calculation-preview strong {color:var(--accent);font-size:12px;font-weight:600;white-space:nowrap} +.completion-summary {display:flex;align-items:center;gap:12px;border-radius:9px;padding:15px;background:#f7f8fc;margin-bottom:20px} +.completion-summary h3 {font-size:12px;font-weight:600;margin-bottom:5px} +.completion-summary p {font-size:10px;color:#92a0b7} +.instructions-box {font-size:11px;color:#7b889f;line-height:1.9;padding:13px 15px;background:#f9fafc;border-left:2px solid #d7def3;margin-bottom:20px;border-radius:0 6px 6px 0} +.drawer-backdrop {justify-content:flex-end;padding:0} +.drawer {width:525px;max-width:100%;height:100dvh;background:white;display:flex;flex-direction:column;box-shadow:-10px 0 50px #1c28421a;animation:drawer-in .2s ease;overflow:hidden} +.drawer .modal-header {padding:22px 27px;align-items:center} +.drawer .modal-body {padding:26px 27px} +.detail-brand {display:flex;align-items:center;gap:13px;margin-bottom:24px} +.detail-brand .provider-logo {width:51px;height:53px;font-size:30px;border-radius:13px} +.detail-brand h2 {font-size:18px;font-weight:600;margin-bottom:8px} +.detail-brand p {font-size:11px;color:#8f9ab1;display:flex;align-items:center;gap:8px} +.detail-grid {display:grid;grid-template-columns:1fr 1fr;gap:19px;background:#f8f9fc;border-radius:10px;padding:18px;margin-bottom:24px} +.detail-grid dt {font-size:10px;color:#98a2b7;margin-bottom:6px} +.detail-grid dd {margin:0;font-size:12px;color:#57667e;font-variant-numeric:tabular-nums;line-height:1.8} +.detail-section {border-top:1px solid var(--border);padding-top:23px;margin-top:23px} +.detail-section .section-header {margin-bottom:14px} +.detail-section h3 {font-size:13px;font-weight:550} +.detail-rule {border:1px solid var(--border);border-radius:9px;padding:15px;margin:10px 0} +.detail-rule-head {display:flex;align-items:center;gap:8px;margin-bottom:10px} +.detail-rule-head>.icon {width:16px;height:16px;color:var(--accent)} +.detail-rule-head strong {font-size:11px;font-weight:550;flex:1} +.detail-rule-head span {font-size:10px;color:#99a4b8} +.detail-rule>p {font-size:10px;color:#94a0b6;line-height:1.9;margin-bottom:12px} +.detail-rule-bottom {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:#8592ad} +.detail-rule-bottom>div:last-child {display:flex;gap:10px} +.detail-platform {display:flex;align-items:center;gap:9px;border-bottom:1px solid #f0f2f7;padding:12px 0} +.detail-platform:last-child {border:0} +.detail-platform>div:nth-child(2) {flex:1;min-width:0} +.detail-platform strong {display:block;font-size:11px;font-weight:500} +.detail-platform p {font-size:9px;color:#99a4b8;line-height:1.7;margin-top:3px;overflow-wrap:anywhere} +.detail-platform .icon-button svg {width:14px;height:14px} +.history-item {border-left:2px solid #e4e8fc;padding:0 0 18px 14px;position:relative} +.history-item::before {content:"";position:absolute;width:6px;height:6px;border:2px solid white;background:#adb7ec;border-radius:50%;left:-5px;top:0;box-sizing:content-box} +.history-item strong {font-size:10px;font-weight:500} +.history-item p {font-size:9px;color:#95a1b6;line-height:1.8;margin-top:5px} +.detail-note {font-size:11px;color:#8793aa;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere} +.help-copy {font-size:12px;color:#7d8ba3;line-height:2} +.help-copy h3 {font-size:13px;font-weight:600;color:#475570;margin:17px 0 5px} +.help-copy h3:first-child {margin-top:0} +.help-copy ul {padding-left:19px;margin:6px 0 15px} +.account-option {border:1px solid var(--border);border-radius:9px;display:flex;align-items:center;gap:12px;width:100%;padding:14px;text-align:left;margin:10px 0} +.account-option.active {background:var(--accent-soft);border-color:#d5dbff} +.account-option>div:nth-child(2) {flex:1} +.account-option strong {font-size:12px;font-weight:550} +.account-option p {font-size:10px;color:#97a2b7;margin-top:5px} +.account-option>.icon {width:17px;height:17px;color:var(--accent)} +.account-note {font-size:10px;color:#99a3b7;line-height:1.9;margin-top:20px} +#toast-root {position:fixed;left:calc(50% + 111px);bottom:30px;transform:translateX(-50%);z-index:200;max-width:calc(100% - 32px)} +.toast {display:flex;align-items:center;gap:9px;background:#28334a;color:white;padding:13px 20px;border-radius:9px;box-shadow:0 8px 30px #1d27402b;font-size:12px;line-height:1.7;animation:modal-in .18s ease} +.toast svg {width:16px;height:16px;color:#9ad7c6} +.toast.error svg {color:#ffb6c4} +.body-overlay {overflow:hidden} +@keyframes backdrop-in {from{opacity:0}to{opacity:1}} +@keyframes modal-in {from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}} +@keyframes drawer-in {from{transform:translateX(30px);opacity:.5}to{transform:translateX(0);opacity:1}} +@media (min-width:1650px) { + .card-grid {grid-template-columns:repeat(3,minmax(0,1fr))} + .workspace-grid {grid-template-columns:minmax(0,1fr) 292px} +} +@media (max-width:1250px) { + .app-shell {grid-template-columns:195px minmax(0,1fr)} + .sidebar {width:195px;padding-inline:12px} + .brand {margin-inline:13px} + .content {padding:26px 25px} + .topbar {padding-inline:25px} + .workspace-grid {grid-template-columns:minmax(0,1fr) 250px;gap:19px} + .card-grid {gap:13px} + .sim-card {padding-inline:15px} + .card-footer {margin-inline:-15px;padding-inline:15px} + .card-top {gap:8px} + .card-heading h3 {font-size:11px} + .card-number {font-size:13px} + .provider-logo {width:33px;height:35px;border-radius:8px;font-size:20px} + .card-rule {padding-inline:9px} + .card-rule-name {gap:4px;font-size:9px} + .card-rule-name span {font-size:8px} + .card-rule-date {font-size:9px} + .renewal-pills {gap:9px} + .renewal-pill:nth-child(3) {display:none} + .stat {padding-inline:20px} + .section-controls {gap:6px} + .segmented button {padding-inline:8px} + #toast-root {left:calc(50% + 97px)} +} +@media (max-width:1060px) { + .app-shell {grid-template-columns:80px minmax(0,1fr)} + .sidebar {width:80px;padding:28px 10px 19px;align-items:center} + .brand {margin:0 0 35px} + .brand-name,.nav-caption,.nav-button>span,.sidebar-note,.user-text,.sidebar-user>.icon {display:none} + .nav-list {width:100%;gap:12px} + .nav-button {padding:13px;justify-content:center} + .nav-button .icon {width:21px;height:21px} + .nav-button.active::before {top:15px;bottom:15px} + .sidebar-user {justify-content:center;padding-top:20px} + .topbar {height:70px} + .content {padding:25px} + .workspace-grid {grid-template-columns:minmax(0,1fr) 245px;gap:19px} + .settings-layout {grid-template-columns:minmax(0,1fr) 265px} + .settings-panel {padding:20px} + #toast-root {left:calc(50% + 40px)} +} +@media (max-width:890px) { + .workspace-grid {grid-template-columns:minmax(0,1fr)} + .right-rail {grid-template-columns:1fr 1fr;align-items:start;margin-top:10px} + .rail-tip {grid-column:1/-1} + .card-grid {gap:17px} + .sim-card {padding:19px 19px 0} + .card-footer {margin-inline:-19px;padding-inline:19px} + .card-heading h3 {font-size:12px} + .card-number {font-size:15px} + .card-rule-name {font-size:10px} + .card-rule-name span {font-size:9px} + .card-rule-date {font-size:10px} + .provider-logo {width:39px;height:40px;font-size:22px} + .renewal-pill:nth-child(3) {display:flex} + .renewal-intro {min-width:105px} + .renewal-strip {gap:13px;padding-inline:17px} + .renewal-strip>.text-button {font-size:0;gap:0} + .renewal-strip>.text-button svg {width:15px;height:15px} + .global-search {width:220px} + .stat {padding-inline:18px} + .stat-foot {font-size:9px} + .settings-layout {grid-template-columns:1fr} + .notification-preview {max-width:460px} + .task-row {gap:13px;padding:17px} + .task-row>.status {margin-right:0} +} +@media (max-width:680px) { + .app-shell {display:block} + .sidebar {transform:translateX(-100%);visibility:hidden;width:222px;padding:28px 17px 18px;align-items:stretch;transition:transform .2s;z-index:70;box-shadow:none} + .sidebar.open {transform:translateX(0);visibility:visible;box-shadow:8px 0 45px #20305010} + .sidebar.open .brand {margin:0 15px 35px} + .sidebar.open .brand-name,.sidebar.open .nav-caption,.sidebar.open .nav-button>span,.sidebar.open .sidebar-note,.sidebar.open .user-text {display:block} + .sidebar.open .nav-button {justify-content:flex-start;padding:12px 18px} + .sidebar.open .nav-list {gap:7px} + .sidebar.open .sidebar-user {justify-content:flex-start} + .menu-scrim {position:fixed;inset:0;background:#25304a4a;z-index:60} + .topbar {height:62px;padding:0 18px;gap:8px} + .mobile-menu {display:flex;padding:5px;margin-left:-5px} + .breadcrumbs {font-size:11px;gap:8px;flex:1} + .breadcrumbs>span:first-child,.breadcrumbs>.icon {display:none} + .topbar-right {gap:11px} + .global-search {width:155px;padding:7px 9px;gap:5px} + .global-search input {font-size:10px} + .global-search kbd,.topbar-divider,.topbar-avatar>.icon {display:none} + .notification-button {padding:4px} + .topbar-avatar .avatar {width:28px;height:28px;font-size:10px} + .content {padding:25px 19px 24px} + .page-heading {align-items:flex-start;gap:10px;margin-bottom:21px;flex-wrap:wrap} + .heading-title {gap:8px;margin-bottom:7px} + h1 {font-size:24px} + .page-heading p {font-size:11px;max-width:320px} + .heading-actions {gap:7px;margin-top:7px} + .heading-actions .btn {font-size:11px;padding:9px 12px} + .stat-strip {grid-template-columns:1fr 1fr;padding:0;margin-bottom:18px;overflow:hidden} + .stat {padding:17px 19px;border-right:0} + .stat:nth-child(odd) {border-right:1px solid var(--border)} + .stat:nth-child(-n+2) {border-bottom:1px solid var(--border)} + .stat-value {font-size:28px} + .stat-foot {margin-top:6px;font-size:9px} + .renewal-strip {padding:15px;gap:11px;flex-wrap:wrap;margin-bottom:24px} + .renewal-strip-icon {width:32px;height:32px;border-radius:8px} + .renewal-strip-icon .icon {width:17px;height:17px} + .renewal-intro {flex:1} + .renewal-strip>.text-button {position:absolute;top:20px;right:15px;font-size:10px;gap:4px} + .renewal-pills {flex-basis:100%;justify-content:flex-start;border-top:1px solid #dfe4fb;padding-top:13px;margin-top:1px;gap:16px} + .renewal-pill {flex:1;gap:7px} + .renewal-pill .provider-mini {width:26px;height:26px;font-size:14px;flex:none} + .renewal-pill strong {font-size:9px;max-width:80px} + .renewal-pill small {font-size:8px;margin-top:3px} + .renewal-pill:nth-child(3) {display:none} + .section-header {gap:8px;margin-bottom:15px;flex-wrap:wrap} + .section-title {font-size:14px;gap:7px} + .section-controls {margin-left:auto;gap:6px} + .segmented button {font-size:9px;padding:5px 8px} + .segmented button span {display:none} + .view-toggle button {padding:5px} + .card-grid {grid-template-columns:1fr;gap:14px} + .card-top {gap:11px;margin-bottom:16px} + .card-heading h3 {font-size:13px} + .card-heading p {font-size:10px} + .card-number {font-size:17px;margin-bottom:9px} + .card-meta {font-size:10px;margin-bottom:17px} + .card-rule {padding:13px 12px;margin-bottom:16px} + .card-rule-name {font-size:11px} + .card-rule-name span {font-size:10px;margin-left:2px} + .card-rule-date {font-size:11px} + .platform-label {font-size:10px} + .platform-tag {font-size:9px;padding:4px 7px} + .card-footer {padding-block:14px} + .card-footer .status,.card-footer .text-button {font-size:10px} + .right-rail {grid-template-columns:1fr;margin-top:15px;gap:17px} + .calendar-grid {row-gap:5px;padding-inline:17px} + .calendar-day {height:35px;font-size:12px;margin-inline:3px} + .weekday {font-size:10px} + .calendar-events {padding-bottom:20px} + .panel-heading h2 {font-size:14px} + .calendar-toolbar strong {font-size:13px} + .page-footer {font-size:8px;align-items:flex-start;line-height:1.8;margin-top:26px} + .page-footer>span:last-child {max-width:145px;text-align:right} + .task-row {flex-wrap:wrap;padding:16px;gap:12px} + .task-date {width:45px;padding-right:11px;font-size:18px} + .task-row .provider-mini {width:30px;height:30px} + .task-info {flex-basis:calc(100% - 104px)} + .task-info h3 {font-size:11px} + .task-info p {font-size:9px;gap:8px;flex-wrap:wrap} + .task-row>.status {margin-left:57px;flex:1} + .task-row>.btn {font-size:10px;padding:7px 10px} + .fields-grid {gap:16px} + .settings-panel {padding:20px 17px} + .settings-panel>.fields-grid {grid-template-columns:1fr} + .offset-check {padding:8px 10px} + .overlay-backdrop {padding:14px} + .modal {max-height:calc(100dvh - 28px);border-radius:12px} + .modal-header {padding:20px 20px 16px} + .modal-header h2 {font-size:16px} + .modal-body {padding:21px 20px} + .modal-footer {padding:15px 20px} + .modal .fields-grid {gap:14px} + .field input,.field select,.field textarea {font-size:13px} + .drawer-backdrop {padding:0} + .drawer .modal-header {padding:20px} + .drawer .modal-body {padding:23px 20px} + #toast-root {left:50%;bottom:22px;width:max-content} + .toast {font-size:11px;padding:12px 16px} +} +@media (max-width:370px) { + .content {padding-inline:15px} + .topbar {padding-inline:15px} + .global-search {width:110px} + .topbar-right {gap:8px} + .breadcrumbs strong {font-size:10px} + .segmented button {padding-inline:6px} + .modal .fields-grid {grid-template-columns:1fr} + .field.wide {grid-column:auto} + .stat {padding-inline:16px} +} +@media (prefers-reduced-motion:reduce) { + *,*::before,*::after {animation:none!important;transition:none!important;scroll-behavior:auto!important} +} diff --git a/tests/api.test.js b/tests/api.test.js new file mode 100644 index 0000000..eab4a9d --- /dev/null +++ b/tests/api.test.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +let API; +try { ({API}=await import('../api.js')); } catch {} +assert.ok(API,'Same-origin authenticated API client must exist'); + +const requests=[]; +const transport=async (path,options) => { + requests.push({path,options}); + if (path==='/api/auth/login') return {ok:true,status:200,json:async()=>({csrfToken:'session-csrf',user:{name:'Alice'}})}; + if (path==='/api/failure') return {ok:false,status:409,json:async()=>({detail:'资料已更新'})}; + if (path==='/api/validation') return {ok:false,status:422,json:async()=>({detail:[{msg:'Value error, 请输入有效日期'}]})}; + return {ok:true,status:200,json:async()=>({workspace:{cards:[{name:'Persisted SIM'}]}})}; +}; +const client=new API(transport); +await client.request('POST','/api/auth/login',{email:'alice@example.com',password:' password '}); +const result=await client.request('POST','/api/cards',{name:'Persisted SIM'}); +assert.equal(requests[0].options.credentials,'same-origin'); +assert.equal(JSON.parse(requests[0].options.body).password,' password '); +assert.equal(requests[1].options.headers['X-CSRF-Token'],'session-csrf'); +assert.equal(result.workspace.cards[0].name,'Persisted SIM'); +await assert.rejects(client.request('GET','/api/failure'),error=>error.status===409 && error.message==='资料已更新'); +await assert.rejects(client.request('GET','/api/validation'),error=>error.message==='请输入有效日期'); +const offline=new API(async()=>{throw new TypeError('Failed to fetch')}); +await assert.rejects(offline.request('GET','/api/workspace'),/无法连接服务器/); +client.clear(); +await client.request('GET','/api/workspace'); +assert.equal(requests.at(-1).options.headers['X-CSRF-Token'],undefined); +let finishOldRead, finishOldError; +const deferred = new API((path) => { + if(path==='/api/old-workspace') return new Promise(resolve=>{finishOldRead=resolve;}); + if(path==='/api/old-error') return new Promise(resolve=>{finishOldError=resolve;}); + return Promise.resolve({ok:true,status:200,json:async()=>({csrfToken:'bob-csrf',user:{id:'bob'},workspace:{cards:[]}})}); +}); +const oldRead=deferred.request('GET','/api/old-workspace'); +const oldError=deferred.request('GET','/api/old-error'); +deferred.clear(); +const bob=await deferred.request('POST','/api/auth/login',{email:'bob@example.com',password:'long-password'}); +const rejectOld=assert.rejects(oldRead,error=>error.name==='StaleResponse'); +const rejectError=assert.rejects(oldError,error=>error.name==='StaleResponse'); +finishOldRead({ok:true,status:200,json:async()=>({csrfToken:'alice-csrf',workspace:{cards:[{name:'Alice private SIM'}]}})}); +finishOldError({ok:false,status:401,json:async()=>({detail:'请先登录'})}); +await rejectOld;await rejectError; +assert.equal(bob.user.id,'bob'); +assert.equal(deferred.csrf,'bob-csrf'); +deferred.clear(); +assert.throws(()=>deferred.assertCurrent(bob),error=>error.name==='StaleResponse'); +console.log('API client: 12 checks passed'); diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..67e17bf --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const core = await import('../core.js').catch(error => { + if (error.code === 'ERR_MODULE_NOT_FOUND') return {}; + throw error; +}); + +function invoke(name, ...args) { + assert.equal(typeof core[name], 'function', `${name} is required for renewal calculations`); + return core[name](...args); +} + +test('days advance across a year boundary', () => { + assert.equal(invoke('addDays', '2026-12-31', 2), '2027-01-02'); +}); + +test('calendar months clamp January 31 to the last day of February', () => { + assert.equal(invoke('addCycle', '2026-01-31', 1, 'months'), '2026-02-28'); +}); + +test('calendar months respect leap years', () => { + assert.equal(invoke('addCycle', '2028-01-31', 1, 'months'), '2028-02-29'); +}); + +test('days use whole business dates independent of daylight saving time', () => { + assert.equal(invoke('daysUntil', '2026-03-09', '2026-03-07'), 2); + assert.equal(invoke('daysUntil', '2026-10-03', '2026-10-04'), -1); +}); + +test('completion-based renewal starts from the actual completion date', () => { + const rule = {dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-07-05'}; + assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2027-01-02'); +}); + +test('early fixed renewal advances one scheduled cycle rather than retaining the completed deadline', () => { + const rule = {dueDate:'2026-10-10', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-10'}; + assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2026-11-10'); +}); + +test('overdue fixed renewal skips elapsed cycles', () => { + const rule = {dueDate:'2026-01-31', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'}; + assert.equal(invoke('nextAfterCompletion', rule, '2026-04-01'), '2026-04-30'); +}); + +test('fixed monthly schedules restore the original day after a short month', () => { + const rule = {dueDate:'2026-02-28', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'}; + assert.equal(invoke('nextAfterCompletion', rule, '2026-02-28'), '2026-03-31'); +}); + +test('invalid dates and cycles are rejected instead of silently overflowing', () => { + assert.throws(() => invoke('addCycle', '2026-02-30', 1, 'months'), /日期/); + assert.throws(() => invoke('addCycle', '2026-10-04', 0, 'days'), /周期/); + assert.throws(() => invoke('addCycle', '2026-10-04', -1, 'days'), /周期/); + assert.throws(() => invoke('addCycle', '2026-10-04', 1.5, 'days'), /周期/); +}); + +test('recording one rule leaves another renewal rule untouched', () => { + const card = {id:'c1', activatedAt:'2026-01-01', rules:[ + {id:'sms', dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-10-03'}, + {id:'plan', dueDate:'2026-10-20', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-20'} + ]}; + const result = invoke('completeRenewal', card, 'sms', '2026-10-04', '2026-10-04', '发了一条短信'); + assert.equal(result.card.rules[0].dueDate, '2027-01-02'); + assert.equal(result.card.rules[1].dueDate, '2026-10-20'); + assert.equal(card.rules[0].dueDate, '2026-10-03'); + assert.equal(result.event.oldDueDate, '2026-10-03'); + assert.equal(result.event.newDueDate, '2027-01-02'); +}); + +test('completion cannot be in the future, before activation or before the last completion', () => { + const card = {activatedAt:'2026-02-01', rules:[{id:'r1', dueDate:'2026-10-20', lastCompletedAt:'2026-09-20', interval:30, unit:'days', anchor:'completion'}]}; + assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-10-05', '2026-10-04'), /未来/); + assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-01-01', '2026-10-04'), /开通/); + assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-09-01', '2026-10-04'), /上次/); +}); + +test('archived cards are excluded and remaining tasks sort by their actual deadline', () => { + const cards = [ + {id:'a', archived:false, rules:[{id:'late',dueDate:'2026-10-20'},{id:'soon',dueDate:'2026-10-03'}]}, + {id:'b', archived:true, rules:[{id:'archived',dueDate:'2026-09-01'}]}, + {id:'c', archived:false, rules:[{id:'middle',dueDate:'2026-10-10'}]} + ]; + assert.deepEqual(invoke('getTasks', cards).map(task => task.rule.id), ['soon','middle','late']); +}); + +test('user timezone determines today around the UTC midnight boundary', () => { + const instant = new Date('2026-10-03T17:00:00Z'); + assert.equal(invoke('todayString', 'Asia/Shanghai', instant), '2026-10-04'); + assert.equal(invoke('todayString', 'America/New_York', instant), '2026-10-03'); +}); + +test('editing activation cannot invalidate an older renewal event even when the latest completion is later', () => { + const card = {id:'c1', activatedAt:'2026-01-01', rules:[{dueDate:'2026-12-01',lastCompletedAt:'2026-10-04'}]}; + const events = [ + {cardId:'c1',completedAt:'2026-06-01'}, + {cardId:'c1',completedAt:'2026-10-04'}, + ]; + assert.throws(() => invoke('validateActivationChange', card, events, '2026-08-01', '2026-10-04'), /续期记录/); +}); + +test('editing activation ignores another card history and permits a correction before its own history', () => { + const card = {id:'c1',activatedAt:'2026-01-01',rules:[{dueDate:'2026-12-01'}]}; + const events = [{cardId:'c2',completedAt:'2026-02-01'},{cardId:'c1',completedAt:'2026-06-01'}]; + assert.doesNotThrow(() => invoke('validateActivationChange', card, events, '2026-05-01', '2026-10-04')); +}); + +test('HTTP browsers without randomUUID can still generate distinct valid card identifiers', () => { + const httpCrypto = {getRandomValues:globalThis.crypto.getRandomValues.bind(globalThis.crypto)}; + const first = invoke('createId', httpCrypto); + const second = invoke('createId', httpCrypto); + assert.match(first, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.match(second, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.notEqual(first,second); +}); diff --git a/tests/money.test.js b/tests/money.test.js new file mode 100644 index 0000000..8789853 --- /dev/null +++ b/tests/money.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +let money; +try {money=await import('../money.js');} catch {} +assert.ok(money,'Exact currency helpers must exist'); +const {normalizeAmount,sumMoney,balanceAfter,formatMoney}=money; +assert.equal(normalizeAmount('00019.9','USD'),'19.90'); +assert.equal(normalizeAmount('1000.00','JPY'),'1000'); +assert.equal(normalizeAmount('1.234','KWD'),'1.234'); +for(const [amount,currency] of [['1.001','USD'],['1.1','JPY'],['-1','USD'],['1e3','USD'],['NaN','USD'],['1000000000','USD'],['1','ZZZ']]) { + assert.throws(()=>normalizeAmount(amount,currency)); +} +assert.deepEqual(sumMoney([{currency:'USD',amount:'0.10'},{currency:'USD',amount:'0.20'},{currency:'JPY',amount:'1000'}]),[{currency:'JPY',amount:'1000'},{currency:'USD',amount:'0.30'}]); +assert.equal(balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.10'},'deduct').amount,'0.20'); +assert.throws(()=>balanceAfter([],{currency:'USD',amount:'0.10'},'deduct')); +assert.throws(()=>balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.31'},'deduct')); +assert.equal(balanceAfter([],{currency:'HKD',amount:'5.50'},'credit').amount,'5.50'); +assert.equal(formatMoney({currency:'USD',amount:'1250.50'}),'USD 1,250.50'); +assert.equal(formatMoney({currency:'JPY',amount:'1000'}),'JPY 1,000'); +assert.equal(formatMoney(null),'未填写'); +console.log('Money: 18 checks passed'); diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e4a05d9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,141 @@ +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import date,timedelta +from pathlib import Path +from fastapi.testclient import TestClient + +try: + from server.main import create_app +except ImportError: + create_app = None + + +class ApiTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(create_app,'Authenticated card API must be implemented') + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.db = Path(self.temp.name)/'test.db' + self.app = create_app(self.db, start_worker=False) + self.a = TestClient(self.app) + self.b = TestClient(self.app) + self.addCleanup(self.a.close) + self.addCleanup(self.b.close) + for client,name in [(self.a,'Alice'),(self.b,'Bob')]: + response = client.post('/api/auth/register',json={'name':name,'email':f'{name.lower()}@example.com','password':'safe-password-123'}) + self.assertEqual(response.status_code,201,response.text) + + def headers(self,client): + return {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']} + + def card_payload(self): + today = date.today().isoformat() + return dict(name='My SIM',provider='Example',type='eSIM',country='US',phone='+1 202 555 0100',activatedAt='2026-01-01',notes='',plan='',rules=[dict(action='sms',interval=90,unit='days',anchor='completion',dueDate=today,instructions='发送短信')]) + + def create_card(self,client=None): + client = client or self.a + response = client.post('/api/cards',headers=self.headers(client),json=self.card_payload()) + self.assertEqual(response.status_code,201,response.text) + return response.json()['workspace']['cards'][0] + + def test_login_cookie_csrf_logout(self): + cookie = self.a.cookies.get('simkeep_session') + self.assertTrue(cookie) + self.assertEqual(self.a.post('/api/cards',json=self.card_payload()).status_code,403) + self.assertEqual(self.a.post('/api/auth/logout',headers=self.headers(self.a)).status_code,200) + self.assertEqual(self.a.get('/api/workspace').status_code,401) + self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'wrong-password'}).status_code,401) + self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'}).status_code,200) + self.assertEqual(self.a.get('/api/workspace').status_code,200) + + def test_card_rule_platform_and_history_are_private(self): + card = self.create_card() + self.assertEqual(self.b.get('/api/workspace').json()['workspace']['cards'],[]) + self.assertEqual(self.b.get(f"/api/cards/{card['id']}").status_code,404) + self.assertEqual(self.b.post(f"/api/cards/{card['id']}/archive",headers=self.headers(self.b),json={'archived':True,'version':card['version']}).status_code,404) + self.assertEqual(self.b.post(f"/api/cards/{card['id']}/platforms",headers=self.headers(self.b),json={'name':'Google','account':'a','purpose':'auth','version':card['version']}).status_code,404) + rule = card['rules'][0] + self.assertEqual(self.b.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.b),json={'completedAt':date.today().isoformat(),'note':'','requestId':'other-user-attempt','version':rule['version']}).status_code,404) + self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['cards']),1) + + def test_completion_is_server_calculated_and_idempotent(self): + card = self.create_card() + rule = card['rules'][0] + payload = {'completedAt':date.today().isoformat(),'note':'done','requestId':'one-logical-operation','version':rule['version']} + url=f"/api/rules/{rule['id']}/completions" + first = self.a.post(url,headers=self.headers(self.a),json=payload) + self.assertEqual(first.status_code,200,first.text) + second = self.a.post(url,headers=self.headers(self.a),json=payload) + self.assertEqual(second.status_code,200,second.text) + workspace=second.json()['workspace'] + self.assertEqual(len(workspace['events']),1) + self.assertEqual(workspace['cards'][0]['rules'][0]['dueDate'],(date.today()+timedelta(days=90)).isoformat()) + payload['requestId']='another-operation' + self.assertEqual(self.a.post(url,headers=self.headers(self.a),json=payload).status_code,409) + + def test_persisted_card_survives_application_recreation(self): + card=self.create_card() + other=TestClient(create_app(self.db,start_worker=False)) + self.addCleanup(other.close) + other.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'}) + self.assertEqual(other.get('/api/workspace').json()['workspace']['cards'][0]['id'],card['id']) + + def test_settings_validation_and_credentials_not_exposed(self): + payload={'timezone':'Asia/Shanghai','time':'09:00','offsets':[7,3,1],'overdue':True,'telegram':True,'email':True,'emailAddress':'alice@example.com'} + response=self.a.put('/api/settings',headers=self.headers(self.a),json=payload) + self.assertEqual(response.status_code,200,response.text) + self.assertFalse(response.json()['workspace']['settings']['emailVerified']) + self.assertNotIn('smtpPassword',response.text) + payload['timezone']='Invalid/Zone' + self.assertEqual(self.a.put('/api/settings',headers=self.headers(self.a),json=payload).status_code,422) + + def test_card_update_rejects_old_version_and_history_conflict(self): + card=self.create_card() + payload=self.card_payload() + payload.pop('rules') + payload['version']=card['version'] + payload['name']='Renamed' + first=self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload) + self.assertEqual(first.status_code,200,first.text) + self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,409) + + def test_concurrent_completions_only_advance_once(self): + card=self.create_card() + rule=card['rules'][0] + headers=self.headers(self.a) + def submit(request_id): + return self.a.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version']}).status_code + with ThreadPoolExecutor(max_workers=2) as executor: + statuses=list(executor.map(submit,['concurrent-one','concurrent-two'])) + self.assertEqual(sorted(statuses),[200,409]) + self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['events']),1) + + def test_activation_edit_checks_older_history_after_later_completion(self): + card=self.create_card() + rule=card['rules'][0] + for completed,request_id in [('2026-02-01','older-history'),('2026-09-01','later-history')]: + response=self.a.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.a),json={'completedAt':completed,'note':'','requestId':request_id,'version':rule['version']}) + self.assertEqual(response.status_code,200,response.text) + card=response.json()['workspace']['cards'][0] + rule=card['rules'][0] + payload=self.card_payload() + payload.pop('rules') + payload.update(activatedAt='2026-05-01',version=card['version']) + self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,422) + + def test_bad_length_and_validation_never_echo_password(self): + response=self.a.get('/api/health',headers={'Content-Length':'invalid'}) + self.assertEqual(response.status_code,400) + response=self.a.post('/api/auth/register',json={'name':'Test','email':'test@example.com','password':'secret12'}) + self.assertEqual(response.status_code,422) + self.assertNotIn('secret12',response.text) + + def test_foreign_origin_cannot_create_a_card(self): + headers={**self.headers(self.a),'Origin':'http://another-site.example'} + self.assertEqual(self.a.post('/api/cards',headers=headers,json=self.card_payload()).status_code,403) + self.assertEqual(self.a.get('/api/workspace').json()['workspace']['cards'],[]) + + +if __name__=='__main__': + unittest.main() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..7a58918 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,23 @@ +import unittest +from unittest.mock import patch +from fastapi import HTTPException +from server.auth import RateLimit, hash_password, verify_password + + +class AuthTests(unittest.TestCase): + def test_password_hash_is_salted_and_preserves_spaces(self): + a=hash_password(' password with spaces ') + b=hash_password(' password with spaces ') + self.assertNotEqual(a,b) + self.assertTrue(verify_password(' password with spaces ',a)) + self.assertFalse(verify_password('password with spaces',a)) + + def test_other_routes_do_not_clear_longer_rate_limit(self): + rate=RateLimit() + with patch('server.auth.time.monotonic',return_value=100): + rate.check('registration',1,3600) + with patch('server.auth.time.monotonic',return_value=200): + rate.check('login',10,60) + with self.assertRaises(HTTPException) as error: + rate.check('registration',1,3600) + self.assertEqual(error.exception.status_code,429) diff --git a/tests/test_domain.py b/tests/test_domain.py new file mode 100644 index 0000000..1e7ccda --- /dev/null +++ b/tests/test_domain.py @@ -0,0 +1,42 @@ +import unittest + +try: + from server import domain +except ImportError: + domain = None + + +class DomainTests(unittest.TestCase): + def run_function(self, name, *args): + self.assertIsNotNone(domain, 'Service date rules must be implemented') + return getattr(domain, name)(*args) + + def test_calendar_months_and_leap_year(self): + self.assertEqual(self.run_function('add_cycle','2026-01-31',1,'months'),'2026-02-28') + self.assertEqual(self.run_function('add_cycle','2028-01-31',1,'months'),'2028-02-29') + + def test_fixed_months_restore_original_anchor(self): + rule = dict(dueDate='2026-02-28',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled') + self.assertEqual(self.run_function('next_due',rule,'2026-02-28'),'2026-03-31') + + def test_early_fixed_completion_advances_current_occurrence(self): + rule = dict(dueDate='2026-10-10',anchorDate='2026-10-10',interval=1,unit='months',anchor='scheduled') + self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2026-11-10') + + def test_overdue_fixed_completion_skips_elapsed_occurrences(self): + rule = dict(dueDate='2026-01-31',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled') + self.assertEqual(self.run_function('next_due',rule,'2026-04-01'),'2026-04-30') + + def test_completion_based_dates_start_from_actual_date(self): + rule = dict(dueDate='2026-10-03',interval=90,unit='days',anchor='completion') + self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2027-01-02') + + def test_bad_date_and_cycle_rejected(self): + self.assertIsNotNone(domain) + for value, interval, unit in [('2026-02-30',1,'months'),('2026-10-04',0,'days'),('2026-10-04',True,'days')]: + with self.assertRaises(ValueError): + domain.add_cycle(value,interval,unit) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_money.py b/tests/test_money.py new file mode 100644 index 0000000..285a150 --- /dev/null +++ b/tests/test_money.py @@ -0,0 +1,42 @@ +import unittest + +try: + from server.money import normalize_amount, apply_balance +except ImportError: + normalize_amount = apply_balance = None + + +class MoneyTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(normalize_amount,'Exact multi-currency amounts must be implemented') + + def test_normalizes_currency_precision_without_rounding(self): + self.assertEqual(normalize_amount('00019.9','USD'),'19.90') + self.assertEqual(normalize_amount('1000.00','JPY'),'1000') + self.assertEqual(normalize_amount('1.234','KWD'),'1.234') + self.assertEqual(normalize_amount('0','GBP'),'0.00') + + def test_invalid_amounts_are_rejected(self): + for amount,currency in [('1.001','USD'),('0.1','JPY'),('-1','USD'),('NaN','USD'),('1e3','USD'),('1000000000','USD'),('1','ZZZ')]: + with self.subTest(amount=amount,currency=currency),self.assertRaises(ValueError): + normalize_amount(amount,currency) + + def test_exact_subtraction_only_touches_matching_currency(self): + balances=[{'currency':'USD','amount':'0.30'},{'currency':'GBP','amount':'8.00'}] + updated,before,after=apply_balance(balances,{'currency':'USD','amount':'0.10'},'deduct') + self.assertEqual(updated,[{'currency':'USD','amount':'0.20'},{'currency':'GBP','amount':'8.00'}]) + self.assertEqual(before,{'currency':'USD','amount':'0.30'}) + self.assertEqual(after,{'currency':'USD','amount':'0.20'}) + self.assertEqual(balances[0]['amount'],'0.30') + + def test_insufficient_or_missing_balance_cannot_be_deducted(self): + for balances,cost in [([],{'currency':'USD','amount':'1.00'}),([{'currency':'USD','amount':'1.00'}],{'currency':'USD','amount':'1.01'})]: + with self.assertRaises(ValueError): + apply_balance(balances,cost,'deduct') + + def test_topup_can_create_currency_and_credit_exactly(self): + balances=[{'currency':'JPY','amount':'100'}] + updated,before,after=apply_balance(balances,{'currency':'HKD','amount':'10.50'},'credit') + self.assertEqual(updated,[{'currency':'JPY','amount':'100'},{'currency':'HKD','amount':'10.50'}]) + self.assertEqual(before,{'currency':'HKD','amount':'0.00'}) + self.assertEqual(after,{'currency':'HKD','amount':'10.50'}) diff --git a/tests/test_money_api.py b/tests/test_money_api.py new file mode 100644 index 0000000..89dd68f --- /dev/null +++ b/tests/test_money_api.py @@ -0,0 +1,161 @@ +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from pathlib import Path +from fastapi.testclient import TestClient +from server.main import create_app + + +class MoneyApiTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path=Path(self.temp.name)/'test.db' + self.app=create_app(self.path,start_worker=False) + self.client=TestClient(self.app) + self.addCleanup(self.client.close) + response=self.client.post('/api/auth/register',json={'name':'Money Test','email':'money@example.com','password':'long-password-123'}) + self.headers={'X-CSRF-Token':response.json()['csrfToken']} + + def payload(self): + return {'name':'Money SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','balances':[{'currency':'USD','amount':'0.30'},{'currency':'JPY','amount':'1000'}],'rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':date.today().isoformat(),'cost':{'currency':'USD','amount':'0.10'}}]} + + def create_card(self): + response=self.client.post('/api/cards',headers=self.headers,json=self.payload()) + self.assertEqual(response.status_code,201,response.text) + return response.json()['workspace']['cards'][0] + + def complete(self,card,request_id='money-operation',**extra): + rule=card['rules'][0] + payload={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version'],**extra} + return self.client.post(f"/api/rules/{rule['id']}/completions",headers=self.headers,json=payload) + + def test_multiple_balances_and_rule_cost_persist_after_restart(self): + card=self.create_card() + with TestClient(create_app(self.path,start_worker=False)) as client: + client.post('/api/auth/login',json={'email':'money@example.com','password':'long-password-123'}) + persisted=client.get('/api/workspace').json()['workspace']['cards'][0] + self.assertEqual(persisted['balances'],card['balances']) + self.assertEqual(persisted['rules'][0]['cost'],{'currency':'USD','amount':'0.10'}) + + def test_explicit_deduction_and_duplicate_submit_are_atomic(self): + card=self.create_card() + payload={'balanceAction':'deduct','cardVersion':card['version']} + first=self.complete(card,**payload) + self.assertEqual(first.status_code,200,first.text) + retry=self.complete(card,**payload) + self.assertEqual(retry.status_code,200,retry.text) + workspace=retry.json()['workspace'] + self.assertEqual(workspace['cards'][0]['balances'],[{'currency':'USD','amount':'0.20'},{'currency':'JPY','amount':'1000'}]) + self.assertEqual(len(workspace['events']),1) + event=workspace['events'][0] + self.assertEqual(event['cost'],{'currency':'USD','amount':'0.10'}) + self.assertEqual(event['balanceBefore']['amount'],'0.30') + self.assertEqual(event['balanceAfter']['amount'],'0.20') + + def test_record_only_keeps_balance_and_historical_actual_cost(self): + card=self.create_card() + response=self.complete(card,cost={'currency':'GBP','amount':'2.5'}) + self.assertEqual(response.status_code,200,response.text) + workspace=response.json()['workspace'] + self.assertEqual(workspace['cards'][0]['balances'],card['balances']) + self.assertEqual(workspace['events'][0]['cost'],{'currency':'GBP','amount':'2.50'}) + rule=workspace['cards'][0]['rules'][0] + edited={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']} + edited['cost']={'currency':'USD','amount':'5.00'} + updated=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=edited) + self.assertEqual(updated.status_code,200,updated.text) + self.assertEqual(updated.json()['workspace']['events'][0]['cost'],{'currency':'GBP','amount':'2.50'}) + + def test_changed_retry_cannot_silently_discard_cost_or_balance_action(self): + card=self.create_card() + first=self.complete(card,cost={'currency':'USD','amount':'0.10'}) + self.assertEqual(first.status_code,200,first.text) + for changed in [ + {'cost':{'currency':'USD','amount':'0.20'}}, + {'cost':{'currency':'USD','amount':'0.10'},'balanceAction':'deduct','cardVersion':card['version']}, + {'cost':None}, + {'note':'changed after a lost response'}, + ]: + with self.subTest(changed=changed): + retry=self.complete(card,**changed) + self.assertEqual(retry.status_code,409,retry.text) + retry=self.complete(card,cost={'currency':'USD','amount':'0.100'}) + self.assertEqual(retry.status_code,200,retry.text) + workspace=retry.json()['workspace'] + self.assertEqual(workspace['cards'][0]['balances'],card['balances']) + self.assertEqual(len(workspace['events']),1) + self.assertEqual(workspace['events'][0]['cost'],{'currency':'USD','amount':'0.10'}) + + def test_insufficient_balance_rejects_entire_completion(self): + card=self.create_card() + response=self.complete(card,cost={'currency':'USD','amount':'0.31'},balanceAction='deduct',cardVersion=card['version']) + self.assertEqual(response.status_code,422,response.text) + workspace=self.client.get('/api/workspace').json()['workspace'] + self.assertEqual(workspace['cards'][0],card) + self.assertEqual(workspace['events'],[]) + + def test_stale_card_balance_version_rejects_deduction(self): + card=self.create_card() + changed={**self.payload(),'rules':[],'version':card['version'],'balances':[{'currency':'USD','amount':'8.00'}]} + response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed) + self.assertEqual(response.status_code,200,response.text) + self.assertEqual(self.complete(card,balanceAction='deduct',cardVersion=card['version']).status_code,409) + + def test_duplicate_currency_and_invalid_precision_are_rejected(self): + payload=self.payload() + payload['balances'].append({'currency':'USD','amount':'9.00'}) + self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422) + payload=self.payload() + payload['rules'][0]['cost']['amount']='0.001' + self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422) + + def test_legacy_card_edits_preserve_existing_money(self): + card=self.create_card() + changed={k:v for k,v in self.payload().items() if k not in ('balances','rules')} + changed.update(name='Renamed',version=card['version']) + response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed) + self.assertEqual(response.status_code,200,response.text) + updated=response.json()['workspace']['cards'][0] + self.assertEqual(updated['balances'],card['balances']) + rule=updated['rules'][0] + fields={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']} + response=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=fields) + self.assertEqual(response.status_code,200,response.text) + self.assertEqual(response.json()['workspace']['cards'][0]['rules'][0]['cost'],card['rules'][0]['cost']) + + def test_topup_creates_new_currency_and_saves_balance_snapshot(self): + payload=self.payload() + payload['rules'][0].update(action='topup',cost={'currency':'HKD','amount':'10.50'}) + response=self.client.post('/api/cards',headers=self.headers,json=payload) + self.assertEqual(response.status_code,201,response.text) + card=response.json()['workspace']['cards'][0] + response=self.complete(card,balanceAction='credit',cardVersion=card['version']) + self.assertEqual(response.status_code,200,response.text) + workspace=response.json()['workspace'] + self.assertEqual(workspace['cards'][0]['balances'][-1],{'currency':'HKD','amount':'10.50'}) + self.assertEqual(workspace['events'][0]['balanceBefore'],{'currency':'HKD','amount':'0.00'}) + + def test_concurrent_requests_cannot_deduct_twice(self): + card=self.create_card() + def submit(request_id): + return self.complete(card,request_id,balanceAction='deduct',cardVersion=card['version']).status_code + with ThreadPoolExecutor(max_workers=2) as pool: + statuses=list(pool.map(submit,['money-concurrent-one','money-concurrent-two'])) + self.assertEqual(sorted(statuses),[200,409]) + workspace=self.client.get('/api/workspace').json()['workspace'] + self.assertEqual(workspace['cards'][0]['balances'][0]['amount'],'0.20') + self.assertEqual(len(workspace['events']),1) + + def test_other_account_cannot_change_or_deduct_balance(self): + card=self.create_card() + with TestClient(self.app) as other: + response=other.post('/api/auth/register',json={'name':'Other','email':'other-money@example.com','password':'long-password-123'}) + headers={'X-CSRF-Token':response.json()['csrfToken']} + payload={**self.payload(),'rules':[],'version':card['version']} + self.assertEqual(other.put(f"/api/cards/{card['id']}",headers=headers,json=payload).status_code,404) + rule=card['rules'][0] + response=other.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'requestId':'foreign-deduction','version':rule['version'],'balanceAction':'deduct','cardVersion':card['version']}) + self.assertEqual(response.status_code,404) + self.assertEqual(self.client.get('/api/workspace').json()['workspace']['cards'][0],card) diff --git a/tests/test_notification_config.py b/tests/test_notification_config.py new file mode 100644 index 0000000..f866e3e --- /dev/null +++ b/tests/test_notification_config.py @@ -0,0 +1,282 @@ +import asyncio +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from server.db import decode, encode +from server.main import create_app +from server.notifications import Config, Transport +from server.notification_config import TelegramConfigInput + + +TOKEN_A = '123456789:' + 'A' * 35 +TOKEN_B = '987654321:' + 'B' * 35 +SMTP_A = {'host':'smtp.example.com','port':587,'mode':'starttls','user':'alice@example.com','password':' password $ with spaces ','from':'alice@example.com'} + + +class NotificationConfigTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name)/'test.db' + self.app = create_app(self.path, start_worker=False, config=Config()) + self.db = self.app.state.db + self.notifier = self.app.state.notifier + self.a, self.b = TestClient(self.app), TestClient(self.app) + self.addCleanup(self.a.close) + self.addCleanup(self.b.close) + self.people, self.headers = [], [] + for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]: + reply = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'}) + self.assertEqual(reply.status_code, 201, reply.text) + self.people.append(reply.json()['user']['id']) + self.headers.append({'X-CSRF-Token':reply.json()['csrfToken']}) + + def save(self, channel, payload, client=None, headers=None): + reply = (client or self.a).put(f'/api/notifications/config/{channel}', headers=headers or self.headers[0], json=payload) + self.assertEqual(reply.status_code, 200, reply.text) + return reply + + def services(self, client=None): + return (client or self.a).get('/api/workspace').json()['workspace']['services'] + + def test_saved_configuration_is_owned_persistent_and_never_returns_secrets(self): + for channel, payload in [('telegram',{'token':TOKEN_A}), ('email',SMTP_A)]: + reply = self.save(channel, payload) + self.assertNotIn(TOKEN_A, reply.text) + self.assertNotIn(SMTP_A['password'], reply.text) + self.assertTrue(self.services()[channel]['configured']) + self.assertFalse(self.services(self.b)[channel]['configured']) + response = self.a.get('/api/notifications/config') + self.assertEqual(response.status_code, 200, response.text) + self.assertNotIn(TOKEN_A, response.text) + self.assertNotIn(SMTP_A['password'], response.text) + self.assertEqual(response.json()['services']['email']['host'], 'smtp.example.com') + self.assertTrue(response.json()['services']['telegram']['tokenSaved']) + restarted = create_app(self.path, start_worker=False, config=Config()) + with TestClient(restarted) as client: + client.cookies.update(self.a.cookies) + self.assertTrue(self.services(client)['email']['configured']) + self.assertTrue(self.services(client)['telegram']['configured']) + + def test_blank_secret_preserves_saved_credentials_and_password_whitespace(self): + self.save('telegram', {'token':TOKEN_A}) + self.save('telegram', {'token':''}) + self.save('email', SMTP_A) + self.save('email', {**SMTP_A, 'password':'', 'port':465, 'mode':'ssl'}) + received = [] + + class SMTP: + def __init__(self, host, port, **kwargs): + received.append((host, port)) + def __enter__(self): return self + def __exit__(self, *args): pass + def ehlo_or_helo_if_needed(self): pass + def ehlo(self): return 250, b'ok' + def login(self, user, password): received.append((user, password)) + def send_message(self, message): + received.append((message['From'], message['To'])) + return {} + + with patch('server.notifications.smtplib.SMTP_SSL', SMTP): + reply = self.a.post('/api/notifications/email/verify', headers=self.headers[0]) + self.assertEqual(reply.status_code, 200, reply.text) + self.assertEqual(received[:2], [('smtp.example.com',465), ('alice@example.com',' password $ with spaces ')]) + self.assertEqual(received[2], ('alice@example.com','alice@example.com')) + + def test_save_requires_session_csrf_and_does_not_accept_another_owner(self): + route = '/api/notifications/config/telegram' + with TestClient(self.app) as visitor: + self.assertEqual(visitor.put(route,json={'token':TOKEN_A}).status_code,401) + self.assertEqual(self.a.put(route,json={'token':TOKEN_A}).status_code,403) + reply = self.a.put(route,headers=self.headers[0],json={'token':TOKEN_A,'userId':self.people[1]}) + self.assertEqual(reply.status_code,422) + self.assertFalse(self.services(self.b)['telegram']['configured']) + + def test_invalid_configuration_never_echoes_credentials_and_keeps_previous_value(self): + self.save('telegram', {'token':TOKEN_A}) + for secret in ['https://secret.example/token','1234:secret/malicious','secret\npassword']: + reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':secret}) + self.assertEqual(reply.status_code,422,reply.text) + self.assertNotIn(secret,reply.text) + for change in [{'port':0},{'mode':'unsafe'},{'host':'https://smtp.example.com'},{'from':'alice@example.com\r\nBcc:secret@example.com'}]: + reply = self.a.put('/api/notifications/config/email',headers=self.headers[0],json={**SMTP_A,**change}) + self.assertEqual(reply.status_code,422,reply.text) + self.assertNotIn(SMTP_A['password'],reply.text) + self.assertTrue(self.services()['telegram']['configured']) + self.assertFalse(self.services()['email']['configured']) + + def test_telegram_bot_cannot_be_claimed_by_two_accounts(self): + self.save('telegram', {'token':TOKEN_A}) + reply = self.b.put('/api/notifications/config/telegram',headers=self.headers[1],json={'token':TOKEN_A}) + self.assertEqual(reply.status_code,409,reply.text) + self.assertNotIn(TOKEN_A,reply.text) + self.assertFalse(self.services(self.b)['telegram']['configured']) + + def test_changing_bot_invalidates_old_binding_and_requires_rebinding(self): + self.save('telegram', {'token':TOKEN_A}) + async def telegram(transport, method, payload): + return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'} + with patch.object(Transport,'telegram',telegram): + code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code'] + message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}} + self.assertTrue(self.notifier.process_update(message,user_id=self.people[0])) + self.save('telegram', {'token':TOKEN_B}) + settings = self.a.get('/api/workspace').json()['workspace']['settings'] + self.assertFalse(settings['telegramVerified']) + self.assertFalse(settings['telegram']) + self.assertEqual(settings['chatId'],'') + self.assertFalse(self.notifier.process_update(message,user_id=self.people[0])) + + def test_personal_bot_cannot_consume_another_accounts_binding_code(self): + self.save('telegram', {'token':TOKEN_A}) + self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1]) + async def telegram(transport, method, payload): + return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'} + with patch.object(Transport,'telegram',telegram): + code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code'] + message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}} + self.assertFalse(self.notifier.process_update(message,user_id=self.people[1])) + self.assertFalse(self.notifier.process_update(message)) + self.assertTrue(self.notifier.process_update(message,user_id=self.people[0])) + + def test_background_reminders_use_each_accounts_saved_token_immediately(self): + messages = [] + for client, headers, token, chat in [(self.a,self.headers[0],TOKEN_A,'100'),(self.b,self.headers[1],TOKEN_B,'200')]: + self.save('telegram',{'token':token},client,headers) + reply = client.post('/api/cards',headers=headers,json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]}) + self.assertEqual(reply.status_code,201,reply.text) + owner = client.get('/api/auth/me').json()['user']['id'] + with self.db.connect(write=True) as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()[0]) + settings.update(telegram=True,telegramVerified=True,chatId=chat) + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),owner)) + async def telegram(transport, method, payload): + messages.append((transport.config.telegram_token,method,payload['chat_id'])) + return {'message_id':1} + now = datetime(2026,10,4,1,0,tzinfo=timezone.utc) + self.notifier.enqueue(now) + with patch.object(Transport,'telegram',telegram): + asyncio.run(self.notifier.deliver(now)) + self.assertCountEqual(messages,[(TOKEN_A,'sendMessage','100'),(TOKEN_B,'sendMessage','200')]) + self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'sent') + + def test_connection_check_never_sends_a_message_and_redacts_network_errors(self): + self.save('telegram', {'token':TOKEN_A}) + methods = [] + async def telegram(transport, method, payload): + methods.append(method) + return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'} + with patch.object(Transport,'telegram',telegram): + reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0]) + self.assertEqual(reply.status_code,200,reply.text) + self.assertTrue(reply.json()['checked']) + self.assertEqual(reply.json()['botUsername'],'AliceTestBot') + self.assertEqual(methods,['getMe']) + async def fail(transport, method, payload): raise RuntimeError(TOKEN_A) + with patch.object(Transport,'telegram',fail): + reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0]) + self.assertEqual(reply.status_code,503,reply.text) + self.assertNotIn(TOKEN_A,reply.text) + self.assertTrue(self.services()['telegram']['configured']) + + def test_clearing_configuration_only_affects_its_owner(self): + self.save('email',SMTP_A) + self.save('email',{**SMTP_A,'user':'bob@example.com','from':'bob@example.com'},self.b,self.headers[1]) + reply = self.a.delete('/api/notifications/config/email',headers=self.headers[0]) + self.assertEqual(reply.status_code,200,reply.text) + self.assertFalse(self.services()['email']['configured']) + self.assertTrue(self.services(self.b)['email']['configured']) + + def test_bot_change_during_connection_cannot_create_a_stale_binding(self): + self.save('telegram', {'token':TOKEN_A}) + async def telegram(transport, method, payload): + self.notifier.configs.save(self.people[0],'telegram',TelegramConfigInput(token=TOKEN_B),Config()) + return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'} + with patch.object(Transport,'telegram',telegram): + reply = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]) + self.assertEqual(reply.status_code,409,reply.text) + with self.db.connect() as conn: + self.assertEqual(conn.execute('SELECT count(*) FROM bindings WHERE user_id=?',(self.people[0],)).fetchone()[0],0) + + def test_personal_polling_keeps_separate_offsets_and_failed_bot_does_not_block_others(self): + self.save('telegram', {'token':TOKEN_A}) + self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1]) + async def get_me(transport, method, payload): + return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'} + with patch.object(Transport,'telegram',get_me): + code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code'] + calls = [] + async def updates(transport, method, payload): + calls.append((transport.config.telegram_token,payload['offset'])) + if transport.config.telegram_token == TOKEN_B: + raise RuntimeError(TOKEN_B) + return [] if payload['offset'] else [{'update_id':21,'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}] + with patch.object(Transport,'telegram',updates), self.assertLogs('simkeep.notifications',level='WARNING') as logs: + asyncio.run(self.notifier.poll()) + asyncio.run(self.notifier.poll()) + self.assertCountEqual(calls,[(TOKEN_A,0),(TOKEN_B,0),(TOKEN_A,22),(TOKEN_B,0)]) + self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['telegramVerified']) + self.assertNotIn(TOKEN_B,' '.join(logs.output)) + + def test_smtp_connection_check_authenticates_without_sending_email(self): + self.save('email',SMTP_A) + commands = [] + class SMTP: + def __init__(self, host, port, **kwargs): commands.append(('connect',host,port)) + def __enter__(self): return self + def __exit__(self, *args): commands.append(('quit',)) + def ehlo_or_helo_if_needed(self): commands.append(('hello',)) + def starttls(self, **kwargs): commands.append(('starttls',)) + def ehlo(self): commands.append(('hello-tls',)) + def login(self, user, password): commands.append(('login',user,password)) + def send_message(self, message): raise AssertionError('Connection checking must not send email') + with patch('server.notifications.smtplib.SMTP',SMTP): + reply = self.a.post('/api/notifications/config/email/check',headers=self.headers[0]) + self.assertEqual(reply.status_code,200,reply.text) + self.assertTrue(reply.json()['checked']) + self.assertEqual(commands,[('connect','smtp.example.com',587),('hello',),('starttls',),('hello-tls',),('login','alice@example.com',' password $ with spaces '),('quit',)]) + + def test_fake_token_with_same_bot_prefix_cannot_reserve_another_persons_credentials(self): + self.save('telegram',{'token':'123456789:'+'X'*35}) + self.save('telegram',{'token':TOKEN_A},self.b,self.headers[1]) + self.assertTrue(self.services(self.b)['telegram']['configured']) + # The actual full credential remains exclusive, including canonical ID spelling. + reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':'000'+TOKEN_A}) + self.assertEqual(reply.status_code,409,reply.text) + + def test_repaired_smtp_retries_failed_reminder_without_resending_a_sent_job(self): + self.save('email',SMTP_A) + reply = self.a.post('/api/cards',headers=self.headers[0],json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]}) + self.assertEqual(reply.status_code,201,reply.text) + with self.db.connect(write=True) as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.people[0],)).fetchone()[0]) + settings.update(email=True,emailVerified=True) + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.people[0])) + now = datetime(2026,10,4,1,0,tzinfo=timezone.utc) + self.notifier.enqueue(now) + with patch.object(Transport,'smtp',side_effect=RuntimeError('old-password')): + for attempt in range(5): + asyncio.run(self.notifier.deliver(now+timedelta(minutes=10*attempt))) + self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'failed') + self.save('email',{**SMTP_A,'password':'corrected-password'}) + delivered = [] + def smtp(transport, destination, text): delivered.append(destination) + with patch.object(Transport,'smtp',smtp): + self.notifier.enqueue(now+timedelta(minutes=50)) + asyncio.run(self.notifier.deliver(now+timedelta(minutes=50))) + self.save('email',{**SMTP_A,'password':'another-password'}) + self.notifier.enqueue(now+timedelta(minutes=60)) + asyncio.run(self.notifier.deliver(now+timedelta(minutes=60))) + self.assertEqual(delivered,['alice@example.com']) + jobs = self.a.get('/api/workspace').json()['workspace']['notifications'] + self.assertEqual(len(jobs),1) + self.assertEqual(jobs[0]['status'],'sent') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..21e500a --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,261 @@ +import asyncio +import re +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from fastapi.testclient import TestClient +from server.db import decode, encode +from server.main import create_app + +try: + from server.notifications import Config, Notifier +except ImportError: + Config = Notifier = None + + +class RecordingTransport: + """Only the external delivery boundary is replaced; queue and DB stay real.""" + def __init__(self): + self.messages = [] + self.failures = {} + + async def send(self, channel, destination, text): + if self.failures.get(channel, 0): + self.failures[channel] -= 1 + raise RuntimeError('secret-token-must-never-reach-browser') + self.messages.append((channel, destination, text)) + + async def bot_username(self): + return 'SimkeepTestBot' + + async def updates(self, offset): + return [] + + +class NotificationTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(Notifier, 'Real notification service must be implemented') + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name)/'test.db' + self.config = Config(telegram_token='server-secret-token', smtp_host='smtp.example.com', smtp_from='bot@example.com', public_url='http://example.com') + self.app = create_app(self.path, start_worker=False, config=self.config) + self.a = TestClient(self.app) + self.b = TestClient(self.app) + self.addCleanup(self.a.close) + self.addCleanup(self.b.close) + for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]: + response = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'}) + self.assertEqual(response.status_code, 201, response.text) + self.db = self.app.state.db + self.notifier = self.app.state.notifier + self.transport = RecordingTransport() + self.notifier.transport = self.transport + self.owner = self.a.get('/api/auth/me').json()['user']['id'] + self.headers = {'X-CSRF-Token': self.a.get('/api/auth/me').json()['csrfToken']} + self.now = datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc) + self.card = self.a.post('/api/cards', headers=self.headers, json={'name':'Private SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07','instructions':'发送一条短信'}]}).json()['workspace']['cards'][0] + + def enable(self, verified=True): + with self.db.connect(write=True) as conn: + settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (self.owner,)).fetchone()[0]) + settings.update(telegram=True, email=True, chatId='123456', telegramVerified=verified, emailVerified=verified) + conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), self.owner)) + + def tick(self, now=None): + self.notifier.enqueue(now or self.now) + asyncio.run(self.notifier.deliver(now or self.now)) + + def test_local_time_deduplication_and_sent_jobs_survive_restart(self): + self.enable() + self.tick(datetime(2026, 10, 4, 0, 59, tzinfo=timezone.utc)) + self.assertEqual(self.transport.messages, []) + self.tick() + self.assertEqual(len(self.transport.messages), 2) + self.assertIn('Private SIM', self.transport.messages[0][2]) + self.assertIn('2026-10-07', self.transport.messages[0][2]) + self.tick() + restarted = Notifier(self.db, self.config, self.transport) + restarted.recover() + restarted.enqueue(self.now) + asyncio.run(restarted.deliver(self.now)) + self.assertEqual(len(self.transport.messages), 2) + self.assertNotIn('server-secret-token', self.a.get('/api/workspace').text) + + def test_unverified_and_archived_cards_never_send(self): + self.enable(verified=False) + self.tick() + self.assertEqual(self.transport.messages, []) + self.enable() + self.notifier.enqueue(self.now) + self.a.post(f"/api/cards/{self.card['id']}/archive", headers=self.headers, json={'archived':True,'version':self.card['version']}) + asyncio.run(self.notifier.deliver(self.now)) + self.assertEqual(self.transport.messages, []) + + def test_old_rule_jobs_cancel_after_completion(self): + self.enable() + self.notifier.enqueue(self.now) + rule = self.card['rules'][0] + response = self.a.post(f"/api/rules/{rule['id']}/completions", headers=self.headers, json={'completedAt':'2026-10-04','note':'','requestId':'record-once-only','version':rule['version']}) + self.assertEqual(response.status_code, 200, response.text) + asyncio.run(self.notifier.deliver(self.now)) + self.assertEqual(self.transport.messages, []) + self.assertTrue(all(x['status']=='cancelled' for x in self.a.get('/api/workspace').json()['workspace']['notifications'])) + + def test_unconfigured_channel_does_not_starve_email_and_stale_jobs_cancel(self): + self.enable() + with self.db.connect(write=True) as conn: + original=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) + settings=decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.owner,)).fetchone()[0]) + settings['email']=False + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner)) + # Arrange 50 real active rules so the previous batch was fully occupied. + for index in range(49): + card={**original,'id':f'card-{index}','rules':[{**original['rules'][0],'id':f'rule-{index}'}]} + conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],self.owner,encode(card))) + self.notifier.enqueue(self.now) + self.notifier.config=Config(smtp_host='smtp.example.com',smtp_from='bot@example.com') + with self.db.connect(write=True) as conn: + settings['email']=True + conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner)) + self.tick() + self.assertEqual(len(self.transport.messages),50) + self.assertTrue(all(channel=='email' for channel,_,_ in self.transport.messages)) + with self.db.connect(write=True) as conn: + conn.execute("UPDATE notification_jobs SET ready_at=ready_at-86400 WHERE channel='telegram'") + self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc)) + with self.db.connect() as conn: + self.assertEqual(conn.execute("SELECT count(*) FROM notification_jobs WHERE channel='telegram' AND status='cancelled'").fetchone()[0],50) + + def test_rebind_check_waits_for_this_binding(self): + self.enable() + code=self.a.post('/api/notifications/telegram/binding',headers=self.headers).json()['code'] + check=lambda:self.a.post('/api/notifications/telegram/binding/check',headers=self.headers,json={'code':code}) + response=check() + self.assertEqual(response.status_code,200,response.text) + self.assertEqual(response.json()['bindingStatus'],'waiting') + self.assertTrue(self.notifier.process_update({'message':{'chat':{'id':654321,'type':'private'},'text':f'/start {code}'}})) + self.assertEqual(check().json()['bindingStatus'],'bound') + bheaders={'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} + other=self.b.post('/api/notifications/telegram/binding/check',headers=bheaders,json={'code':code}) + self.assertEqual(other.json()['bindingStatus'],'expired') + + def test_real_message_matches_suffix_preview(self): + self.enable() + with self.db.connect(write=True) as conn: + card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) + card['phone']='+44 7700 900123' + conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) + self.tick() + for _,_,text in self.transport.messages: + self.assertIn('尾号 0123',text) + self.assertNotIn('+44 7700 900123',text) + + def test_reminder_includes_estimated_cost_and_matching_balance(self): + self.enable() + with self.db.connect(write=True) as conn: + card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) + card['balances']=[{'currency':'USD','amount':'10.50'}] + card['rules'][0]['cost']={'currency':'USD','amount':'2.50'} + conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) + self.tick() + for _,_,text in self.transport.messages: + self.assertIn('续期金额:USD 2.50',text) + self.assertIn('同币种余额:USD 10.50',text) + + def test_transport_failures_retry_without_blocking_other_channel(self): + self.enable() + self.transport.failures['telegram'] = 1 + self.tick() + self.assertEqual([x[0] for x in self.transport.messages], ['email']) + self.assertNotIn('secret-token-must', self.a.get('/api/workspace').text) + self.tick(datetime(2026, 10, 4, 1, 3, tzinfo=timezone.utc)) + self.assertEqual(sorted(x[0] for x in self.transport.messages), ['email','telegram']) + self.tick(datetime(2026, 10, 4, 1, 6, tzinfo=timezone.utc)) + self.assertEqual(len(self.transport.messages), 2) + + def test_cancelled_pending_job_can_resume_without_resending_sent_job(self): + self.enable() + self.notifier.enqueue(self.now) + settings = self.a.get('/api/workspace').json()['workspace']['settings'] + fields = ['timezone','time','offsets','overdue','telegram','email','emailAddress'] + payload = {k:settings[k] for k in fields} + payload['telegram'] = payload['email'] = False + self.a.put('/api/settings', headers=self.headers, json=payload) + payload['telegram'] = payload['email'] = True + self.a.put('/api/settings', headers=self.headers, json=payload) + self.tick() + self.assertEqual(len(self.transport.messages), 2) + self.a.put('/api/settings', headers=self.headers, json=payload) + self.tick() + self.assertEqual(len(self.transport.messages), 2) + + def test_telegram_binding_is_private_expiring_and_single_use(self): + response = self.a.post('/api/notifications/telegram/binding', headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + code = response.json()['code'] + group = {'message':{'chat':{'id':-123,'type':'group'},'text':f'/start {code}'}} + self.assertFalse(self.notifier.process_update(group)) + private = {'message':{'chat':{'id':123456,'type':'private'},'text':f'/start {code}'}} + self.assertTrue(self.notifier.process_update(private)) + self.assertFalse(self.notifier.process_update(private)) + settings = self.a.get('/api/workspace').json()['workspace']['settings'] + self.assertTrue(settings['telegramVerified']) + self.assertEqual(settings['chatId'], '123456') + bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} + other = self.b.post('/api/notifications/telegram/binding', headers=bheaders).json()['code'] + private['message']['text'] = f'/start {other}' + self.assertFalse(self.notifier.process_update(private)) + self.assertFalse(self.b.get('/api/workspace').json()['workspace']['settings']['telegramVerified']) + expired = self.a.post('/api/notifications/telegram/binding', headers=self.headers).json()['code'] + with self.db.connect(write=True) as conn: + conn.execute("UPDATE bindings SET expires_at=0 WHERE kind='telegram'") + private['message']['text'] = f'/start {expired}' + self.assertFalse(self.notifier.process_update(private)) + + def test_email_confirmation_is_owner_scoped_and_destination_bound(self): + response = self.a.post('/api/notifications/email/verify', headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() + bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} + self.assertEqual(self.b.post('/api/notifications/email/confirm', headers=bheaders, json={'code':code}).status_code, 422) + self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 200) + self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['emailVerified']) + self.assertEqual(self.a.post('/api/notifications/test/email', headers=self.headers).status_code, 200) + self.assertEqual(self.b.post('/api/notifications/test/email', headers=bheaders).status_code, 422) + self.a.post('/api/notifications/email/verify', headers=self.headers) + code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() + self.a.put('/api/settings', headers=self.headers, json={'timezone':'Asia/Shanghai','time':'09:00','offsets':[3],'overdue':True,'telegram':False,'email':True,'emailAddress':'new@example.com'}) + self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422) + + def test_email_attempt_limit_and_unconfigured_service(self): + self.a.post('/api/notifications/email/verify', headers=self.headers) + code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() + wrong = '000000' if code != '000000' else '111111' + for _ in range(5): + self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':wrong}).status_code, 422) + self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422) + app = create_app(Path(self.temp.name)/'no-config.db', start_worker=False, config=Config()) + with TestClient(app) as client: + client.post('/api/auth/register', json={'name':'Test','email':'test@example.com','password':'long-password-123'}) + headers = {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']} + self.assertEqual(client.post('/api/notifications/email/verify', headers=headers).status_code, 503) + self.assertEqual(client.post('/api/notifications/telegram/binding', headers=headers).status_code, 503) + + def test_overdue_notification_is_once_per_local_day(self): + self.enable() + with self.db.connect(write=True) as conn: + card = decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) + card['rules'][0]['dueDate']='2026-10-03' + conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) + self.tick() + self.tick() + self.assertEqual(len(self.transport.messages),2) + self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc)) + self.assertEqual(len(self.transport.messages),4) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_restore.py b/tests/test_restore.py new file mode 100644 index 0000000..84b04ad --- /dev/null +++ b/tests/test_restore.py @@ -0,0 +1,63 @@ +import io +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from server.db import Database + +try: + from scripts.restore import restore_database +except ImportError: + restore_database=None + + +class RestoreTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(restore_database,'Container restore must work as the application user') + self.temp=tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name) + original=self.root/'backup.db' + db=Database(original) + with db.connect(write=True) as conn: + conn.execute('INSERT INTO system_state VALUES(?,?)',('restore-fixture','preserved')) + self.incoming=original.read_bytes() + self.destination=self.root/'data'/'simkeep.db' + + def test_stream_import_preserves_database_contents_and_private_mode(self): + restore_database(self.destination,io.BytesIO(self.incoming)) + with sqlite3.connect(self.destination) as conn: + self.assertEqual(conn.execute('SELECT value FROM system_state WHERE key=?',('restore-fixture',)).fetchone()[0],'preserved') + self.assertEqual(conn.execute('PRAGMA integrity_check').fetchone()[0],'ok') + self.assertEqual(self.destination.stat().st_mode&0o777,0o600) + + def test_existing_database_requires_explicit_replace(self): + restore_database(self.destination,io.BytesIO(self.incoming)) + before=self.destination.read_bytes() + with self.assertRaises(ValueError): + restore_database(self.destination,io.BytesIO(self.incoming)) + self.assertEqual(self.destination.read_bytes(),before) + + def test_invalid_or_unrelated_backup_preserves_existing_database(self): + restore_database(self.destination,io.BytesIO(self.incoming)) + before=self.destination.read_bytes() + other=self.root/'other.db' + with sqlite3.connect(other) as conn: + conn.execute('CREATE TABLE unrelated(value TEXT)') + for incoming in (b'not a database',b'',other.read_bytes()): + with self.subTest(incoming_size=len(incoming)),self.assertRaises(ValueError): + restore_database(self.destination,io.BytesIO(incoming),replace=True) + self.assertEqual(self.destination.read_bytes(),before) + + def test_explicit_restore_removes_old_sidecars_and_replaces_contents(self): + restore_database(self.destination,io.BytesIO(self.incoming)) + with sqlite3.connect(self.destination) as conn: + conn.execute('DELETE FROM system_state') + for suffix in ('-wal','-shm'): + Path(str(self.destination)+suffix).write_bytes(b'old stopped database sidecar') + restore_database(self.destination,io.BytesIO(self.incoming),replace=True) + for suffix in ('-wal','-shm'): + self.assertFalse(Path(str(self.destination)+suffix).exists()) + with sqlite3.connect(self.destination) as conn: + self.assertEqual(conn.execute('SELECT value FROM system_state').fetchone()[0],'preserved')