Archived
refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
1 parent
838d635bc5
commit
6288a98ecc
89 files changed
+5806
-5112
No files matched your search
@@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SMTP 配置、发信与邮箱验证码。
|
||||
|
||||
(自 auth_system.py 拆出,行为与原实现一致。)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import sqlite3
|
||||
import ssl
|
||||
import time
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from .db import (
|
||||
_DB_LOCK,
|
||||
_q,
|
||||
_x,
|
||||
get_conn,
|
||||
get_secret_key,
|
||||
)
|
||||
from .security import (
|
||||
_normalize_email,
|
||||
)
|
||||
|
||||
# ============ 邮箱验证码 / SMTP ============
|
||||
|
||||
# 验证码猜错计数(内存态,重启清零可接受)——同邮箱累计错 5 次作废验证码
|
||||
# 键='purpose:任意邮箱' 无上限可被慢速灌大——超限清最旧
|
||||
_CODE_FAILS: dict = {}
|
||||
|
||||
_CODE_FAILS_MAX = 5000
|
||||
|
||||
|
||||
def _code_fails_trim() -> None:
|
||||
"""超上限时按写入序丢弃最旧的一半(仅防内存无界,不承担安全语义)"""
|
||||
if len(_CODE_FAILS) > _CODE_FAILS_MAX:
|
||||
for k in list(_CODE_FAILS.keys())[:len(_CODE_FAILS) // 2]:
|
||||
_CODE_FAILS.pop(k, None)
|
||||
|
||||
|
||||
_SMTP_ENC_PREFIX = 'enc1:' # 信封加密标记前缀(存量明文读取时透明升级)
|
||||
|
||||
|
||||
def _smtp_password_encrypt(plaintext: str) -> str:
|
||||
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码。
|
||||
|
||||
原实现明文落库、并随数据库备份一起导出,等于扩大泄露半径。"""
|
||||
import hashlib as _hl
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
||||
f = Fernet(base64.urlsafe_b64encode(key))
|
||||
return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def _smtp_password_decrypt(ciphertext: str) -> str:
|
||||
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
|
||||
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
|
||||
import hashlib as _hl
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
||||
f = Fernet(base64.urlsafe_b64encode(key))
|
||||
return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def get_smtp_config() -> dict[str, Any]:
|
||||
rows = _q('SELECT * FROM smtp_config WHERE id = 1')
|
||||
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
|
||||
# 读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
|
||||
if cfg.get('password'):
|
||||
try:
|
||||
cfg['password'] = _smtp_password_decrypt(cfg['password'])
|
||||
except Exception:
|
||||
cfg['password'] = ''
|
||||
return cfg
|
||||
|
||||
|
||||
def save_smtp_config(host: str, port: int, sender: str, password: str) -> None:
|
||||
enc_pwd = _smtp_password_encrypt(password) if password else ''
|
||||
_x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1',
|
||||
(host, int(port or 587), sender, enc_pwd, int(time.time())))
|
||||
|
||||
|
||||
def smtp_configured() -> bool:
|
||||
cfg = get_smtp_config()
|
||||
return bool(cfg['host'] and cfg['sender'] and cfg['password'])
|
||||
|
||||
|
||||
def _smtp_connect() -> smtplib.SMTP:
|
||||
cfg = get_smtp_config()
|
||||
port = int(cfg['port'] or 587)
|
||||
# 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
|
||||
ctx = ssl.create_default_context()
|
||||
if port == 465:
|
||||
server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg['host'], port, timeout=15)
|
||||
server.starttls(context=ctx)
|
||||
server.login(cfg['sender'], cfg['password'])
|
||||
return server
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> None:
|
||||
"""收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为
|
||||
CRLF/信封注入点(现有调用点的校验仅作 UX 提示)。"""
|
||||
to = _normalize_email(to)
|
||||
if not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to, re.ASCII):
|
||||
raise ValueError('收件邮箱格式不正确')
|
||||
cfg = get_smtp_config()
|
||||
msg = MIMEText(body, 'plain', 'utf-8')
|
||||
# email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码
|
||||
# (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确)
|
||||
msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment]
|
||||
msg['From'] = formataddr(('云村小助手', cfg['sender']))
|
||||
msg['To'] = to
|
||||
with _smtp_connect() as server:
|
||||
server.sendmail(cfg['sender'], [to], msg.as_string())
|
||||
|
||||
|
||||
_EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受)
|
||||
|
||||
|
||||
# 验证码用途隔离——注册码不能用于找回密码,反之亦然(库行带 purpose,
|
||||
# 消费/作废/还原均按用途匹配)。邮件主题与正文按用途区分,用户一眼可辨。
|
||||
# 新增 admin2fa(管理员两步验证)——发码前置条件是密码已验证正确,
|
||||
# 故豁免 24h 5 封与 60s 冷却(否则管理员每天最多登录 5 次会被自己锁死);
|
||||
# 滥用面由"票据仅密码正确时签发 + 票据尝试上限 + 登录限流"约束
|
||||
_CODE_PURPOSES = ('register', 'reset', 'admin2fa')
|
||||
|
||||
_CODE_EMAIL_TEMPLATES = {
|
||||
'register': ('云村小助手 注册验证码',
|
||||
'您正在注册云村小助手账号。\n\n'
|
||||
'验证码:{code}\n\n'
|
||||
'验证码 10 分钟内有效,请勿泄露给他人。\n'
|
||||
'如非本人操作,请忽略本邮件。'),
|
||||
'reset': ('云村小助手 找回密码验证码',
|
||||
'您正在找回云村小助手账号的登录密码。\n\n'
|
||||
'验证码:{code}\n\n'
|
||||
'验证码 10 分钟内有效,仅用于重置密码,请勿泄露给他人。\n'
|
||||
'如非本人操作,请忽略本邮件(您的密码不会被修改)。'),
|
||||
'admin2fa': ('云村小助手 管理员登录验证码',
|
||||
'您正在登录云村小助手管理员账号(两步验证第二步)。\n\n'
|
||||
'验证码:{code}\n\n'
|
||||
'验证码 10 分钟内有效,请勿泄露给任何人。\n'
|
||||
'如非本人操作,请立即修改管理员密码并检查邮箱安全。'),
|
||||
}
|
||||
|
||||
|
||||
def send_verify_code(email: str, purpose: str = 'register') -> None:
|
||||
"""生成并发送邮箱验证码(10 分钟有效)(先发信成功再落库;同用途旧码即时作废)。
|
||||
① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗);
|
||||
② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。
|
||||
purpose 区分注册/找回(文案不同 + 库行用途隔离);24h 计数移到
|
||||
发信成功之后(原实现 SMTP 失败也烧掉额度)。"""
|
||||
if purpose not in _CODE_PURPOSES:
|
||||
raise ValueError('验证码用途不正确')
|
||||
email = _normalize_email(email or '')
|
||||
# re.ASCII——\w 的 Unicode 语义会放行"中文邮箱@例子.com"这类
|
||||
# 非 ASCII 地址(SMTP 阶段才失败,路由兜底成 500);加 254 长度上限(RFC 口径,
|
||||
# 原实现 5000 字符邮箱可入库)
|
||||
if len(email) > 254 or not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', email, re.ASCII):
|
||||
raise ValueError('邮箱格式不正确')
|
||||
if not smtp_configured():
|
||||
raise RuntimeError('邮箱服务未配置,请联系管理员')
|
||||
# 同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数);
|
||||
# admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释)
|
||||
now_ts = time.time()
|
||||
sent = [t for t in _EMAIL_SENT_24H.get(email, []) if now_ts - t < 86400]
|
||||
if purpose != 'admin2fa' and len(sent) >= 5:
|
||||
raise RuntimeError('该邮箱验证码发送已达上限(24 小时 5 封),请明日再试')
|
||||
# 60 秒内限制重发(按同邮箱同用途的最近一封计)
|
||||
if purpose != 'admin2fa':
|
||||
rows = _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1',
|
||||
(email, purpose))
|
||||
if rows and time.time() - rows[0]['created_at'] < 60:
|
||||
raise RuntimeError('发送过于频繁,请 1 分钟后再试')
|
||||
code = f'{secrets.randbelow(1000000):06d}'
|
||||
subject, body_tpl = _CODE_EMAIL_TEMPLATES[purpose]
|
||||
# 先发送:SMTP 失败不留任何有效验证码与冷却记录;成功后才计入 24h 额度(admin2fa 不计数)
|
||||
send_email(email, subject, body_tpl.format(code=code))
|
||||
if purpose != 'admin2fa':
|
||||
sent.append(now_ts)
|
||||
_EMAIL_SENT_24H[email] = sent
|
||||
# 键只增不减(旧时间戳被过滤但空键留存)——顺手剔除过期
|
||||
# 空键并做容量上限,防慢速灌任意格式邮箱造成无界增长
|
||||
if len(_EMAIL_SENT_24H) > 5000:
|
||||
for k in [k for k, v in _EMAIL_SENT_24H.items() if not v][:2500]:
|
||||
_EMAIL_SENT_24H.pop(k, None)
|
||||
if len(_EMAIL_SENT_24H) > 5000:
|
||||
for k in list(_EMAIL_SENT_24H.keys())[:len(_EMAIL_SENT_24H) // 2]:
|
||||
_EMAIL_SENT_24H.pop(k, None)
|
||||
with _DB_LOCK:
|
||||
conn = get_conn()
|
||||
try:
|
||||
# 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码
|
||||
conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?',
|
||||
(email, purpose, int(time.time())))
|
||||
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
|
||||
(email, code, int(time.time() + 600), int(time.time()), purpose))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
|
||||
"""校验验证码(单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。
|
||||
成功返回剩余有效秒数(真值),失败返回 0。
|
||||
同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。
|
||||
WHERE 带 purpose——注册码与找回码互不可用。"""
|
||||
if purpose not in _CODE_PURPOSES:
|
||||
return 0
|
||||
email = _normalize_email(email or '')
|
||||
fail_key = purpose + ':' + email # 失败计数按"用途:邮箱"隔离,互不连带
|
||||
with _DB_LOCK:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? '
|
||||
'RETURNING expires_at',
|
||||
(email, (code or '').strip(), purpose, int(time.time())))
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
if row:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
return max(0, int(row[0]) - int(time.time()))
|
||||
except sqlite3.OperationalError as _oe:
|
||||
# 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。
|
||||
# 仅"不支持 RETURNING"走回退——库忙(database is locked)原样
|
||||
# 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径
|
||||
if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower():
|
||||
raise
|
||||
conn.rollback()
|
||||
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?',
|
||||
(email, (code or '').strip(), purpose, int(time.time())))
|
||||
conn.commit()
|
||||
if cur.rowcount == 1:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
return 60
|
||||
row = None
|
||||
finally:
|
||||
conn.close()
|
||||
# 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码
|
||||
n = _CODE_FAILS.get(fail_key, 0) + 1
|
||||
_CODE_FAILS[fail_key] = n
|
||||
_code_fails_trim()
|
||||
if n >= 5:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
try:
|
||||
_x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None:
|
||||
"""注册流程后续步骤失败时还原已消费的验证码(第二轮:建号失败不应吞掉验证码)"""
|
||||
if ttl_seconds <= 0:
|
||||
return
|
||||
if purpose not in _CODE_PURPOSES:
|
||||
return
|
||||
email = _normalize_email(email or '')
|
||||
code = (code or '').strip()
|
||||
if not email or not code:
|
||||
return
|
||||
with _DB_LOCK:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
|
||||
(email, code, int(time.time() + ttl_seconds), int(time.time()), purpose))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in new issue
Block a user