diff --git a/.gitignore b/.gitignore index 64c2260..3840a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ runtime/ *.log *.key *.env +baidu-secrets.json +baidu-secrets.partial __pycache__/ .venv/ mediamtx diff --git a/README.md b/README.md index 8659a59..d5b8734 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # 赵府智家 · 视觉系统 +## v0.1.28 · 个人记事与百度网盘 + +- 场所内新增「记事与阅历」。文字、日期、分类、作者、可见范围、点赞和评论存于本机数据库,同一条记事可从动态或个人阅历查询。支持关键词、人物、分类及日期筛选,每页 20 条。默认仅自己可见,选择「当前家庭/场所」才会共享;其他家庭不可查看,超级管理员的普通记事 API 也不越过他人的私密范围。 +- 支持照片、视频、音频附件和浏览器直接录音。每条最多 9 个附件,单文件上限 256 MiB,录音最多 5 分钟;录音需要 HTTPS 与用户的麦克风授权。只上传原始媒体,不进行服务端转码或语音识别;实际播放还取决于浏览器编码支持。 +- **一个后台百度网盘账号统一存放媒体**,应用账号与网盘账号分别管理。用户权限通过本平台的家庭归属和记事可见范围检查。该网盘账号持有人可以直接管理网盘中的全部文件。开发者应用的个人场景限制以百度后台为准。 +- 个人记事的媒体不写入服务器磁盘。浏览器逐个计算 4 MiB 分片 MD5,以 512 KiB 请求发送;服务器最多保留两个 4 MiB 中转缓冲,转发至百度预上传/分片上传/创建文件接口。中断后选择同一原文件,重新读取云端缺失分片继续。数据库仅保存附件归属、名称、大小、校验值、网盘路径和文件 ID 等元数据,不保存媒体内容或永久公开分享链接。 +- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片点击后加载,当前页面最多使用 20 MiB 图片内存缓存;视频和音频由浏览器按需缓冲。退出、离开记事页或切换家庭会释放页面媒体资源。不使用服务端文件缓存、Service Worker 离线缓存或浏览器本地存储保存附件。删除记事/移除引用不删除网盘原文件;云端整理在百度网盘进行。 +- 设置中心由超级管理员填写 AppKey、SecretKey、百度后台应用名称及 `https://你的域名/api/baidu/callback`。回调地址须在百度开发者后台登记且完全一致;通过这个 HTTPS 域名登录后发起授权。SignKey 不用于当前 OAuth 接入。授权 state 一次性、10 分钟有效,并绑定原登录会话与浏览器 HttpOnly/Lax 回调 Cookie;原账号退出、停用或降权后不可完成授权。重新连接须使用原网盘账号,避免已有附件失联。 +- AppKey、SecretKey 和网盘令牌保存在 `VISION_DATA/baidu-secrets.json`,权限 0600;不进入 Git、品牌配置导出或数据库备份。主机管理员能读取该文件。数据库恢复后需要重新配置并授权原网盘;请单独安全保管开发者凭据。授权失败不会使本系统账号退出登录。 +- 数据库备份可独立勾选「同时上传到百度网盘」,默认关闭。本机 SQLite 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。百度云端旧备份不自动清理,保留份数只作用于本机和挂载目录。 +- 稳定性:家庭档案使用独立修订号防止相互覆盖;Logo、图标及封面采用最新图片任务校验,取消或重新选择后旧解码结果不会覆盖新选择;备份配置读取失败也会释放执行状态。 + +升级会新增 `journal_entries`、`journal_media`、`journal_comments` 和 `journal_likes` 表,现有设备、家谱和家庭数据保持原样。当前版本的「个人阅历」以系统账号为人物主体,尚未提供导入网盘已有文件、自动关联祖先人物、分享链接或语音转文字。自定义品牌小图及家庭封面仍沿用原有外观配置,新的记事附件全部使用上述云端流程。 + +回归验证增加 `python3 -m unittest -q test_journal` 与 `node --test test_journal.js`,应和原有完整测试集一起执行。接口参考:[百度 OAuth](https://pan.baidu.com/union/doc/al0rwqzzl)、[上传](https://pan.baidu.com/union/doc/3ksg0s9ye)、[下载](https://pan.baidu.com/union/doc/pkuo3snyp)及[百度网盘官方 Go SDK](https://github.com/baidu-netdisk/baidu-drive-sdk-go)。真实上传、播放和云端备份仍须在合法网盘授权后验收;模拟接口测试不能代替真实平台验收。 + 运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。 ## 界面风格 diff --git a/VERSION b/VERSION index a2e1aa9..baec65a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.27 +0.1.28 diff --git a/app.py b/app.py index e5930c8..fd68b1d 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,8 @@ import inventory import heritage import appearance import backups +import baidu +import journal import ptz ROOT = Path(__file__).resolve().parent @@ -83,6 +85,8 @@ INVENTORY = inventory.Inventory(sys.modules[__name__]) HERITAGE = heritage.Heritage(sys.modules[__name__]) APPEARANCE = appearance.Appearance(sys.modules[__name__]) BACKUPS = backups.Backups(sys.modules[__name__]) +BAIDU = baidu.Baidu(sys.modules[__name__]) +JOURNAL = journal.Journal(sys.modules[__name__]) def setting(key, default=None): @@ -629,15 +633,28 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): try: path = url.urlsplit(self.path).path + if path == '/api/journal/chunk': + self.validate_origin(); self.require_auth() + query = dict(url.parse_qsl(url.urlsplit(self.path).query)) + try: + size = int(self.headers.get('Content-Length', '0')); offset = int(query.get('offset', '-1')) + except ValueError: raise Problem('上传分片参数不正确') + if not 0 < size <= journal.CHUNK: raise Problem('上传分片大小不正确') + self.connection.settimeout(45) + raw = self.rfile.read(size) + if len(raw) != size: raise Problem('分片未完整接收,请重试') + self.answer(JOURNAL.chunk(self.user, query.get('id'), offset, raw)) + return # 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB. - data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage') else 16384) + data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage','/api/journal') else 16384) self.validate_origin() if path in ('/api/login', '/api/setup'): self.login(data, path == '/api/setup') return self.require_auth() if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore', - '/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'): + '/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets', + '/api/journal', '/api/journal/action', '/api/journal/upload', '/api/journal/finish', '/api/journal/remove-upload'): self.require_manager() if path == '/api/logout': with LOCK: @@ -646,6 +663,24 @@ class Handler(BaseHTTPRequestHandler): self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') elif path == '/api/preferences': self.answer(APPEARANCE.save_preferences(data, self.user)) + elif path == '/api/baidu/config': + self.require_admin(); self.answer(BAIDU.configure(data, self.user)) + elif path == '/api/baidu/connect': + self.require_admin() + result, cookie = BAIDU.begin(self.user, 'media', self.token(), self.headers.get('Host')) + self.answer(result, cookie=cookie) + elif path == '/api/baidu/disconnect': + self.require_admin(); self.answer(BAIDU.disconnect(self.user, 'media')) + elif path == '/api/journal': + self.answer(JOURNAL.save(self.user, data)) + elif path == '/api/journal/action': + self.answer(JOURNAL.action(self.user, data)) + elif path == '/api/journal/upload': + self.answer(JOURNAL.begin_upload(self.user, data)) + elif path == '/api/journal/finish': + self.answer(JOURNAL.finish_upload(self.user, data.get('id'))) + elif path == '/api/journal/remove-upload': + self.answer(JOURNAL.remove_upload(self.user, data.get('id'))) elif path == '/api/appearance': self.require_admin() self.answer(APPEARANCE.save_config(data, self.user)) @@ -841,6 +876,17 @@ class Handler(BaseHTTPRequestHandler): parsed = url.urlsplit(self.path) path = parsed.path query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()} + if path == baidu.CALLBACK: + try: + BAIDU.callback(query, self.headers.get('Cookie', '')); result = 'ok' + except Problem: result = 'failed' + self.send_response(303) + self.send_header('Location', '/?baidu='+result) + self.send_header('Cache-Control', 'no-store') + self.send_header('Referrer-Policy', 'no-referrer') + self.send_header('Set-Cookie', 'baidu_flow=; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=0') + self.send_header('Content-Length', '0'); self.end_headers() + return if path == '/api/branding': self.answer(APPEARANCE.public()) return @@ -862,6 +908,34 @@ class Handler(BaseHTTPRequestHandler): self.require_auth() if path == '/api/state': self.answer(ACCOUNTS.state(self.user, status())) + elif path == '/api/baidu/config': + self.require_admin(); self.answer(BAIDU.public_config()) + elif path == '/api/baidu/status': + self.answer(BAIDU.status(self.user)) + elif path == '/api/journal': + self.answer(JOURNAL.snapshot(self.user, query)) + elif path == '/media/journal': + self.user = HOUSEHOLDS.context(self.user, query.get('family')) + item = JOURNAL.media(self.user, query.get('id')) + with BAIDU.open_file('shared', item, self.headers.get('Range', '')) as response: + if response.status not in (200, 206): raise Problem('网盘媒体暂时不可用', 502) + self.send_response(response.status) + self.send_header('Content-Type', item['mime']) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Referrer-Policy', 'no-referrer') + self.send_header('Accept-Ranges', 'bytes') + self.send_header('Content-Disposition', ('attachment' if query.get('download') == '1' else 'inline')+"; filename*=UTF-8''"+url.quote(item['name'])) + for header in ('Content-Length', 'Content-Range'): + if response.headers.get(header): self.send_header(header, response.headers[header]) + self.end_headers() + remaining = item['size'] + try: + while remaining > 0: + chunk = response.read(min(65536, remaining)) + if not chunk: break + self.wfile.write(chunk); remaining -= len(chunk) + except (OSError, http.client.HTTPException): self.close_connection = True elif path == '/api/preferences': self.answer(APPEARANCE.preferences(self.user)) elif path == '/api/appearance': @@ -970,6 +1044,7 @@ class Handler(BaseHTTPRequestHandler): '/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js', '/device-model.js': 'device-model.js', '/devices.js': 'devices.js', '/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js', + '/journal.js': 'journal.js', '/file-hash.js': 'file-hash.js', '/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js', '/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css', '/family-events.js': 'family-events.js', @@ -1009,6 +1084,7 @@ def initialize(): ACCOUNTS.initialize() HOUSEHOLDS.initialize() HERITAGE.initialize() + JOURNAL.initialize() if not setting('storage'): set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) if not setting('mediaSecret'): diff --git a/backups.py b/backups.py index 4ef4e35..e71979d 100644 --- a/backups.py +++ b/backups.py @@ -17,12 +17,13 @@ class Backups: self.a = app self.guard = threading.Lock() self.running = False + self.stage = '' def config(self): - return self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0)) + return {'baidu': False, **self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0))} def status(self): - return dict(config=self.config(), running=self.running, + return dict(config=self.config(), running=self.running, stage=self.stage, localPath=str(self.a.DATA/'managed-backups'), last=self.a.setting('backup_last', None), history=self.a.setting('backup_history', [])[-20:][::-1]) @@ -73,11 +74,14 @@ class Backups: return folder def save(self, data): - if set(data) != {'enabled','time','keep','networkPath','revision'}: + data = {'baidu': False, **data} + if set(data) != {'enabled','time','keep','networkPath','revision','baidu'}: raise self.a.Problem('备份设置字段不完整') with self.guard: if self.running: raise self.a.Problem('备份正在执行,请完成后再调整设置', 409) + if not isinstance(data['baidu'], bool): raise self.a.Problem('网盘备份设置不正确') + if data['baidu']: self.a.BAIDU.token('shared') with self.a.LOCK: old = self.config() if type(data['revision']) is not int or data['revision'] != old['revision']: @@ -96,7 +100,10 @@ class Backups: if self.running: raise self.a.Problem('已有备份正在执行', 409) self.running = True - threading.Thread(target=self.run, args=(reason,), daemon=True).start() + try: threading.Thread(target=self.run, args=(reason,), daemon=True).start() + except Exception: + with self.guard: self.running = False + raise return dict(started=True) def prune(self, folder, prefix, keep): @@ -106,11 +113,13 @@ class Backups: def run(self, reason='manual'): a = self.a - config = self.config() result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason, - localOk=False, networkOk=False, networkRequested=bool(config['networkPath'])) + localOk=False, networkOk=False, baiduOk=False, networkRequested=False, baiduRequested=False) temporary = network_temp = None try: + config = self.config() + result.update(networkRequested=bool(config['networkPath']), baiduRequested=config['baidu']) + self.stage = '正在生成本机数据库副本' local = a.DATA/'managed-backups' local.mkdir(mode=0o700, exist_ok=True) instance = a.setting('backup_instance') @@ -133,6 +142,7 @@ class Backups: result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest) self.prune(local, prefix, config['keep']) if config['networkPath']: + self.stage = '正在复制到网络目录' network = self.network_folder(config['networkPath']) network_temp = network/(name+'.partial') shutil.copyfile(target, network_temp) @@ -143,6 +153,11 @@ class Backups: os.replace(network_temp, network/name) result['networkOk'] = True self.prune(network, prefix, config['keep']) + if config['baidu']: + self.stage = '正在上传百度网盘' + remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance+'/'+name+'/'+name + uploaded = a.BAIDU.upload('shared', target, remote) + result.update(baiduOk=True, baiduPath=remote, baiduFileId=str(uploaded['fs_id'])) except Exception as error: result['error'] = str(error)[:300] or '备份未完成' finally: @@ -151,11 +166,14 @@ class Backups: if temporary_path and temporary_path.exists(): temporary_path.unlink() except OSError: pass - with a.LOCK: - a.set_setting('backup_last', result) - history = a.setting('backup_history', []) - a.set_setting('backup_history', (history+[result])[-100:]) - self.running = False + try: + with a.LOCK: + a.set_setting('backup_last', result) + history = a.setting('backup_history', []) + a.set_setting('backup_history', (history+[result])[-100:]) + finally: + with self.guard: + self.running = False; self.stage = '' return result def tick(self, now=None): diff --git a/baidu.py b/baidu.py new file mode 100644 index 0000000..d2aa3d3 --- /dev/null +++ b/baidu.py @@ -0,0 +1,317 @@ +"""Baidu OAuth and bounded uploads. Secrets stay in a private file, outside DB backups.""" +import datetime as dt +import hashlib +import hmac +import http.cookies +import json +import os +import re +import secrets +import threading +import time +import urllib.error +import urllib.parse as url +import urllib.request + +BLOCK = 4 * 1024 * 1024 +CALLBACK = '/api/baidu/callback' +API = 'https://pan.baidu.com/rest/2.0/xpan/' +OAUTH = 'https://openapi.baidu.com/oauth/2.0/' + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def download_url(value): + p = url.urlsplit(value) + if (p.scheme != 'https' or p.username or p.password or p.port not in (None, 443) + or not any((p.hostname or '').endswith('.'+d) or p.hostname == d + for d in ('baidu.com', 'baidupcs.com'))): + raise ValueError('网盘下载地址未通过校验') + return value + + +class DownloadRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + download_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class Baidu: + def __init__(self, app): + self.a = app + self.guard = threading.RLock() + self.pending = {} + + def read(self): + path = self.a.DATA/'baidu-secrets.json' + if not path.exists(): + return dict(config={}, connections={}) + return json.loads(path.read_text(encoding='utf8')) + + def write(self, value): + path = self.a.DATA/'baidu-secrets.json' + temporary = path.with_suffix('.partial') + with temporary.open('w', encoding='utf8') as stream: + json.dump(value, stream, ensure_ascii=False) + stream.flush(); os.fsync(stream.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + + def key(self, user, purpose='media'): + if purpose == 'backup': + if user['role'] != 'admin': + raise self.a.Problem('数据库云备份仅超级管理员可操作', 403) + return 'shared' + if purpose != 'media': + raise self.a.Problem('网盘用途不正确') + self.a.HOUSEHOLDS.scope(user) + return 'shared' + + def public_config(self): + with self.guard: + c = self.read()['config'] + return dict(configured=bool(c.get('appKey') and c.get('secretKey')), + appKey=c.get('appKey', ''), hasSecret=bool(c.get('secretKey')), + appFolder=c.get('appFolder', '智家'), redirectUri=c.get('redirectUri', ''), + revision=c.get('revision', 0)) + + def configure(self, data, user): + a = self.a + if user['role'] != 'admin': + raise a.Problem('开发者应用仅超级管理员可配置', 403) + if set(data) != {'appKey', 'secretKey', 'appFolder', 'redirectUri', 'revision'}: + raise a.Problem('应用配置字段不完整') + with self.guard: + saved = self.read(); old = saved['config'] + if type(data['revision']) is not int or data['revision'] != old.get('revision', 0): + raise a.Problem('应用配置已变化,请重新读取', 409) + key = a.clean_text(data['appKey'], 150, True) + secret = a.clean_text(data['secretKey'], 150) or old.get('secretKey', '') + folder = a.clean_text(data['appFolder'], 40, True) + callback = a.clean_text(data['redirectUri'], 400, True) + p = url.urlsplit(callback) + if not secret or not re.fullmatch(r'[A-Za-z0-9]+', key+secret): + raise a.Problem('AppKey 或 SecretKey 格式不正确') + if any(c in folder for c in '/\\:*?"<>|') or folder in ('.', '..'): + raise a.Problem('应用文件夹须与百度后台应用名称一致') + if p.scheme != 'https' or not p.hostname or p.username or p.password or p.query or p.fragment or p.path != CALLBACK: + raise a.Problem('回调地址须为 HTTPS 站点地址加 '+CALLBACK) + changed = any(old.get(k) != v for k, v in dict(appKey=key, secretKey=secret, appFolder=folder, redirectUri=callback).items()) + if changed and saved['connections']: + raise a.Problem('已有网盘授权,修改应用前须先解除共享网盘连接', 409) + saved['config'] = dict(appKey=key, secretKey=secret, appFolder=folder, + redirectUri=callback, revision=old.get('revision', 0)+1) + self.write(saved) + if changed: self.pending.clear() + a.audit('配置百度网盘应用', user['username']) + return self.public_config() + + def status(self, user, purpose='media'): + key = self.key(user, purpose) + with self.guard: + saved = self.read(); c = saved['config']; connection = saved['connections'].get(key, {}) + return dict(configured=bool(c.get('appKey') and c.get('secretKey')), + connected=bool(connection.get('refresh_token')), account=connection.get('name', ''), + reconnect=bool(connection.get('invalid')), purpose=purpose, + redirectUri=c.get('redirectUri', ''), root='/apps/'+c.get('appFolder', '智家'), + connectedAt=connection.get('connectedAt', '')) + + def begin(self, user, purpose, session_token, host): + if user['role'] != 'admin': + raise self.a.Problem('共享网盘由超级管理员统一连接', 403) + key = self.key(user, purpose) + with self.guard: + c = self.read()['config'] + if not c.get('appKey') or not c.get('secretKey'): + raise self.a.Problem('请先由超级管理员配置百度开发者应用') + if url.urlsplit(c['redirectUri']).netloc != host: + raise self.a.Problem('请通过 '+url.urlsplit(c['redirectUri']).netloc+' 登录系统后连接网盘') + now = time.time() + self.pending = {s: v for s, v in self.pending.items() if v['expires'] > now and v['key'] != key} + if len(self.pending) >= 32: + raise self.a.Problem('授权请求较多,请稍后再试', 429) + state = secrets.token_hex(32) + self.pending[state] = dict(key=key, userId=user['id'], familyId=self.a.HOUSEHOLDS.scope(user), + session=hashlib.sha256(session_token.encode()).hexdigest(), + revision=c['revision'], expires=now+600) + address = OAUTH+'authorize?'+url.urlencode(dict(response_type='code', client_id=c['appKey'], + redirect_uri=c['redirectUri'], scope='basic,netdisk', state=state, display='page')) + return dict(url=address), 'baidu_flow='+state+'; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=600' + + def json_request(self, address, data=None, raw=None, content_type=None): + body = url.urlencode(data).encode() if data is not None else raw + headers = {'User-Agent': 'pan.baidu.com'} + if body is not None: headers['Content-Type'] = content_type or 'application/x-www-form-urlencoded' + req = urllib.request.Request(address, body, headers) + try: + with urllib.request.build_opener(NoRedirect()).open(req, timeout=40) as response: + payload = response.read(2*1024*1024+1) + if len(payload) > 2*1024*1024: raise ValueError() + value = json.loads(payload) + except Exception: + raise self.a.Problem('百度网盘暂时未响应,请稍后重试', 502) from None + if not isinstance(value, dict): raise self.a.Problem('网盘返回内容不完整', 502) + if value.get('error') or value.get('errno', value.get('error_code', 0)): + code = str(value.get('error') or value.get('errno') or value.get('error_code')) + code = code if re.fullmatch('[A-Za-z0-9_-]{1,50}', code) else 'unknown' + if code in ('invalid_grant', 'invalid_token', 'expired_token', '110', '111', '-6', '20016', '20017', '31045'): + error = self.a.Problem('网盘授权已失效,请重新连接('+code+')', 409) + error.reauthorize = True + raise error + raise self.a.Problem('网盘操作未完成('+code+'),请检查应用权限、目录及网盘容量', 502) + return value + + def token_response(self, c, **fields): + value = self.json_request(OAUTH+'token?'+url.urlencode(dict(client_id=c['appKey'], client_secret=c['secretKey'], **fields))) + if not value.get('access_token') or not value.get('refresh_token') or not isinstance(value.get('expires_in'), int): + raise self.a.Problem('网盘授权返回不完整,请重新连接', 502) + if 'netdisk' not in value.get('scope', '').split(): + # Some responses separate scopes with commas instead of spaces. + if 'netdisk' not in value.get('scope', '').split(','): + raise self.a.Problem('本次未授予网盘访问权限,请重新连接', 403) + return dict(access_token=value['access_token'], refresh_token=value['refresh_token'], + expires=time.time()+max(0, value['expires_in']-120)) + + def callback(self, query, cookie): + state = query.get('state', '') + try: cookies = http.cookies.SimpleCookie(cookie) + except http.cookies.CookieError: cookies = {} + flow = cookies.get('baidu_flow') + if not re.fullmatch('[a-f0-9]{64}', state) or not flow or not hmac.compare_digest(flow.value, state): + raise self.a.Problem('授权校验失败,请从系统重新发起连接', 403) + with self.guard: + pending = self.pending.pop(state, None) + c = self.read()['config'] + if not pending or pending['expires'] < time.time() or pending['revision'] != c.get('revision'): + raise self.a.Problem('授权已过期或已使用,请重新连接', 403) + with self.a.LOCK: + session = self.a.DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?', (pending['session'], time.time())).fetchone() + user = self.a.get_object('users', pending['userId']) + if not session or session[0] != pending['userId'] or not user or user['disabled']: + raise self.a.Problem('原登录已失效,请重新登录后授权', 403) + user = self.a.HOUSEHOLDS.context(user, pending['familyId']) + if user['role'] != 'admin': raise self.a.Problem('管理员权限已变化,请重新授权', 403) + key = self.key(user) + if query.get('error') or not query.get('code'): + raise self.a.Problem('本次未完成网盘授权,可回到系统重新连接') + token = self.token_response(c, grant_type='authorization_code', code=query['code'], redirect_uri=c['redirectUri']) + info = self.json_request(API+'nas?'+url.urlencode(dict(method='uinfo', access_token=token['access_token']))) + uid = str(info.get('uk', '')) + if not uid.isdigit(): raise self.a.Problem('无法确认网盘账号,请重试', 502) + saved = self.read(); old = saved['connections'].get(key, {}) + # Re-authorizing a different disk would strand existing attachments. + owner = self.a.setting('baidu_owner:'+key) + if owner and owner != uid: + raise self.a.Problem('请连接原网盘账号;更换账号需先迁移现有资料', 409) + saved['connections'][key] = dict(token, uid=uid, name=str(info.get('baidu_name') or info.get('netdisk_name') or '百度网盘')[:80], + connectedAt=dt.datetime.now(dt.timezone.utc).isoformat(), generation=old.get('generation', 0)+1) + self.write(saved); self.a.set_setting('baidu_owner:'+key, uid) + self.a.audit('连接共享百度网盘', user['username']) + + def disconnect(self, user, purpose): + if user['role'] != 'admin': raise self.a.Problem('共享网盘由超级管理员管理', 403) + key = self.key(user, purpose) + with self.guard: + saved = self.read(); saved['connections'].pop(key, None); self.write(saved) + self.pending = {s: v for s, v in self.pending.items() if v['key'] != key} + self.a.audit('解除本机网盘连接', user['username']) + return self.status(user, purpose) + + def token(self, key): + with self.guard: + saved = self.read(); connection = saved['connections'].get(key) + if not connection or connection.get('invalid'): + raise self.a.Problem('请连接百度网盘后再操作', 409) + if connection['expires'] <= time.time(): + try: + connection.update(self.token_response(saved['config'], grant_type='refresh_token', refresh_token=connection['refresh_token'])) + except self.a.Problem as error: + if getattr(error, 'reauthorize', False): + connection['invalid'] = True; self.write(saved) + raise + self.write(saved) + return connection['access_token'] + + def request(self, key, resource, method, data=None, **query): + return self.json_request(API+resource+'?'+url.urlencode(dict(method=method, access_token=self.token(key), **query)), data=data) + + def prepare(self, remote, size, hashes): + root = '/apps/'+self.public_config()['appFolder'] + if not remote.startswith(root+'/') or '/..' in remote: raise self.a.Problem('网盘路径不正确') + parent = remote.rsplit('/', 1)[0] + for n in range(3, len(parent.split('/'))+1): + path = '/'.join(parent.split('/')[:n]) + # rtype=0 keeps an existing folder; -8 is also reported by some API versions. + try: self.request('shared', 'file', 'create', dict(path=path, isdir=1, rtype=0)) + except self.a.Problem as error: + if '(-8)' not in str(error): raise + found = self.find_file(remote, size) + if found: return dict(existing=found) + pre = self.request('shared', 'file', 'precreate', dict(path=remote, size=size, isdir=0, rtype=0, block_list=json.dumps(hashes), autoinit=1)) + if pre.get('return_type') == 2: + found = self.find_file(remote, size) + if not found: raise self.a.Problem('云端文件尚未确认,请重试', 502) + return dict(existing=found) + if not pre.get('uploadid'): raise self.a.Problem('网盘预上传返回不完整', 502) + return pre + + def find_file(self, remote, size): + values = self.request('shared', 'file', 'list', dir=remote.rsplit('/', 1)[0], limit=1000).get('list', []) + found = next((v for v in values if v.get('path') == remote), None) + if found and found.get('size') != size: raise self.a.Problem('云端同名文件大小不符,未覆盖文件', 409) + return found + + def send_block(self, remote, upload_id, index, block, expected): + if hashlib.md5(block).hexdigest() != expected: raise self.a.Problem('文件分片校验不一致,请重新选择原文件') + boundary = secrets.token_hex(16) + body = ('--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="chunk"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()+block+('\r\n--'+boundary+'--\r\n').encode() + address = 'https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?'+url.urlencode(dict(method='upload', type='tmpfile', path=remote, uploadid=upload_id, partseq=index, access_token=self.token('shared'))) + result = self.json_request(address, raw=body, content_type='multipart/form-data; boundary='+boundary) + if result.get('md5') != expected: raise self.a.Problem('网盘分片校验不一致,请重试', 502) + + def complete(self, item): + found = self.find_file(item['remotePath'], item['size']) + if found: return found + result = self.request('shared', 'file', 'create', dict(path=item['remotePath'], size=item['size'], isdir=0, rtype=0, + uploadid=item['uploadId'], block_list=json.dumps(item['hashes']))) + if not result.get('fs_id') or result.get('size') != item['size'] or result.get('path') != item['remotePath']: + raise self.a.Problem('上传结果尚未确认,请重试核对云端文件', 502) + return result + + def upload(self, key, local, remote, progress=lambda n, total: None): + """Only database backups use a local file; personal media uses send_block directly.""" + size = local.stat().st_size + hashes = [] + with local.open('rb') as stream: + while block := stream.read(BLOCK): hashes.append(hashlib.md5(block).hexdigest()) + if not hashes: raise self.a.Problem('空文件不能上传') + pre = self.prepare(remote, size, hashes) + if pre.get('existing'): return pre['existing'] + needed = pre.get('block_list', list(range(len(hashes)))) + if any(type(i) is not int or not 0 <= i < len(hashes) for i in needed): + raise self.a.Problem('网盘分片索引不正确', 502) + with local.open('rb') as stream: + for i in needed: + stream.seek(i*BLOCK) + self.send_block(remote, pre['uploadid'], i, stream.read(BLOCK), hashes[i]) + progress(i+1, len(hashes)) + return self.complete(dict(remotePath=remote, size=size, uploadId=pre['uploadid'], hashes=hashes)) + + def open_file(self, key, item, byte_range=''): + result = self.request(key, 'multimedia', 'filemetas', fsids=json.dumps([int(item['fsId'])]), dlink=1) + info = next((v for v in result.get('list', []) if str(v.get('fs_id')) == str(item['fsId'])), None) + if not info or info.get('path') != item['remotePath']: + raise self.a.Problem('网盘文件已移动或不存在', 404) + address = download_url(info.get('dlink', '')) + address += ('&' if '?' in address else '?')+url.urlencode(dict(access_token=self.token(key))) + headers = {'User-Agent': 'pan.baidu.com'} + if byte_range: + if not re.fullmatch(r'bytes=(?:\d+-\d*|-\d+)', byte_range): raise self.a.Problem('播放范围不正确', 416) + headers['Range'] = byte_range + try: + return urllib.request.build_opener(DownloadRedirect()).open(urllib.request.Request(address, headers=headers), timeout=30) + except Exception: + raise self.a.Problem('网盘文件暂时无法读取,请稍后重试', 502) from None diff --git a/households.py b/households.py index cc02261..eed8768 100644 --- a/households.py +++ b/households.py @@ -157,6 +157,8 @@ class Households: raise a.Problem('家庭不存在', 404) if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)): raise a.Problem('只能管理自己所属的家庭', 403) + if type(data.get('profileRevision', 0)) is not int or data.get('profileRevision', 0) != (old or {}).get('profileRevision', 0): + raise a.Problem('家庭档案已被更新,请重新打开后保存,避免覆盖他人的修改', 409) kind = data.get('kind', old.get('kind', 'home') if old else 'home') if kind not in ('home', 'office', 'factory'): raise a.Problem('请选择家庭、办公场所或工厂') @@ -164,6 +166,7 @@ class Households: name=a.clean_text(data.get('name', ''), 80, True), kind=kind, note=a.clean_text(data.get('note', ''), 300), + profileRevision=(old or {}).get('profileRevision', 0)+1, createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat()) for field in ('province','city','district'): item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60) diff --git a/journal.py b/journal.py new file mode 100644 index 0000000..642abe6 --- /dev/null +++ b/journal.py @@ -0,0 +1,272 @@ +"""Personal journals, household sharing and owner-bound cloud attachments.""" +import datetime as dt +import hashlib +import os +import re +import secrets +import threading +import time + +CHUNK = 512 * 1024 +MAX_FILE = 256 * 1024 * 1024 +KINDS = {'日常', '成长', '家庭', '旅行', '工作', '其他'} +TYPES = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', + '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.wav': 'audio/wav', '.ogg': 'audio/ogg'} + + +class Journal: + def __init__(self, app): + self.a = app + self.guard = threading.RLock() + self.buffers = {} + + def initialize(self): + with self.a.LOCK, self.a.DB: + for table in ('journal_entries', 'journal_media', 'journal_comments', 'journal_likes'): + self.a.DB.execute('CREATE TABLE IF NOT EXISTS '+table+' (id TEXT PRIMARY KEY, body TEXT NOT NULL)') + + # No binary files are written to disk. Partial 4 MiB blocks vanish on restart. + + def owned(self, user, table, key): + item = self.a.HOUSEHOLDS.owns(user, self.a.get_object(table, key)) + if item['ownerId'] != user['id']: + raise self.a.Problem('只能修改自己的记录', 403) + return item + + def visible(self, user, item): + return bool(item and not item.get('deleted') and item['familyId'] == self.a.HOUSEHOLDS.scope(user) + and (item['ownerId'] == user['id'] or item['visibility'] == 'household')) + + def entry(self, user, key): + item = self.a.get_object('journal_entries', key) + if not self.visible(user, item): raise self.a.Problem('记事不存在或未获授权', 404) + return item + + def public_media(self, item): + return {k: item.get(k) for k in ('id', 'name', 'mime', 'size', 'status', 'received', 'progress', 'error', 'at')} + + def media(self, user, key): + item = self.a.HOUSEHOLDS.owns(user, self.a.get_object('journal_media', key)) + if item['ownerId'] != user['id'] and not any(self.visible(user, entry) and key in entry['mediaIds'] for entry in self.a.objects('journal_entries')): + raise self.a.Problem('附件不存在或未获授权', 404) + if item['status'] != 'ready': raise self.a.Problem('附件尚未上传完成', 409) + return item + + def snapshot(self, user, query): + a = self.a + if query.get('mode', 'feed') not in ('feed', 'mine', 'person'): + raise a.Problem('查询方式不正确') + entries = [e for e in a.objects('journal_entries') if self.visible(user, e)] + mode = query.get('mode', 'feed') + if mode == 'feed': entries = [e for e in entries if e['visibility'] == 'household'] + elif mode == 'mine': entries = [e for e in entries if e['ownerId'] == user['id']] + elif mode == 'person': entries = [e for e in entries if e['ownerId'] == query.get('person')] + for field, compare in [('from', lambda x, y: x >= y), ('to', lambda x, y: x <= y)]: + if query.get(field): + self.date(query[field]); entries = [e for e in entries if compare(e['occurredOn'], query[field])] + search = query.get('search', '').strip().casefold()[:120] + if search: entries = [e for e in entries if search in (e['text']+' '+e['category']).casefold()] + if query.get('category'): entries = [e for e in entries if e['category'] == query['category']] + entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True) + try: page = max(0, min(100000, int(query.get('page', '0')))) + except ValueError: raise a.Problem('页码不正确') + media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')} + likes = a.HOUSEHOLDS.objects(user, 'journal_likes') + comments = a.HOUSEHOLDS.objects(user, 'journal_comments') + items = [] + for entry in entries[page*20:page*20+20]: + related = sorted([c for c in comments if c['entryId'] == entry['id'] and not c.get('deleted')], key=lambda c: c['createdAt']) + voters = [v for v in likes if v['entryId'] == entry['id'] and v['liked']] + items.append(dict(entry, media=[self.public_media(media[mid]) for mid in entry['mediaIds'] if mid in media], + canEdit=entry['ownerId'] == user['id'], likes=len(voters), liked=any(v['ownerId'] == user['id'] for v in voters), + comments=[dict(c, canDelete=c['ownerId'] == user['id'] or entry['ownerId'] == user['id']) for c in related[-100:]], + commentCount=len(related))) + uploads = [self.public_media(m) for m in media.values() if m['ownerId'] == user['id'] and m['status'] != 'removed'] + uploads.sort(key=lambda m: m['at'], reverse=True) + people = {e['ownerId']: e['authorName'] for e in a.objects('journal_entries') if self.visible(user, e)} + return dict(items=items, total=len(entries), page=page, uploads=uploads[:50], people=people, + connection=a.BAIDU.status(user), chunkSize=CHUNK, maxFile=MAX_FILE) + + def date(self, value): + try: + if dt.date.fromisoformat(value).isoformat() != value: raise ValueError() + except (TypeError, ValueError): raise self.a.Problem('请填写有效的公历日期') + return value + + def save(self, user, data): + a = self.a + with a.LOCK: + old = self.owned(user, 'journal_entries', data['id']) if data.get('id') else None + if old and old.get('deleted'): raise a.Problem('记录已经删除', 404) + if type(data.get('revision', 0)) is not int or data.get('revision', 0) != (old or {}).get('revision', 0): + raise a.Problem('记事已更新,请重新打开后保存', 409) + client = a.clean_text(data.get('clientId', ''), 32, True) + if not re.fullmatch('[a-f0-9]{32}', client): raise a.Problem('提交编号不正确') + if not old: + prior = next((e for e in a.objects('journal_entries') if e['ownerId'] == user['id'] and e['familyId'] == a.HOUSEHOLDS.scope(user) and e['clientId'] == client), None) + if prior: return {'id': prior['id'], 'duplicate': True} + raw_text = data.get('text', '') + if not isinstance(raw_text, str): raise a.Problem('记事内容须为文字') + text = a.clean_text(raw_text.replace('\r\n', '\n').replace('\n', '\u2028'), 5000).replace('\u2028', '\n') + visibility = data.get('visibility', 'private'); category = data.get('category', '日常') + if visibility not in ('private', 'household') or category not in KINDS: + raise a.Problem('可见范围或分类不正确') + ids = data.get('mediaIds', []) + if not isinstance(ids, list) or len(ids) > 9 or any(not isinstance(i, str) for i in ids) or len(set(ids)) != len(ids): + raise a.Problem('每条记事最多 9 个附件') + for mid in ids: + item = self.owned(user, 'journal_media', mid) + if item['status'] != 'ready': raise a.Problem('请等待附件上传完成后再发布') + if not text and not ids: raise a.Problem('请写下内容或添加附件') + now = dt.datetime.now(dt.timezone.utc).isoformat() + entry = dict(id=old['id'] if old else secrets.token_hex(8), familyId=a.HOUSEHOLDS.scope(user), + ownerId=user['id'], authorName=user['name'], text=text, category=category, + visibility=visibility, occurredOn=self.date(data.get('occurredOn')), + mediaIds=ids, createdAt=old['createdAt'] if old else now, updatedAt=now, + revision=(old or {}).get('revision', 0)+1, clientId=client, deleted=False) + a.save_object('journal_entries', entry) + return {'id': entry['id']} + + def action(self, user, data): + a = self.a + with a.LOCK: + entry = self.entry(user, data.get('id')) + action = data.get('action') + if action == 'delete': + self.owned(user, 'journal_entries', entry['id']) + if data.get('revision') != entry['revision']: raise a.Problem('记事已更新,请重新读取', 409) + entry.update(deleted=True, revision=entry['revision']+1); a.save_object('journal_entries', entry) + elif action == 'like': + if not isinstance(data.get('liked'), bool): raise a.Problem('点赞状态不正确') + key = hashlib.sha256((entry['id']+user['id']).encode()).hexdigest()[:32] + a.save_object('journal_likes', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], liked=data['liked'])) + elif action == 'comment': + text = a.clean_text(data.get('text', ''), 500, True) + key = a.clean_text(data.get('clientId', ''), 32, True) + if not re.fullmatch('[a-f0-9]{32}', key): raise a.Problem('评论编号不正确') + old = a.get_object('journal_comments', key) + if old: + if old['ownerId'] != user['id'] or old['entryId'] != entry['id']: raise a.Problem('评论编号已使用', 409) + return {'ok': True} + count = sum(c['entryId'] == entry['id'] and not c.get('deleted') for c in a.objects('journal_comments')) + if count >= 100: raise a.Problem('本条记事已达到 100 条评论上限') + a.save_object('journal_comments', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], authorName=user['name'], text=text, createdAt=dt.datetime.now(dt.timezone.utc).isoformat())) + elif action == 'delete-comment': + comment = a.get_object('journal_comments', data.get('commentId')) + if not comment or comment['entryId'] != entry['id']: raise a.Problem('评论不存在', 404) + if user['id'] not in (entry['ownerId'], comment['ownerId']): raise a.Problem('只能删除自己的评论或自己记事下的评论', 403) + comment['deleted'] = True; a.save_object('journal_comments', comment) + else: raise a.Problem('记事操作不正确') + return {'ok': True} + + def begin_upload(self, user, data): + a = self.a + name = a.clean_text(data.get('name', ''), 120, True) + ext = os.path.splitext(name)[1].lower() + if ext not in TYPES or any(c in name for c in '/\\:*?"<>|') or name.startswith('.'): + raise a.Problem('请选择 JPG、PNG、WebP 照片,MP4、MOV、WebM 视频或常见音频文件') + size = a.integer(data.get('size'), 1, MAX_FILE) + hashes = data.get('hashes') + if not isinstance(hashes, list) or len(hashes) != (size+4194303)//4194304 or any(not isinstance(h, str) or not re.fullmatch('[a-f0-9]{32}', h) for h in hashes): + raise a.Problem('文件分片校验表不正确') + mime = 'audio/webm' if ext == '.webm' and data.get('mime', '').startswith('audio/') else TYPES[ext] + a.BAIDU.token('shared') + with self.guard: + for key, buffer in list(self.buffers.items()): + if buffer['touched'] < time.time()-600: self.buffers.pop(key) + prior = self.owned(user, 'journal_media', data['id']) if data.get('id') else None + if prior: + if prior['hashes'] != hashes or prior['size'] != size or prior['name'] != name: + raise a.Problem('请选择与原任务相同的文件') + if prior['status'] == 'ready': return self.public_media(prior) + if prior['status'] == 'removed': raise a.Problem('该附件已移除,请新建上传任务') + item = prior + else: + active = [m for m in a.objects('journal_media') if m['status'] == 'receiving' and m['ownerId'] == user['id']] + if len(active) >= 4: raise a.Problem('请先完成或移除现有上传任务,最多同时保留 4 个', 409) + key = secrets.token_hex(8); family = a.HOUSEHOLDS.scope(user) + instance = a.setting('cloud_instance') + if not instance: + instance = secrets.token_hex(8); a.set_setting('cloud_instance', instance) + remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/资料/'+instance+'/'+family+'/'+key+'/'+name + item = dict(id=key, familyId=family, ownerId=user['id'], name=name, mime=mime, size=size, + status='receiving', received=0, progress=0, error='', remotePath=remote, hashes=hashes, + at=dt.datetime.now(dt.timezone.utc).isoformat()) + a.save_object('journal_media', item) + try: + pre = a.BAIDU.prepare(item['remotePath'], size, hashes) + if pre.get('existing'): + item.update(status='ready', received=size, progress=100, fsId=str(pre['existing']['fs_id']), error='') + else: + # Precreate reports which cloud blocks are still missing; after browser/server + # restart resend from the first missing block, without any local file cache. + needed = pre.get('block_list', list(range(len(hashes)))) + if not isinstance(needed, list) or any(type(n) is not int or not 0 <= n < len(hashes) for n in needed): + raise a.Problem('网盘返回的分片索引不正确', 502) + received = min(needed)*4194304 if needed else size + item.update(uploadId=pre['uploadid'], received=received, progress=round(received/size*95), error='') + self.buffers.pop(item['id'], None) + a.save_object('journal_media', item) + except a.Problem as error: + item['error'] = str(error); a.save_object('journal_media', item); raise + return self.public_media(item) + + @staticmethod + def valid_signature(mime, raw): + return bool(mime == 'image/jpeg' and raw.startswith(b'\xff\xd8\xff') or + mime == 'image/png' and raw.startswith(b'\x89PNG\r\n\x1a\n') or + mime == 'image/webp' and raw.startswith(b'RIFF') and raw[8:12] == b'WEBP' or + mime in ('video/mp4', 'video/quicktime', 'audio/mp4') and raw[4:8] == b'ftyp' or + mime in ('video/webm', 'audio/webm') and raw.startswith(b'\x1a\x45\xdf\xa3') or + mime == 'audio/wav' and raw.startswith(b'RIFF') and raw[8:12] == b'WAVE' or + mime == 'audio/ogg' and raw.startswith(b'OggS') or + mime in ('audio/mpeg', 'audio/aac') and (raw.startswith(b'ID3') or len(raw) > 1 and raw[0] == 255 and raw[1] & 224 == 224)) + + def chunk(self, user, key, offset, content): + if not content or len(content) > CHUNK: raise self.a.Problem('文件分片大小不正确') + # At most two 4 MiB buffers; this lock covers only cloud upload, never the app DB. + with self.guard: + item = self.owned(user, 'journal_media', key) + if item['status'] != 'receiving' or not item.get('uploadId'): raise self.a.Problem('请重新选择文件继续上传', 409) + if offset < 0 or offset+len(content) > item['size']: raise self.a.Problem('文件分片位置不正确') + if offset < item['received']: return dict(self.public_media(item), received=item['received']) + for mid, value in list(self.buffers.items()): + if value['touched'] < time.time()-600: self.buffers.pop(mid) + if key not in self.buffers: + if len(self.buffers) >= 2: raise self.a.Problem('上传缓冲正在使用,请稍后重试', 429) + self.buffers[key] = dict(start=item['received'], data=bytearray(), touched=time.time()) + buffer = self.buffers[key]; end = buffer['start']+len(buffer['data']) + if offset == end: buffer['data'].extend(content) + elif offset < end and buffer['data'][offset-buffer['start']:offset-buffer['start']+len(content)] == content: pass + else: raise self.a.Problem('分片顺序或内容不正确,请重新选择文件', 409) + buffer['touched'] = time.time(); end = buffer['start']+len(buffer['data']) + if len(buffer['data']) > 4194304: raise self.a.Problem('分片超出缓冲上限') + if buffer['start'] == 0 and not self.valid_signature(item['mime'], buffer['data'][:32]): + self.buffers.pop(key); raise self.a.Problem('文件内容与格式不符,请选择原始媒体文件') + if len(buffer['data']) == 4194304 or end == item['size']: + index = buffer['start']//4194304 + self.a.BAIDU.send_block(item['remotePath'], item['uploadId'], index, bytes(buffer['data']), item['hashes'][index]) + item.update(received=end, progress=round(end/item['size']*95), error='') + self.a.save_object('journal_media', item); self.buffers.pop(key) + return dict(self.public_media(item), received=end) + + def finish_upload(self, user, key): + with self.guard: + item = self.owned(user, 'journal_media', key) + if item['status'] == 'ready': return self.public_media(item) + if item['status'] != 'receiving' or item['received'] != item['size']: + raise self.a.Problem('文件尚未完整上传,请重新选择原文件继续', 409) + result = self.a.BAIDU.complete(item) + item.update(status='ready', progress=100, fsId=str(result['fs_id']), error='') + self.a.save_object('journal_media', item) + return self.public_media(item) + + def remove_upload(self, user, key): + with self.guard, self.a.LOCK: + item = self.owned(user, 'journal_media', key) + if any(not e.get('deleted') and key in e['mediaIds'] for e in self.a.objects('journal_entries')): + raise self.a.Problem('附件仍被记事引用,请先编辑记事移除附件', 409) + self.buffers.pop(key, None) + item.update(status='removed'); self.a.save_object('journal_media', item) + return {'ok': True} diff --git a/test_app.py b/test_app.py index b820e62..1e62d51 100644 --- a/test_app.py +++ b/test_app.py @@ -1,5 +1,6 @@ """Isolated regression tests: python -m unittest -v test_app.""" import hashlib +import atexit import http.client import io import json @@ -16,6 +17,7 @@ os.environ['VISION_DATA'] = _data.name os.environ['VISION_RECORDINGS'] = _data.name + '/recordings' import app import playback +atexit.register(app.DB.close) class WebTests(unittest.TestCase): @@ -105,7 +107,7 @@ class WebTests(unittest.TestCase): self.assertEqual(headers['Cache-Control'], 'no-store') self.assertNotIn(b'__ASSET_VERSION__', body) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) - self.assertEqual(len(assets), 25) + self.assertEqual(len(assets), 27) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) diff --git a/test_appearance.py b/test_appearance.py index d1aaa6d..9b6ee69 100644 --- a/test_appearance.py +++ b/test_appearance.py @@ -73,7 +73,7 @@ class AppearanceTests(unittest.TestCase): listed=self.request('/api/households',self.owner)[1]['households'][0] self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed) self.assertIn('/api/households/cover?',listed['coverUrl']) - saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称'))[1] + saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称',profileRevision=1))[1] self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data()) for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]: c=http.client.HTTPConnection(*self.server.server_address) diff --git a/test_backups.py b/test_backups.py index d3d1f5b..0c7c46e 100644 --- a/test_backups.py +++ b/test_backups.py @@ -80,3 +80,16 @@ class BackupTests(unittest.TestCase): '2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw'] self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records)) self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data'])) + + def test_config_read_failure_always_releases_running_guard(self): + backup=self.source_file();backup.running=True + with patch.object(backup,'config',side_effect=RuntimeError('配置读取失败')): + result=backup.run() + self.assertFalse(backup.running);self.assertIn('配置读取失败',result['error']) + + def test_baidu_success_is_separate_from_network_mount_and_requires_opt_in(self): + backup=self.source_file() + with patch.object(app.BAIDU,'upload',return_value={'fs_id':123}) as upload, patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}): + result=backup.run();upload.assert_not_called();self.assertFalse(result['baiduOk']) + app.set_setting('backup_config',dict(backup.config(),baidu=True)) + result=backup.run();self.assertTrue(result['baiduOk']);self.assertFalse(result['networkOk']);self.assertEqual(result['baiduFileId'],'123') diff --git a/test_journal.js b/test_journal.js new file mode 100644 index 0000000..86cb990 --- /dev/null +++ b/test_journal.js @@ -0,0 +1,33 @@ +const test=require('node:test'),assert=require('node:assert/strict'),crypto=require('node:crypto'),fs=require('node:fs'); +const hash=require('./web/file-hash.js'); +test('upload MD5 matches RFC vectors and a multi-block file',()=>{ + for(const text of ['', 'a', 'abc', 'message digest', 'abcdefghijklmnopqrstuvwxyz', '照片与语音', 'x'.repeat(4194304)]){ + const bytes=Buffer.from(text);assert.equal(hash.md5(bytes),crypto.createHash('md5').update(bytes).digest('hex')); + } +}); +test('browser hashes in 4 MiB slices rather than loading the entire video',async()=>{ + const file=new Blob([Buffer.alloc(4194304,1),Buffer.from('tail')]);const values=await hash.blocks(file); + assert.equal(values.length,2);assert.equal(values[1],crypto.createHash('md5').update('tail').digest('hex')); + await assert.rejects(hash.blocks(file,()=>{},()=>false),/取消/); +}); +test('journals default to private and encode content before rendering',()=>{ + const html=fs.readFileSync('web/index.html','utf8'),js=fs.readFileSync('web/journal.js','utf8'); + assert.match(html,/

回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。


解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。

+ +

本场所监控墙

这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。

主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。