feat: add private journals and centralized Baidu media storage for v0.1.28
This commit is contained in:
@@ -5,6 +5,8 @@ runtime/
|
||||
*.log
|
||||
*.key
|
||||
*.env
|
||||
baidu-secrets.json
|
||||
baidu-secrets.partial
|
||||
__pycache__/
|
||||
.venv/
|
||||
mediamtx
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 界面风格
|
||||
|
||||
@@ -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'):
|
||||
|
||||
+29
-11
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+272
@@ -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}
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,/<select name="visibility"><option value="private">/);assert.match(js,/esc\(e.text\)/);assert.match(js,/esc\(c.text\)/);
|
||||
assert.doesNotMatch(js,/localStorage|indexedDB|caches\.open|access_token|secretKey.*localStorage/);
|
||||
});
|
||||
test('leave clears media URLs, live microphone tracks, and pending requests',()=>{
|
||||
const js=fs.readFileSync('web/journal.js','utf8');assert.match(js,/journalRequest\?\.abort\(\)/);assert.match(js,/URL.revokeObjectURL/);assert.match(js,/getTracks\(\).forEach\(t=>t.stop\(\)\)/);
|
||||
});
|
||||
test('new entry clears hidden edit identifiers even when form.reset preserves them',()=>{
|
||||
const vm=require('node:vm'),js=fs.readFileSync('web/journal.js','utf8');
|
||||
const form={elements:{id:{value:'old-entry'},revision:{value:'9'},clientId:{value:'old-request'}},reset(){}};
|
||||
const context={$:()=>form};vm.createContext(context);
|
||||
vm.runInContext(js.match(/function clearJournalForm\(\)\{[^\n]+/)[0]+'\nclearJournalForm();',context);
|
||||
for(const field of Object.values(form.elements))assert.equal(field.value,'');
|
||||
assert.match(js,/function newJournal\(\)\{clearJournalForm\(\)/);
|
||||
assert.match(js,/function resetJournal\(\).*clearJournalForm\(\)/);
|
||||
});
|
||||
test('initial composer setup happens before asynchronous loading and never clears a typed draft',()=>{
|
||||
const js=fs.readFileSync('web/journal.js','utf8');const fn=js.slice(js.indexOf('async function loadJournal'),js.indexOf('function renderJournalUploads'));
|
||||
assert.ok(fn.indexOf('newJournal()')<fn.indexOf('await api('));assert.equal(fn.match(/newJournal\(\)/g).length,1);
|
||||
});
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import hashlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from test_app import app
|
||||
import test_households as fixture
|
||||
from baidu import Baidu, download_url
|
||||
from journal import Journal, CHUNK
|
||||
|
||||
|
||||
class JournalTests(unittest.TestCase):
|
||||
stop_server = fixture.HouseholdTests.stop_server
|
||||
request = fixture.HouseholdTests.request
|
||||
|
||||
def setUp(self):
|
||||
fixture.HouseholdTests.setUp(self)
|
||||
for name, value in [('BAIDU', Baidu(app)), ('JOURNAL', Journal(app))]:
|
||||
p = patch.object(app, name, value); p.start(); self.addCleanup(p.stop)
|
||||
self.actor = self.members[self.aid]
|
||||
|
||||
def note(self, **changes):
|
||||
return dict(text='今日记事\n第二行', occurredOn='2026-10-04', category='日常',
|
||||
clientId=secrets.token_hex(16), visibility='private', revision=0, mediaIds=[], **changes)
|
||||
|
||||
def saved(self, user=None, visibility='private'):
|
||||
body = self.note(); body['visibility'] = visibility
|
||||
return app.JOURNAL.save(user or self.actor, body)['id']
|
||||
|
||||
def test_private_notes_are_not_readable_by_family_manager_or_superadmin(self):
|
||||
key = self.saved()
|
||||
self.assertEqual(len(app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items']), 1)
|
||||
for user in (self.owner, self.root, self.other, self.members[self.bid]):
|
||||
self.assertEqual(app.JOURNAL.snapshot(user, {'mode':'person','person':self.actor['id']})['items'], [])
|
||||
self.assertEqual(self.request('/api/journal/action', user, dict(id=key,action='like',liked=True))[0], 404)
|
||||
|
||||
def test_shared_feed_history_search_and_date_filters_reference_the_same_record(self):
|
||||
key = self.saved(visibility='household')
|
||||
for mode in ('feed','person'):
|
||||
result = app.JOURNAL.snapshot(self.owner, dict(mode=mode,person=self.actor['id'],search='第二行',category='日常',**{'from':'2026-10-01','to':'2026-10-31'}))
|
||||
self.assertEqual(result['items'][0]['id'], key)
|
||||
self.assertEqual(app.JOURNAL.snapshot(self.other, {'mode':'feed'})['total'], 0)
|
||||
self.assertEqual(app.JOURNAL.snapshot(self.owner, {'from':'2026-10-05'})['total'], 0)
|
||||
|
||||
def test_account_cannot_forge_other_household_or_edit_anothers_note(self):
|
||||
key = self.saved(visibility='household')
|
||||
body = dict(app.get_object('journal_entries', key), text='stolen')
|
||||
self.assertEqual(self.request('/api/journal', self.owner, body)[0], 403)
|
||||
self.assertEqual(self.request('/api/journal', self.actor, self.note(), home=self.bid)[0], 403)
|
||||
|
||||
def test_creation_retry_and_edit_conflict(self):
|
||||
body = self.note(); first = app.JOURNAL.save(self.actor, body); second = app.JOURNAL.save(self.actor, body)
|
||||
self.assertEqual(first['id'], second['id']); self.assertTrue(second['duplicate'])
|
||||
old = app.get_object('journal_entries', first['id'])
|
||||
self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200)
|
||||
self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409)
|
||||
|
||||
def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self):
|
||||
key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True)
|
||||
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200)
|
||||
row = app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items'][0]; self.assertEqual(row['likes'],1)
|
||||
app.JOURNAL.save(self.actor, dict(app.get_object('journal_entries', key),visibility='private'))
|
||||
self.assertEqual(self.request('/api/journal/action', self.owner, dict(data,liked=False))[0],404)
|
||||
|
||||
def test_comments_allow_author_or_entry_owner_to_remove_but_no_other_member(self):
|
||||
key = self.saved(visibility='household'); cid=secrets.token_hex(16)
|
||||
data=dict(id=key,action='comment',clientId=cid,text='你好')
|
||||
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0],200)
|
||||
self.assertEqual(len(app.objects('journal_comments')),1)
|
||||
delete=dict(id=key,action='delete-comment',commentId=cid)
|
||||
self.assertEqual(self.request('/api/journal/action', self.backup, delete)[0],403)
|
||||
self.assertEqual(self.request('/api/journal/action', self.actor, delete)[0],200)
|
||||
|
||||
def test_unready_or_foreign_media_cannot_be_attached_and_unlinked_media_is_private(self):
|
||||
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',size=8,name='x.png',mime='image/png',at='now')
|
||||
app.save_object('journal_media', media)
|
||||
body=self.note();body['mediaIds']=[media['id']]
|
||||
self.assertEqual(self.request('/api/journal', self.owner, body)[0],403)
|
||||
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner, media['id'])
|
||||
body['visibility']='household';key=app.JOURNAL.save(self.actor,body)['id']
|
||||
self.assertEqual(app.JOURNAL.media(self.owner,media['id'])['id'],media['id'])
|
||||
app.JOURNAL.action(self.actor,dict(id=key,action='delete',revision=1))
|
||||
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner,media['id'])
|
||||
|
||||
def test_upload_sends_bounded_blocks_without_writing_any_media_file(self):
|
||||
raw=b'\x89PNG\r\n\x1a\n'+b'a'*(CHUNK+20)
|
||||
before=set(app.DATA.rglob('*'))
|
||||
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}), patch.object(app.BAIDU,'send_block') as send:
|
||||
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
|
||||
first=app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]);self.assertEqual(first['received'],CHUNK)
|
||||
app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]) # idempotent partial retry
|
||||
app.JOURNAL.chunk(self.actor,task['id'],CHUNK,raw[CHUNK:]);send.assert_called_once()
|
||||
self.assertEqual(send.call_args.args[3],raw)
|
||||
self.assertEqual(set(app.DATA.rglob('*')), before)
|
||||
self.assertFalse(app.JOURNAL.buffers)
|
||||
|
||||
def test_resume_after_restart_uses_cloud_missing_blocks(self):
|
||||
data=dict(name='v.mp4',size=4194305,hashes=['a'*32,'b'*32])
|
||||
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[1]}):
|
||||
task=app.JOURNAL.begin_upload(self.actor,data)
|
||||
self.assertEqual(task['received'],4194304)
|
||||
again=app.JOURNAL.begin_upload(self.actor,dict(data,id=task['id']))
|
||||
self.assertEqual(again['id'],task['id'])
|
||||
|
||||
def test_raw_upload_api_auth_and_foreign_user(self):
|
||||
raw=b'\x89PNG\r\n\x1a\nabc'
|
||||
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}):
|
||||
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
|
||||
c=http.client.HTTPConnection(*self.server.server_address)
|
||||
c.request('POST','/api/journal/chunk?id='+task['id']+'&offset=0',raw,{'Cookie':'vision='+self.tokens[self.owner['id']]})
|
||||
r=c.getresponse();r.read();c.close();self.assertEqual(r.status,403)
|
||||
|
||||
def test_media_and_oauth_metadata_do_not_reveal_tokens_or_remote_paths(self):
|
||||
self.assertEqual(self.request('/api/baidu/config',self.owner)[0],403)
|
||||
self.assertEqual(self.request('/api/baidu/connect',self.owner,{})[0],403)
|
||||
self.assertEqual(self.request('/api/baidu/disconnect',self.owner,{})[0],403)
|
||||
body=app.JOURNAL.snapshot(self.actor,{'mode':'mine'})
|
||||
self.assertNotIn('secretKey',json.dumps(body));self.assertNotIn('access_token',json.dumps(body))
|
||||
|
||||
def test_media_http_requires_visibility_and_preserves_range_without_cloud_redirect(self):
|
||||
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',
|
||||
size=10,name='clip.mp4',mime='video/mp4',at='now',fsId='123',remotePath='/private/clip.mp4')
|
||||
app.save_object('journal_media',media)
|
||||
key=app.JOURNAL.save(self.actor,dict(self.note(),mediaIds=[media['id']]))['id']
|
||||
path='/media/journal?id='+media['id']+'&family='+self.aid
|
||||
def fetch(user):
|
||||
c=http.client.HTTPConnection(*self.server.server_address)
|
||||
headers={'Range':'bytes=2-4'}
|
||||
if user: headers['Cookie']='vision='+self.tokens[user['id']]
|
||||
c.request('GET',path,headers=headers);r=c.getresponse();result=(r.status,dict(r.getheaders()),r.read());c.close();return result
|
||||
with patch.object(app.BAIDU,'open_file') as opened:
|
||||
self.assertEqual(fetch(None)[0],401)
|
||||
self.assertEqual(fetch(self.owner)[0],404)
|
||||
opened.assert_not_called()
|
||||
app.JOURNAL.save(self.actor,dict(app.get_object('journal_entries',key),visibility='household'))
|
||||
response=io.BytesIO(b'234');response.status=206
|
||||
response.headers={'Content-Length':'3','Content-Range':'bytes 2-4/10'};opened.return_value=response
|
||||
status,headers,body=fetch(self.owner)
|
||||
self.assertEqual((status,body),(206,b'234'));self.assertEqual(headers['Cache-Control'],'no-store')
|
||||
self.assertEqual(headers['Content-Range'],'bytes 2-4/10');self.assertNotIn('Location',headers)
|
||||
self.assertEqual(opened.call_args.args[2],'bytes=2-4')
|
||||
|
||||
def config(self):
|
||||
return dict(appKey='fixtureKey',secretKey='fixtureSecret',appFolder='测试',redirectUri='https://example.org/api/baidu/callback',revision=0)
|
||||
|
||||
def test_config_revisions_callback_validation_and_secret_not_returned(self):
|
||||
body=app.BAIDU.configure(self.config(),self.root)
|
||||
self.assertTrue(body['hasSecret']);self.assertNotIn('secretKey',body)
|
||||
with self.assertRaises(app.Problem): app.BAIDU.configure(self.config(),self.root)
|
||||
for address in ('http://example.org/api/baidu/callback','https://example.org/other','https://a:b@example.org/api/baidu/callback'):
|
||||
with self.assertRaises(app.Problem): app.BAIDU.configure(dict(self.config(),revision=1,redirectUri=address),self.root)
|
||||
|
||||
def test_oauth_state_cookie_session_and_replay_protection(self):
|
||||
app.BAIDU.configure(self.config(),self.root)
|
||||
result,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
|
||||
state=cookie.split(';')[0].split('=')[1]
|
||||
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),'')
|
||||
token=dict(access_token='test-token',refresh_token='test-refresh',expires=time.time()+1000)
|
||||
with patch.object(app.BAIDU,'token_response',return_value=token),patch.object(app.BAIDU,'json_request',return_value={'uk':123,'baidu_name':'测试网盘'}):
|
||||
app.BAIDU.callback(dict(state=state,code='x'),cookie)
|
||||
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
|
||||
self.assertTrue(app.BAIDU.status(self.actor)['connected'])
|
||||
self.assertNotIn('test-token',json.dumps(app.BAIDU.status(self.actor)))
|
||||
|
||||
def test_logged_out_or_demoted_oauth_cannot_bind_account(self):
|
||||
app.BAIDU.configure(self.config(),self.root)
|
||||
_,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
|
||||
state=cookie.split(';')[0].split('=')[1];app.DB.execute('DELETE FROM sessions');app.DB.commit()
|
||||
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
|
||||
|
||||
def test_download_redirect_targets_are_constrained(self):
|
||||
for address in ('https://d.pcs.baidu.com/file/a','https://x.baidupcs.com/a'):
|
||||
self.assertEqual(download_url(address),address)
|
||||
for address in ('http://d.pcs.baidu.com/a','https://evil.test/a','https://baidu.com.evil.test/a','https://user@baidu.com/a','https://127.0.0.1/a'):
|
||||
with self.assertRaises(ValueError): download_url(address)
|
||||
|
||||
def test_removed_attachment_and_invalid_text_never_publish(self):
|
||||
bad=self.note();bad['text']=[]
|
||||
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
|
||||
bad=self.note();bad['occurredOn']='2026-99-99'
|
||||
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
|
||||
self.assertEqual(self.request('/api/journal',self.actor,dict(self.note(),visibility='public'))[0],400)
|
||||
|
||||
def test_household_profile_revision_preserves_independent_leadership(self):
|
||||
home=app.get_object('households',self.aid)
|
||||
data=dict(home,name='第一次修改')
|
||||
self.assertEqual(self.request('/api/households',self.root,data)[0],200)
|
||||
self.assertEqual(self.request('/api/households',self.root,data)[0],409)
|
||||
self.assertEqual(app.get_object('households',self.aid)['name'],'第一次修改')
|
||||
@@ -60,3 +60,9 @@ test('settings opened before login resolves reloads once after the account is kn
|
||||
c.onAppearanceSession({values:{},defaults:{},revision:0});c.onAppearanceSession({values:{},defaults:{},revision:0});
|
||||
assert.equal(loads,1);assert.equal(c.appearanceOwner,'root');
|
||||
});
|
||||
test('replacing or resetting an image invalidates older decode jobs',()=>{
|
||||
const c=vm.createContext({pageInfo:{},document:{documentElement:{dataset:{}}}});
|
||||
vm.runInContext(source.slice(0,source.indexOf('function applyBranding(')),c);
|
||||
const first=c.nextImageJob('logo'),second=c.nextImageJob('logo');assert.equal(c.imageJobCurrent('logo',first),false);assert.equal(c.imageJobCurrent('logo',second),true);
|
||||
c.invalidateBrandImages();assert.equal(c.imageJobCurrent('logo',second),false);
|
||||
});
|
||||
|
||||
+6
-3
@@ -25,6 +25,7 @@ function applyPermissions(){
|
||||
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
|
||||
}
|
||||
function clearPrivateView(){
|
||||
if(typeof resetJournal==='function')resetJournal();
|
||||
if(typeof resetAppearance==='function')resetAppearance();
|
||||
if(typeof clearSnapshot==='function')clearSnapshot();
|
||||
if(typeof resetDeviceView==='function')resetDeviceView();
|
||||
@@ -57,17 +58,18 @@ function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendant
|
||||
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
|
||||
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
|
||||
function showPage(page, options={}) {
|
||||
if(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
|
||||
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
|
||||
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings'].includes(page))return;
|
||||
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].includes(page))return;
|
||||
if(['heritage','family','heritage-records'].includes(page)&¤tHome()?.kind&¤tHome().kind!=='home')return;
|
||||
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
|
||||
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
|
||||
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
|
||||
if(page==='storage'&&!isPlatformAdmin())return;
|
||||
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
|
||||
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
||||
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
|
||||
activePage = page;
|
||||
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings'].includes(page);
|
||||
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].includes(page);
|
||||
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
|
||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
|
||||
@@ -80,6 +82,7 @@ function showPage(page, options={}) {
|
||||
if (page === 'users') loadUsers();
|
||||
if(page==='heritage-records')loadHeritage();
|
||||
if(page==='settings'&&typeof loadSettings==='function')loadSettings();
|
||||
if(page==='journal'&&typeof loadJournal==='function')loadJournal();
|
||||
}
|
||||
function render() {
|
||||
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
|
||||
|
||||
@@ -294,3 +294,5 @@ html[data-theme=ink] .home-cover .quiet{background:#172a25df;color:#fff}
|
||||
html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263c3340,transparent 65%)}
|
||||
@media(min-width:761px){html[data-layout=top] .sidebar{position:relative;width:100%;padding:16px 30px;display:flex;align-items:center;flex-wrap:wrap;gap:12px 22px;min-height:0;height:auto;border-right:0;border-bottom:1px solid var(--line)}html[data-layout=top] .sidebar .brand{padding:0;max-width:250px}html[data-layout=top] .sidebar>.nav-label,html[data-layout=top] .side-note{display:none}html[data-layout=top] .sidebar nav{display:flex;flex:1;flex-wrap:wrap;min-width:250px;gap:6px}html[data-layout=top] #scope-navigation{display:flex;flex-wrap:wrap;width:100%;order:1;margin:0;padding:0;border:0}html[data-layout=top] #scope-navigation .nav-label{display:none}html[data-layout=top] .sidebar #current-user{margin:0;padding:0;border:0}html[data-layout=top] .sidebar>.quiet{margin:0}html[data-layout=top] main{margin-left:0;margin-right:auto;margin-left:auto}html[data-layout=top] .sidebar nav button{width:auto;white-space:nowrap}}
|
||||
@media(max-width:760px){.brand-image-grid{grid-template-columns:1fr}.home-cover-content{padding:20px}.home-card-body{padding:20px}.home-cover-placeholder{font-size:15px}.sidebar #logout{display:block}#page-settings .form-grid{grid-template-columns:1fr}html[data-density=compact] main{padding:18px 14px}#household-form .form-grid{grid-template-columns:1fr}.home-city-group>h3 small{display:block;margin:8px 0}}
|
||||
/* Personal journals: a quiet paper timeline with bounded media panels. */
|
||||
.journal-feed{max-width:1080px;margin:0 auto}.journal-compose textarea{resize:vertical;min-height:130px}.journal-compose>label{display:grid;gap:12px}.journal-add-media,.journal-reactions{display:flex;flex-wrap:wrap;gap:12px;margin:20px 0}.journal-add-media>label{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.journal-add-media input{max-width:260px}.journal-filters{display:flex;align-items:end;flex-wrap:wrap;gap:16px}.journal-filters label{flex:1;min-width:145px;display:grid;gap:7px}.journal-entry{border-left:3px solid #a98964;margin-bottom:26px}.journal-entry .section-head small{display:block;color:var(--muted);margin-top:6px}.journal-text{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.95;font-size:17px}.journal-author{font-size:20px!important;font-family:serif}.journal-media{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px}.journal-media figure{margin:0;min-width:0}.journal-media img,.journal-media video{width:100%;max-height:450px;object-fit:contain;border-radius:8px;background:#1a2925}.journal-media audio{width:100%;max-width:100%}.journal-media figcaption{font-size:13px;overflow-wrap:anywhere;margin-top:6px}.journal-image-load{width:100%;min-height:150px;background:#e9eee2!important}.journal-upload{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;border-bottom:1px solid #d9d7c8;padding:12px 0}.journal-upload label{flex:1;overflow-wrap:anywhere}.journal-upload small{display:block}.journal-upload input[type=checkbox]{width:auto}.journal-resume input{max-width:220px}.journal-comments{border-top:1px solid #d9d7c8;padding-top:15px}.journal-comments form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.journal-comments form label{flex:1;min-width:170px}.journal-comment{padding:10px 0;border-bottom:1px dashed #d9d7c8;overflow-wrap:anywhere}.journal-comment small{font-size:11px;color:var(--muted)}.journal-reactions [aria-pressed=true]{color:#ad5842!important}.journal-comments .quiet{padding:4px 8px;font-size:12px}.journal-feed .panel{overflow:hidden}html[data-theme=ink] .journal-image-load{background:#293f37!important}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
// MD5 is required by Baidu's upload protocol; it is not used for authentication.
|
||||
const ZhaoFileHash=(()=>{
|
||||
const shifts=[7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
|
||||
const constants=Array.from({length:64},(_,i)=>Math.floor(Math.abs(Math.sin(i+1))*4294967296)|0);
|
||||
function md5(input){
|
||||
const bytes=new Uint8Array(input),length=bytes.length,padded=new Uint8Array(Math.ceil((length+9)/64)*64);
|
||||
padded.set(bytes);padded[length]=128;const view=new DataView(padded.buffer);
|
||||
view.setUint32(padded.length-8,(length*8)>>>0,true);view.setUint32(padded.length-4,Math.floor(length/536870912),true);
|
||||
let h=[0x67452301,0xefcdab89|0,0x98badcfe|0,0x10325476];
|
||||
for(let offset=0;offset<padded.length;offset+=64){
|
||||
let [a,b,c,d]=h;
|
||||
for(let i=0;i<64;i++){
|
||||
let f,g;
|
||||
if(i<16){f=(b&c)|(~b&d);g=i;}
|
||||
else if(i<32){f=(d&b)|(~d&c);g=(5*i+1)%16;}
|
||||
else if(i<48){f=b^c^d;g=(3*i+5)%16;}
|
||||
else{f=c^(b|~d);g=(7*i)%16;}
|
||||
const shift=shifts[Math.floor(i/16)*4+i%4],sum=(a+f+constants[i]+view.getInt32(offset+g*4,true))|0;
|
||||
const next=(b+((sum<<shift)|(sum>>>(32-shift))))|0;a=d;d=c;c=b;b=next;
|
||||
}
|
||||
h=[(h[0]+a)|0,(h[1]+b)|0,(h[2]+c)|0,(h[3]+d)|0];
|
||||
}
|
||||
return h.map(n=>[0,8,16,24].map(s=>((n>>>s)&255).toString(16).padStart(2,'0')).join('')).join('');
|
||||
}
|
||||
async function blocks(file,progress=()=>{},valid=()=>true){
|
||||
const result=[];
|
||||
for(let offset=0;offset<file.size;offset+=4194304){
|
||||
if(!valid())throw Object.assign(Error('操作已取消'),{stale:true});
|
||||
result.push(md5(await file.slice(offset,offset+4194304).arrayBuffer()));progress(Math.min(file.size,offset+4194304),file.size);
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return {md5,blocks};
|
||||
})();
|
||||
if(typeof module!=='undefined')module.exports=ZhaoFileHash;
|
||||
+6
-5
@@ -22,7 +22,7 @@ function openHousehold(id){
|
||||
if(!isAdmin())return;
|
||||
const item=(state.households||[]).find(h=>h.id===id);
|
||||
if(!item&&!isPlatformAdmin())return;
|
||||
fillForm($('#household-form'),item||{});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal();
|
||||
nextImageJob('cover');fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal();
|
||||
}
|
||||
async function openLeadership(id){
|
||||
try{
|
||||
@@ -46,8 +46,8 @@ $('#leadership-form').addEventListener('submit',async e=>{
|
||||
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
|
||||
$('#new-household').onclick=()=>openHousehold();
|
||||
$('#household-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{await api('/api/households',{...formValues(f),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
|
||||
e.preventDefault();if(imageJobPending('cover')){$('#household-cover-status').textContent='照片仍在读取,请完成后保存。';return;}const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{await api('/api/households',{...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
|
||||
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
|
||||
finally{b.disabled=false;}
|
||||
});
|
||||
@@ -58,5 +58,6 @@ document.addEventListener('click',async e=>{
|
||||
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
|
||||
});
|
||||
|
||||
$('#household-cover-file').onchange=async e=>{const householdId=$('#household-form').elements.id.value,generation=sessionGeneration;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!$('#household-dialog').open||$('#household-form').elements.id.value!==householdId)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){$('#household-cover-status').textContent=e.message;}};
|
||||
$('#household-cover-reset').onclick=()=>{coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
|
||||
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
|
||||
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
|
||||
$('#household-dialog').addEventListener('close',()=>nextImageJob('cover'));
|
||||
|
||||
+11
-2
@@ -7,6 +7,7 @@
|
||||
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script><script defer src="/steward.js?v=__ASSET_VERSION__"></script><script defer src="/heritage.js?v=__ASSET_VERSION__"></script><script defer src="/personalization.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/file-hash.js?v=__ASSET_VERSION__"></script><script defer src="/journal.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
@@ -15,6 +16,7 @@
|
||||
<nav aria-label="主导航">
|
||||
<button data-page="households" class="active">⌂ <span>我的家庭与场所</span></button>
|
||||
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
|
||||
<button data-page="journal">✎ <span>记事与阅历</span></button>
|
||||
<button data-page="overview">◫ <span>场所总览</span></button>
|
||||
<button data-page="objects">▣ <span>区域与设备</span></button>
|
||||
<button data-page="heritage">♧ <span>家庭传承</span></button>
|
||||
@@ -36,15 +38,22 @@
|
||||
<section id="page-settings" class="page" hidden>
|
||||
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p>
|
||||
<form id="preferences-form" class="panel"><h3>我的后台</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
|
||||
<section id="baidu-admin" class="panel" hidden><div class="section-head"><h3>百度网盘 · 统一存储</h3><span class="badge">仅超级管理员配置</span></div><p class="description">连接一个网盘账号,保存各用户上传的照片、视频和语音。系统按用户、家庭和记事可见范围授权;网盘账号持有人本身可以管理网盘中的文件。服务器仅转发分片,不保存媒体原文件。</p><form id="baidu-config"><div class="form-grid"><label>AppKey<input name="appKey" autocomplete="off" maxlength="150" required></label><label>SecretKey<input name="secretKey" type="password" autocomplete="new-password" maxlength="150"></label><label>百度后台应用名称<input name="appFolder" value="智家" required maxlength="40"></label><label>OAuth 回调地址<input name="redirectUri" type="url" placeholder="https://你的域名/api/baidu/callback" required maxlength="400"></label></div><p class="description">回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。</p><p id="baidu-config-status" role="status"></p><button type="submit">保存网盘应用配置</button></form><hr><p id="baidu-status" role="status"></p><div class="device-actions"><button id="baidu-connect">连接/重新授权百度网盘</button><button id="baidu-disconnect" class="quiet">解除本机连接</button></div><p class="description">解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。</p></section>
|
||||
<div id="settings-private" hidden>
|
||||
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
|
||||
<div class="form-grid"><label>系统名称<input name="name" required maxlength="40"></label><label>系统副标题<input name="subtitle" required maxlength="60"></label><label class="full">页首题词<input name="motto" maxlength="100"></label><label>登录标题<input name="loginTitle" required maxlength="40"></label><label>登录说明<input name="loginNote" maxlength="160"></label><label class="full">页脚说明<input name="footer" maxlength="120"></label></div>
|
||||
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
|
||||
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
|
||||
</form>
|
||||
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>每个目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。百度网盘需先有挂载或同步服务;网页地址、分享链接不能直接使用。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本系统生成的旧备份。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
|
||||
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>每个目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度云端副本需自行整理。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
|
||||
</div>
|
||||
</section>
|
||||
<section id="page-journal" class="page" hidden>
|
||||
<div class="scope-intro"><span class="eyebrow">岁月有迹 · 日常成章</span><h2>把生活,写成自己的来时路</h2><p>与家人分享近况,也为自己留下一份阅历。</p></div>
|
||||
<form id="journal-form" class="panel journal-compose"><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="clientId"><label>此刻想记下什么?<textarea name="text" rows="4" maxlength="5000" placeholder="今天的心情、一段往事,或一个值得记住的时刻…"></textarea></label><div class="form-grid"><label>记事日期<input name="occurredOn" type="date" required></label><label>阅历分类<select name="category"><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>谁可以看到<select name="visibility"><option value="private">仅自己可见</option><option value="household">当前家庭/场所成员</option></select></label></div><div class="journal-add-media"><label class="quiet">+ 照片、视频或语音<input id="journal-files" disabled type="file" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/webm,.m4a"></label><button id="journal-record" disabled type="button" class="quiet">● 直接录音</button><button id="journal-stop-record" type="button" hidden>结束录音并上传</button><button id="journal-cancel-upload" type="button" class="quiet" hidden>暂停上传</button></div><p id="journal-cloud-note" class="description"></p><p class="description">每条最多 9 个附件,单个文件最多 256 MB;一次上传一个文件。录音须通过 HTTPS 并允许麦克风,最长 5 分钟。上传完成后勾选附件再保存;已有附件可重复引用。</p><div id="journal-upload-list"></div><p id="journal-status" role="status" aria-live="polite"></p><div class="device-actions"><button id="journal-save" type="submit">保存记事</button><button id="journal-new" type="button" class="quiet">另写一条</button></div></form>
|
||||
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
|
||||
</section>
|
||||
|
||||
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-playback" class="page" hidden>
|
||||
@@ -93,7 +102,7 @@
|
||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
||||
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
|
||||
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label>省份<input name="province" maxlength="60" placeholder="例如:山东省"></label><label>地级市<input name="city" maxlength="60" placeholder="例如:烟台市"></label><label>区 / 县 / 县级市<input name="district" maxlength="60" placeholder="例如:莱阳市"></label><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
|
||||
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label>省份<input name="province" maxlength="60" placeholder="例如:山东省"></label><label>地级市<input name="city" maxlength="60" placeholder="例如:烟台市"></label><label>区 / 县 / 县级市<input name="district" maxlength="60" placeholder="例如:莱阳市"></label><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
|
||||
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
|
||||
<datalist id="relationship-choices"></datalist>
|
||||
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
pageInfo.journal=['个人记事','一言一照,都是阅历。私藏或分享,由自己决定。'];
|
||||
let journalGeneration=0,journalData=null,journalPage=0,journalUpload=false,journalRequest=null,journalRecorder=null,journalTracks=null,journalVoice=[],journalVoiceTimer=null,journalConfigRevision=0;
|
||||
const journalImages=new Map();let journalImageBytes=0;
|
||||
const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');};
|
||||
function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;}
|
||||
function journalMessage(text){$('#journal-status').textContent=text;}
|
||||
function journalValid(g){return g===journalGeneration&&loggedIn&&activePage==='journal';}
|
||||
function journalMediaUrl(id,download=false){return '/media/journal?'+new URLSearchParams({id,family:state.familyId,...(download?{download:'1'}:{})});}
|
||||
function stopJournalVoice(discard=true){
|
||||
if(discard)journalVoice=[];if(journalRecorder&&journalRecorder.state!=='inactive')journalRecorder.stop();
|
||||
journalTracks?.getTracks().forEach(t=>t.stop());journalTracks=null;clearTimeout(journalVoiceTimer);journalVoiceTimer=null;
|
||||
$('#journal-record').disabled=false;$('#journal-stop-record').hidden=true;
|
||||
}
|
||||
function leaveJournal(){
|
||||
journalGeneration++;journalRequest?.abort();journalRequest=null;journalBusy(false);stopJournalVoice();
|
||||
$$('#journal-list video,#journal-list audio').forEach(v=>{v.pause();v.removeAttribute('src');v.load();});
|
||||
for(const image of journalImages.values())URL.revokeObjectURL(image.url);journalImages.clear();journalImageBytes=0;
|
||||
$('#journal-list').replaceChildren();$('#journal-files').disabled=false;$('#journal-save').disabled=false;
|
||||
}
|
||||
function clearJournalForm(){const f=$('#journal-form');f.reset();for(const key of ['id','revision','clientId'])f.elements[key].value='';}
|
||||
function resetJournal(){leaveJournal();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
|
||||
function newJournal(){clearJournalForm();const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
|
||||
async function loadJournal(){
|
||||
const g=++journalGeneration;if(!loggedIn)return;
|
||||
if(!$('#journal-form').elements.clientId.value)newJournal();
|
||||
$('#journal-person-label').hidden=$('#journal-mode').value!=='person';
|
||||
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage)});
|
||||
$('#journal-query-status').textContent='正在读取记事…';
|
||||
try{
|
||||
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;
|
||||
const selected=$('#journal-person').value;$('#journal-person').innerHTML='<option value="">请选择人物</option>'+Object.entries(result.people).map(([id,name])=>`<option value="${esc(id)}">${esc(name)}</option>`).join('');$('#journal-person').value=selected;
|
||||
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:'暂时没有记事。写下今天,也可以补记从前。';
|
||||
$('#journal-prev').disabled=!result.page;$('#journal-next').disabled=(result.page+1)*20>=result.total;
|
||||
$('#journal-cloud-note').textContent=result.connection.connected&&!result.connection.reconnect?'附件保存到后台统一连接的百度网盘;可见范围由系统控制。':'文字可直接保存。照片、视频、语音需由超级管理员先连接百度网盘。';
|
||||
$('#journal-files').disabled=journalUpload||!result.connection.connected||result.connection.reconnect;
|
||||
$('#journal-record').disabled=$('#journal-files').disabled||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;
|
||||
renderJournalUploads();renderJournalEntries();
|
||||
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
|
||||
}
|
||||
function renderJournalUploads(){
|
||||
const selected=new Set([...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value));
|
||||
const editing=journalData?.items.find(e=>e.id===$('#journal-form').elements.id.value);
|
||||
const available=[...(journalData?.uploads||[])];for(const media of editing?.media||[])if(!available.some(m=>m.id===media.id))available.push(media);
|
||||
$('#journal-upload-list').innerHTML=available.map(m=>`<div class="journal-upload"><label><input type="checkbox" value="${esc(m.id)}" ${m.status==='ready'?'':'disabled'} ${selected.has(m.id)?'checked':''}>${esc(m.name)} <small>${Math.ceil(m.size/1024)} KB · ${m.status==='ready'?'已存百度网盘':m.status==='receiving'?'等待继续上传':'已失效'}</small></label>${m.status==='receiving'?`<label class="quiet journal-resume">选择原文件继续<input type="file" data-journal-resume="${esc(m.id)}"></label>`:''}<button type="button" class="quiet" data-remove-upload="${esc(m.id)}">移出列表</button></div>`).join('');
|
||||
}
|
||||
function renderJournalEntries(){
|
||||
for(const value of journalImages.values())URL.revokeObjectURL(value.url);journalImages.clear();journalImageBytes=0;
|
||||
$('#journal-list').innerHTML=journalData.items.map(e=>`<article class="panel journal-entry" data-entry="${esc(e.id)}"><div class="section-head"><div><button class="quiet journal-author" data-journal-person="${esc(e.ownerId)}">${esc(e.authorName)}</button><small>${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}</small></div>${e.canEdit?`<div><button class="quiet" data-journal-edit="${esc(e.id)}">编辑</button><button class="quiet" data-journal-delete="${esc(e.id)}">删除</button></div>`:''}</div><p class="journal-text">${esc(e.text)}</p><div class="journal-media">${e.media.map(m=>m.mime.startsWith('image/')?`<figure><button class="journal-image-load quiet" data-load-photo="${esc(m.id)}" data-size="${m.size}">查看照片 · ${esc(m.name)}</button><img hidden alt="${esc(m.name)}" loading="lazy"></figure>`:`<figure><${m.mime.startsWith('audio/')?'audio':'video'} controls preload="none" playsinline src="${esc(journalMediaUrl(m.id))}"></${m.mime.startsWith('audio/')?'audio':'video'}><figcaption>${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></figcaption><small>点击播放后缓冲;编码不兼容时可下载原文件。</small></figure>`).join('')}</div><div class="journal-reactions"><button class="quiet" data-journal-like="${esc(e.id)}" aria-pressed="${e.liked}">${e.liked?'♥ 已赞':'♡ 点赞'} · ${e.likes}</button><button class="quiet" data-journal-person="${esc(e.ownerId)}">查看个人阅历</button></div><details class="journal-comments"><summary>评论 <span data-comment-count>${e.commentCount}</span></summary><div data-comments>${e.comments.map(c=>journalComment(e,c)).join('')}</div><form data-journal-comment="${esc(e.id)}"><label>写评论<input name="text" maxlength="500" required autocomplete="off"></label><button type="submit">发送</button><span class="form-error" role="status"></span></form></details></article>`).join('');
|
||||
}
|
||||
function journalComment(entry,c){return `<p class="journal-comment"><strong>${esc(c.authorName)}</strong> ${esc(c.text)} <small>${esc(new Date(c.createdAt).toLocaleString())}</small>${c.canDelete?` <button class="quiet" data-delete-comment="${esc(c.id)}" data-journal-entry="${esc(entry.id)}">删除评论</button>`:''}</p>`;}
|
||||
$('#journal-form').addEventListener('submit',async event=>{
|
||||
event.preventDefault();if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
|
||||
const f=event.currentTarget,g=journalGeneration,button=$('#journal-save');button.disabled=true;
|
||||
try{await api('/api/journal',{...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)});if(!journalValid(g))return;newJournal();$('#journal-mode').value='mine';journalPage=0;await loadJournal();journalMessage('已保存,动态与个人阅历使用同一条记录。');}
|
||||
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{if(journalValid(g)||activePage==='journal')button.disabled=false;}
|
||||
});
|
||||
$('#journal-new').onclick=newJournal;
|
||||
$('#journal-filter').onsubmit=e=>{e.preventDefault();journalPage=0;loadJournal();};
|
||||
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
|
||||
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
|
||||
async function uploadJournalFile(file,resume=''){
|
||||
if(!file||journalUpload)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
|
||||
journalBusy(true);const g=journalGeneration;$('#journal-save').disabled=true;$('#journal-files').disabled=true;$('#journal-cancel-upload').hidden=false;
|
||||
try{
|
||||
const hashes=await ZhaoFileHash.blocks(file,(n,total)=>journalMessage('正在计算文件校验:'+Math.round(n/total*100)+'%'),()=>journalValid(g)&&journalUpload);
|
||||
if(!journalValid(g)||!journalUpload)return;
|
||||
let task=await api('/api/journal/upload',{id:resume,name:file.name,size:file.size,mime:file.type,hashes});
|
||||
if(!journalValid(g)||!journalUpload)return;
|
||||
for(let offset=task.received;offset<file.size;){
|
||||
if(!journalValid(g)||!journalUpload)return;
|
||||
journalRequest=new AbortController();
|
||||
const response=await fetch('/api/journal/chunk?'+new URLSearchParams({id:task.id,offset:String(offset)}),{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Household-Id':state.familyId},body:file.slice(offset,Math.min(offset+524288,file.size)),signal:journalRequest.signal});
|
||||
const result=await response.json();if(!response.ok)throw Error(result.error||'上传未完成');
|
||||
if(!journalValid(g)||!journalUpload)return;
|
||||
if(result.received<=offset)throw Error('上传进度未前进,请选择原文件继续');offset=result.received;journalMessage('正在上传百度网盘:'+Math.round(offset/file.size*95)+'%');
|
||||
}
|
||||
await api('/api/journal/finish',{id:task.id});if(!journalValid(g)||!journalUpload)return;
|
||||
journalUpload=false;await loadJournal();const input=$('#journal-upload-list').querySelector(`input[value="${task.id}"]`);if(input)input.checked=true;journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。');
|
||||
}catch(e){if(!e.stale&&journalValid(g)){journalMessage(e.name==='AbortError'?'已暂停上传,可选择原文件继续。':e.message);journalUpload=false;const message=$('#journal-status').textContent;await loadJournal();journalMessage(message);}}
|
||||
finally{journalBusy(false);journalRequest=null;if(activePage==='journal'){$('#journal-save').disabled=false;$('#journal-files').disabled=!journalData?.connection.connected;$('#journal-cancel-upload').hidden=true;}}
|
||||
}
|
||||
$('#journal-files').onchange=e=>{uploadJournalFile(e.target.files[0]);e.target.value='';};
|
||||
$('#journal-cancel-upload').onclick=()=>{journalBusy(false);journalRequest?.abort();journalMessage('已暂停。重新选择原文件可继续上传。');};
|
||||
$('#journal-record').onclick=async()=>{
|
||||
if(journalUpload||!journalData?.connection.connected)return;
|
||||
const g=journalGeneration;try{
|
||||
const tracks=await navigator.mediaDevices.getUserMedia({audio:true});if(!journalValid(g)){tracks.getTracks().forEach(t=>t.stop());return;}
|
||||
journalTracks=tracks;journalVoice=[];const type=['audio/webm;codecs=opus','audio/mp4','audio/ogg;codecs=opus'].find(t=>MediaRecorder.isTypeSupported(t));
|
||||
if(!type){stopJournalVoice();throw Error('当前浏览器不能直接录音,可上传已有音频文件。');}
|
||||
const recorder=new MediaRecorder(tracks,{mimeType:type});journalRecorder=recorder;
|
||||
recorder.ondataavailable=e=>{if(journalValid(g)&&e.data.size)journalVoice.push(e.data);};
|
||||
recorder.onstop=()=>{if(journalValid(g)&&journalVoice.length){const blob=new Blob(journalVoice,{type}),extension=type.includes('mp4')?'m4a':type.includes('ogg')?'ogg':'webm';uploadJournalFile(new File([blob],'语音-'+Date.now()+'.'+extension,{type}));}journalVoice=[];};
|
||||
recorder.start();$('#journal-record').disabled=true;$('#journal-stop-record').hidden=false;journalMessage('正在录音,最长 5 分钟。点击结束后上传。');journalVoiceTimer=setTimeout(()=>stopJournalVoice(false),300000);
|
||||
}catch(e){if(journalValid(g))journalMessage(e.name==='NotAllowedError'?'未获得麦克风权限,可上传已有语音文件。':e.message);}
|
||||
};
|
||||
$('#journal-stop-record').onclick=()=>stopJournalVoice(false);
|
||||
document.addEventListener('change',e=>{if(e.target.dataset.journalResume){uploadJournalFile(e.target.files[0],e.target.dataset.journalResume);e.target.value='';}});
|
||||
document.addEventListener('click',async event=>{
|
||||
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
|
||||
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
|
||||
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
|
||||
if(b.dataset.loadPhoto){
|
||||
b.disabled=true;try{
|
||||
const id=b.dataset.loadPhoto;let cached=journalImages.get(id);
|
||||
if(!cached){if(Number(b.dataset.size)>20*1024*1024||journalImageBytes+Number(b.dataset.size)>20*1024*1024)throw Error('照片较大,请下载原图查看。');
|
||||
journalImageBytes+=Number(b.dataset.size);b.textContent='照片缓冲中…';const response=await fetch(journalMediaUrl(id));if(!response.ok)throw Error('照片读取失败,请稍后重试');const blob=await response.blob();if(!journalValid(g))return;
|
||||
cached={url:URL.createObjectURL(blob),size:blob.size};journalImages.set(id,cached);}
|
||||
const img=b.parentElement.querySelector('img');img.src=cached.url;img.hidden=false;b.hidden=true;
|
||||
}catch(e){if(journalValid(g)){if(b.textContent==='照片缓冲中…')journalImageBytes=Math.max(0,journalImageBytes-Number(b.dataset.size));b.textContent=e.message;b.disabled=false;const link=document.createElement('a');link.href=journalMediaUrl(b.dataset.loadPhoto,true);link.textContent='下载照片';b.after(link);}}return;
|
||||
}
|
||||
const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry;
|
||||
if(!id&&!b.dataset.removeUpload)return;b.disabled=true;
|
||||
try{
|
||||
if(b.dataset.removeUpload){await api('/api/journal/remove-upload',{id:b.dataset.removeUpload});if(journalValid(g))await loadJournal();return;}
|
||||
const entry=journalData.items.find(e=>e.id===id);if(!entry)return;
|
||||
if(b.dataset.journalLike){await api('/api/journal/action',{id,action:'like',liked:!entry.liked});if(!journalValid(g))return;entry.liked=!entry.liked;entry.likes+=entry.liked?1:-1;b.textContent=(entry.liked?'♥ 已赞':'♡ 点赞')+' · '+entry.likes;b.setAttribute('aria-pressed',String(entry.liked));}
|
||||
if(b.dataset.journalDelete){if(!confirm('删除这条记事?网盘原文件保留。'))return;await api('/api/journal/action',{id,action:'delete',revision:entry.revision});if(journalValid(g))loadJournal();}
|
||||
if(b.dataset.deleteComment){await api('/api/journal/action',{id,action:'delete-comment',commentId:b.dataset.deleteComment});if(journalValid(g)){b.closest('.journal-comment').remove();entry.commentCount--;document.querySelector(`[data-entry="${id}"] [data-comment-count]`).textContent=entry.commentCount;}}
|
||||
}catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{b.disabled=false;}
|
||||
});
|
||||
document.addEventListener('submit',async event=>{
|
||||
const f=event.target;if(!f.dataset.journalComment)return;event.preventDefault();const id=f.dataset.journalComment,g=journalGeneration,b=f.querySelector('button');b.disabled=true;const text=f.elements.text.value;
|
||||
f.dataset.clientId ||= journalId();
|
||||
try{await api('/api/journal/action',{id,action:'comment',text,clientId:f.dataset.clientId});if(!journalValid(g))return;
|
||||
const entry=journalData.items.find(e=>e.id===id),comment={id:f.dataset.clientId,text,authorName:currentUser.name,createdAt:new Date().toISOString(),canDelete:true};
|
||||
if(!entry.comments.some(c=>c.id===comment.id)){entry.comments.push(comment);entry.commentCount++;f.parentElement.querySelector('[data-comments]').insertAdjacentHTML('beforeend',journalComment(entry,comment));f.parentElement.querySelector('[data-comment-count]').textContent=entry.commentCount;}
|
||||
f.reset();delete f.dataset.clientId;f.querySelector('.form-error').textContent='';
|
||||
}catch(e){if(!e.stale&&journalValid(g))f.querySelector('.form-error').textContent=e.message;}finally{b.disabled=false;}
|
||||
});
|
||||
async function loadBaiduSettings(){
|
||||
const g=appearanceGeneration;$('#baidu-admin').hidden=!isPlatformAdmin();if(!isPlatformAdmin())return;
|
||||
try{const [config,status]=await Promise.all([api('/api/baidu/config'),api('/api/baidu/status')]);if(g!==appearanceGeneration||activePage!=='settings')return;
|
||||
const f=$('#baidu-config');for(const k of ['appKey','appFolder','redirectUri'])f.elements[k].value=config[k];f.elements.secretKey.value='';f.elements.secretKey.placeholder=config.hasSecret?'已保存,留空保持原值':'填写 SecretKey';journalConfigRevision=config.revision;
|
||||
$('#baidu-status').textContent=status.connected?(status.reconnect?'网盘授权已失效,请重新连接。':'已连接统一网盘:'+status.account):'尚未连接百度网盘';$('#baidu-connect').disabled=!config.configured;$('#baidu-disconnect').disabled=!status.connected;
|
||||
}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-status').textContent=e.message;}
|
||||
}
|
||||
$('#baidu-config').onsubmit=async e=>{e.preventDefault();const g=appearanceGeneration,f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api('/api/baidu/config',{...formValues(f),revision:journalConfigRevision});if(g!==appearanceGeneration)return;f.elements.secretKey.value='';await loadBaiduSettings();$('#baidu-config-status').textContent='应用配置已保存,下一步连接百度网盘。';}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-config-status').textContent=e.message;}finally{b.disabled=false;}};
|
||||
$('#baidu-connect').onclick=async()=>{try{const result=await api('/api/baidu/connect',{});const address=new URL(result.url);if(address.origin!=='https://openapi.baidu.com')throw Error('授权地址不正确');location.assign(result.url);}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
|
||||
$('#baidu-disconnect').onclick=async()=>{if(!confirm('解除本机网盘连接后,媒体将暂时无法访问;网盘文件保留。确认解除?'))return;try{await api('/api/baidu/disconnect',{});loadBaiduSettings();}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
|
||||
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
|
||||
+17
-9
@@ -5,6 +5,11 @@ const initialDefaults={theme:'paper',layout:'sidebar',density:'comfortable',back
|
||||
let branding={name:'赵府',subtitle:'智能数字管家',motto:'赵氏家宅 · 起居有序',loginTitle:'欢迎回来',loginNote:'输入用户名和密码,进入家庭空间。',footer:'赵府 · 智能数字管家',logoUrl:'/zhao-icon.png',iconUrl:'/zhao-icon.png',defaults:initialDefaults};
|
||||
let personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),personalRevision=0,appearanceOwner='',appearanceGeneration=0,settingsTimer=null;
|
||||
let brandDraft=null,brandLogo='',brandIcon='',backupRevision=0,coverDraft;
|
||||
const imageJobs=new Map();
|
||||
function nextImageJob(key){const value=(imageJobs.get(key)?.value||0)+1;imageJobs.set(key,{value,pending:false});return value;}
|
||||
function imageJobCurrent(key,value){return imageJobs.get(key)?.value===value;}
|
||||
function imageJobPending(key){return !!imageJobs.get(key)?.pending;}
|
||||
function invalidateBrandImages(){nextImageJob('logo');nextImageJob('icon');nextImageJob('import');}
|
||||
pageInfo.settings=['设置中心','发布配置统一管理,每个账号保留自己的使用习惯。'];
|
||||
function effectiveAppearance(values=personalValues,defaults=branding.defaults){return Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,appearanceChoices[k][values[k]]?values[k]:appearanceChoices[k][defaults?.[k]]?defaults[k]:initialDefaults[k]]));}
|
||||
function applyAppearance(values=personalValues,defaults=branding.defaults){
|
||||
@@ -34,6 +39,7 @@ function onAppearanceSession(preferences){
|
||||
}
|
||||
}
|
||||
function resetAppearance(){
|
||||
invalidateBrandImages();nextImageJob('cover');
|
||||
appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;appearanceOwner='';brandDraft=null;brandLogo='';brandIcon='';coverDraft=undefined;
|
||||
personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default']));personalRevision=0;applyAppearance();
|
||||
for(const selector of ['#brand-form','#preferences-form','#backup-form'])$(selector).reset();
|
||||
@@ -44,6 +50,7 @@ function appearanceFields(container,values,inherit){
|
||||
for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k];
|
||||
}
|
||||
function fillBrandForm(value){
|
||||
invalidateBrandImages();
|
||||
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
|
||||
const form=$('#brand-form');form.reset();
|
||||
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
|
||||
@@ -60,6 +67,7 @@ async function loadSettings(){
|
||||
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;
|
||||
appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();
|
||||
if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;}
|
||||
if(typeof loadBaiduSettings==='function')loadBaiduSettings();
|
||||
$('#settings-error').textContent='';
|
||||
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;}
|
||||
}
|
||||
@@ -89,25 +97,25 @@ async function readImage(file,maxSide=512){
|
||||
}finally{bitmap.close();}
|
||||
}
|
||||
for(const [id,type,max] of [['brand-logo-file','logo',256],['brand-icon-file','icon',96]]){
|
||||
$('#'+id).onchange=async e=>{const generation=appearanceGeneration;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration)return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}};
|
||||
$('#'+id).onchange=async e=>{const generation=appearanceGeneration,job=nextImageJob(type);nextImageJob('import');imageJobs.get(type).pending=true;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration||!imageJobCurrent(type,job))return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent(type,job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
|
||||
}
|
||||
$('#brand-logo-reset').onclick=()=>{brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{brandIcon='';renderBrandImages();};
|
||||
$('#brand-logo-reset').onclick=()=>{nextImageJob('logo');nextImageJob('import');brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{nextImageJob('icon');nextImageJob('import');brandIcon='';renderBrandImages();};
|
||||
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};}
|
||||
$('#brand-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const b=e.currentTarget.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;
|
||||
e.preventDefault();if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
|
||||
try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';}
|
||||
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{b.disabled=false;}
|
||||
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{for(const control of controls)control.disabled=false;}
|
||||
});
|
||||
$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration)return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}};
|
||||
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
|
||||
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;网络复制或清理未完成':'备份失败'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
|
||||
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;invalidateBrandImages();const job=nextImageJob('import');imageJobs.get('import').pending=true;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration||!imageJobCurrent('import',job))return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
|
||||
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;if(f.elements.baidu)f.elements.baidu.checked=!!c.baidu;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
|
||||
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;后续备份步骤未完成':'备份失败'):r.baiduOk?'本机备份完成,百度网盘已确认文件':r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
|
||||
function renderBackup(value){
|
||||
$('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?'正在生成数据库副本…':backupLabel(value.last)+(value.last?.error?':'+value.last.error:'');
|
||||
$('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?(value.stage||'正在生成数据库副本…'):backupLabel(value.last)+(value.last?.error?':'+value.last.error:'');
|
||||
$('#backup-local-path').textContent='本机目录:'+value.localPath;
|
||||
$('#backup-history').innerHTML=value.history.map(r=>`<article class="backup-row"><strong>${esc(backupLabel(r))}</strong><span>${esc(new Date(r.at).toLocaleString('zh-CN'))}</span><small>${esc(r.file||'')} ${r.bytes?Math.round(r.bytes/1024)+' KB':''}</small>${r.sha256?`<details><summary>完整性校验值</summary><code>${esc(r.sha256)}</code></details>`:''}</article>`).join('');
|
||||
clearTimeout(settingsTimer);if(value.running&&activePage==='settings')settingsTimer=setTimeout(pollBackup,2000);
|
||||
}
|
||||
async function pollBackup(){const generation=appearanceGeneration;try{const data=await api('/api/backups');if(generation===appearanceGeneration&&activePage==='settings')renderBackup(data);}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-result').textContent=e.message;}}
|
||||
$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}});
|
||||
$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,baidu:f.elements.baidu?.checked||false,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}});
|
||||
$('#backup-run').onclick=async()=>{const generation=appearanceGeneration;$('#backup-run').disabled=true;try{await api('/api/backups/run',{});if(generation===appearanceGeneration)pollBackup();}catch(e){if(!e.stale&&generation===appearanceGeneration){$('#backup-result').textContent=e.message;$('#backup-run').disabled=false;}}};
|
||||
|
||||
Reference in New Issue
Block a user