Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 693dd2a10a | |||
| 67709832c5 | |||
| 61968adf94 |
@@ -5,6 +5,8 @@ runtime/
|
|||||||
*.log
|
*.log
|
||||||
*.key
|
*.key
|
||||||
*.env
|
*.env
|
||||||
|
baidu-secrets.json
|
||||||
|
baidu-secrets.partial
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
mediamtx
|
mediamtx
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# 赵府智家 · 视觉系统
|
# 赵府智家 · 视觉系统
|
||||||
|
|
||||||
|
## v0.1.29 · 记事预览、完整地域与云备份保留
|
||||||
|
|
||||||
|
- 记事照片默认显示,点击放大;语音、视频自动加载播放信息,提供播放、暂停、进度和音量控制,不自动出声。页面原生播放器按需缓冲,图片延迟解码;媒体仍保存在百度网盘,由同源鉴权接口转发,服务器不保存个人媒体文件。
|
||||||
|
- 本次附件采用卡片,支持预览、从本条移除、删除和恢复。附件库默认折叠。保存成功清空文字与本次附件、结束录音,再读取下方个人阅历;失败则保留草稿。附件库删除是可恢复的整理操作,保留已发布引用与网盘原文件;彻底删除云文件仍由用户在网盘管理。
|
||||||
|
- 籍贯(祖籍)与出生地分别支持国家、省、地级市、区县、乡镇街道、村社区、详细地址及历史地名。层级可留空,原有地域文本保留,搜索和修改记录涵盖新字段;升级不会猜测或覆盖已有地址。
|
||||||
|
- 百度数据库备份保留最近 **一个自然月**(北京时间,以备份文件时间为准,月末按上月最后一天处理)。上传成功后流式读回校验 SHA-256,再清理本应用、当前实例目录下的过期 SQLite 文件;最新副本、其他实例和个人媒体不参与清理。上传或校验失败不清理旧云备份,清理失败单独报告,可在下次备份重试。新备份使用应用目录下的 `数据库备份/实例/文件.sqlite`,兼容旧版多一级同名目录的文件。
|
||||||
|
- 本机和挂载目录仍按设置中的份数保留;每日自动备份开关与时间保持用户原设置。不会自动清空百度回收站。云备份是完整数据库,含所有家庭数据及设备配置;私有 OAuth 凭据文件不在其中。
|
||||||
|
|
||||||
|
验证:136 项 Python 回归、98 项 JavaScript 回归;浏览器验收包括默认照片与音视频控件、预览与 Esc、附件删除恢复、保存后草稿清空及地域分别保存。接口依据:[百度官方 SDK 文件管理](https://github.com/baidu-netdisk/baidu-drive-sdk-go/blob/main/baidudriver/api/file_manager.go)、[目录分页](https://github.com/baidu-netdisk/baidu-drive-sdk-go/blob/main/baidudriver/api/file_list.go)。
|
||||||
|
|
||||||
|
## v0.1.28 · 个人记事与百度网盘
|
||||||
|
|
||||||
|
- 场所内新增「记事与阅历」。文字、日期、分类、作者、可见范围、点赞和评论存于本机数据库,同一条记事可从动态或个人阅历查询。支持关键词、人物、分类及日期筛选,每页 20 条。默认仅自己可见,选择「当前家庭/场所」才会共享;其他家庭不可查看,超级管理员的普通记事 API 也不越过他人的私密范围。
|
||||||
|
- 支持照片、视频、音频附件和浏览器直接录音。每条最多 9 个附件,单文件上限 256 MiB,录音最多 5 分钟;录音需要 HTTPS 与用户的麦克风授权。只上传原始媒体,不进行服务端转码或语音识别;实际播放还取决于浏览器编码支持。
|
||||||
|
- **一个后台百度网盘账号统一存放媒体**,应用账号与网盘账号分别管理。用户权限通过本平台的家庭归属和记事可见范围检查。该网盘账号持有人可以直接管理网盘中的全部文件。开发者应用的个人场景限制以百度后台为准。
|
||||||
|
- 个人记事的媒体不写入服务器磁盘。浏览器逐个计算 4 MiB 分片 MD5,以 512 KiB 请求发送;服务器最多保留两个 4 MiB 中转缓冲,转发至百度预上传/分片上传/创建文件接口。中断后选择同一原文件,重新读取云端缺失分片继续。数据库仅保存附件归属、名称、大小、校验值、网盘路径和文件 ID 等元数据,不保存媒体内容或永久公开分享链接。
|
||||||
|
- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片和音视频的加载交互已在 v0.1.29 更新,浏览器负责展示与缓冲。退出、离开记事页或切换家庭会释放页面媒体资源。不使用服务端文件缓存、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 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。云端保留规则已在 v0.1.29 更新,本机保留份数只作用于本机和挂载目录。
|
||||||
|
- 稳定性:家庭档案使用独立修订号防止相互覆盖;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。
|
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
|
||||||
|
|
||||||
## 界面风格
|
## 界面风格
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import inventory
|
|||||||
import heritage
|
import heritage
|
||||||
import appearance
|
import appearance
|
||||||
import backups
|
import backups
|
||||||
|
import baidu
|
||||||
|
import journal
|
||||||
import ptz
|
import ptz
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
@@ -83,6 +85,8 @@ INVENTORY = inventory.Inventory(sys.modules[__name__])
|
|||||||
HERITAGE = heritage.Heritage(sys.modules[__name__])
|
HERITAGE = heritage.Heritage(sys.modules[__name__])
|
||||||
APPEARANCE = appearance.Appearance(sys.modules[__name__])
|
APPEARANCE = appearance.Appearance(sys.modules[__name__])
|
||||||
BACKUPS = backups.Backups(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):
|
def setting(key, default=None):
|
||||||
@@ -629,15 +633,28 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
try:
|
try:
|
||||||
path = url.urlsplit(self.path).path
|
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.
|
# 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()
|
self.validate_origin()
|
||||||
if path in ('/api/login', '/api/setup'):
|
if path in ('/api/login', '/api/setup'):
|
||||||
self.login(data, path == '/api/setup')
|
self.login(data, path == '/api/setup')
|
||||||
return
|
return
|
||||||
self.require_auth()
|
self.require_auth()
|
||||||
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
|
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', '/api/journal/restore-upload'):
|
||||||
self.require_manager()
|
self.require_manager()
|
||||||
if path == '/api/logout':
|
if path == '/api/logout':
|
||||||
with LOCK:
|
with LOCK:
|
||||||
@@ -646,6 +663,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
||||||
elif path == '/api/preferences':
|
elif path == '/api/preferences':
|
||||||
self.answer(APPEARANCE.save_preferences(data, self.user))
|
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/journal/restore-upload':
|
||||||
|
self.answer(JOURNAL.restore_upload(self.user, data.get('id')))
|
||||||
elif path == '/api/appearance':
|
elif path == '/api/appearance':
|
||||||
self.require_admin()
|
self.require_admin()
|
||||||
self.answer(APPEARANCE.save_config(data, self.user))
|
self.answer(APPEARANCE.save_config(data, self.user))
|
||||||
@@ -841,6 +878,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
parsed = url.urlsplit(self.path)
|
parsed = url.urlsplit(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
|
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':
|
if path == '/api/branding':
|
||||||
self.answer(APPEARANCE.public())
|
self.answer(APPEARANCE.public())
|
||||||
return
|
return
|
||||||
@@ -862,6 +910,34 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.require_auth()
|
self.require_auth()
|
||||||
if path == '/api/state':
|
if path == '/api/state':
|
||||||
self.answer(ACCOUNTS.state(self.user, status()))
|
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':
|
elif path == '/api/preferences':
|
||||||
self.answer(APPEARANCE.preferences(self.user))
|
self.answer(APPEARANCE.preferences(self.user))
|
||||||
elif path == '/api/appearance':
|
elif path == '/api/appearance':
|
||||||
@@ -970,6 +1046,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
|
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
|
||||||
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
|
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
|
||||||
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.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-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
|
||||||
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
|
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
|
||||||
'/family-events.js': 'family-events.js',
|
'/family-events.js': 'family-events.js',
|
||||||
@@ -1009,6 +1086,7 @@ def initialize():
|
|||||||
ACCOUNTS.initialize()
|
ACCOUNTS.initialize()
|
||||||
HOUSEHOLDS.initialize()
|
HOUSEHOLDS.initialize()
|
||||||
HERITAGE.initialize()
|
HERITAGE.initialize()
|
||||||
|
JOURNAL.initialize()
|
||||||
if not setting('storage'):
|
if not setting('storage'):
|
||||||
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
||||||
if not setting('mediaSecret'):
|
if not setting('mediaSecret'):
|
||||||
|
|||||||
+65
-11
@@ -1,5 +1,6 @@
|
|||||||
"""Online SQLite snapshots with optional copy to a pre-mounted network directory."""
|
"""Online SQLite snapshots with optional copy to a pre-mounted network directory."""
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import calendar
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
@@ -17,12 +18,13 @@ class Backups:
|
|||||||
self.a = app
|
self.a = app
|
||||||
self.guard = threading.Lock()
|
self.guard = threading.Lock()
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self.stage = ''
|
||||||
|
|
||||||
def config(self):
|
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):
|
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'),
|
localPath=str(self.a.DATA/'managed-backups'),
|
||||||
last=self.a.setting('backup_last', None),
|
last=self.a.setting('backup_last', None),
|
||||||
history=self.a.setting('backup_history', [])[-20:][::-1])
|
history=self.a.setting('backup_history', [])[-20:][::-1])
|
||||||
@@ -73,11 +75,14 @@ class Backups:
|
|||||||
return folder
|
return folder
|
||||||
|
|
||||||
def save(self, data):
|
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('备份设置字段不完整')
|
raise self.a.Problem('备份设置字段不完整')
|
||||||
with self.guard:
|
with self.guard:
|
||||||
if self.running:
|
if self.running:
|
||||||
raise self.a.Problem('备份正在执行,请完成后再调整设置', 409)
|
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:
|
with self.a.LOCK:
|
||||||
old = self.config()
|
old = self.config()
|
||||||
if type(data['revision']) is not int or data['revision'] != old['revision']:
|
if type(data['revision']) is not int or data['revision'] != old['revision']:
|
||||||
@@ -96,7 +101,10 @@ class Backups:
|
|||||||
if self.running:
|
if self.running:
|
||||||
raise self.a.Problem('已有备份正在执行', 409)
|
raise self.a.Problem('已有备份正在执行', 409)
|
||||||
self.running = True
|
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)
|
return dict(started=True)
|
||||||
|
|
||||||
def prune(self, folder, prefix, keep):
|
def prune(self, folder, prefix, keep):
|
||||||
@@ -104,13 +112,44 @@ class Backups:
|
|||||||
for path in sorted(owned, key=lambda p:p.name, reverse=True)[keep:]:
|
for path in sorted(owned, key=lambda p:p.name, reverse=True)[keep:]:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def month_before(now):
|
||||||
|
year, month = (now.year, now.month-1) if now.month > 1 else (now.year-1, 12)
|
||||||
|
return now.replace(year=year, month=month, day=min(now.day, calendar.monthrange(year, month)[1]))
|
||||||
|
|
||||||
|
def prune_cloud(self, instance, newest, result, now=None):
|
||||||
|
now = now or dt.datetime.now(dt.timezone(dt.timedelta(hours=8)))
|
||||||
|
cutoff = self.month_before(now)
|
||||||
|
result.update(cloudRetentionMonths=1, cloudDeleted=0, cloudCutoff=cutoff.isoformat())
|
||||||
|
root = '/apps/'+self.a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance
|
||||||
|
pattern = re.compile('vision-'+re.escape(instance)+r'-(\d{8}T\d{12}Z)\.sqlite')
|
||||||
|
# Collect all pages before deleting: changing a directory while paging can skip entries.
|
||||||
|
for row in self.a.BAIDU.list_directory(root):
|
||||||
|
path = row.get('path', '')
|
||||||
|
if not path.startswith(root+'/'): continue
|
||||||
|
name = path[len(root)+1:]; match = pattern.fullmatch(name)
|
||||||
|
if not match: continue
|
||||||
|
try: created = dt.datetime.strptime(match[1], '%Y%m%dT%H%M%S%fZ').replace(tzinfo=dt.timezone.utc)
|
||||||
|
except ValueError: continue
|
||||||
|
if created >= cutoff: continue
|
||||||
|
candidates = self.a.BAIDU.list_directory(path) if row.get('isdir') == 1 else [row]
|
||||||
|
for item in candidates:
|
||||||
|
target = item.get('path', '')
|
||||||
|
expected = path+'/'+name if row.get('isdir') == 1 else path
|
||||||
|
if target != expected or target == newest or item.get('isdir') != 0: continue
|
||||||
|
self.a.BAIDU.delete_backup_file(target)
|
||||||
|
result['cloudDeleted'] += 1
|
||||||
|
result['cloudCleanupOk'] = True
|
||||||
|
|
||||||
def run(self, reason='manual'):
|
def run(self, reason='manual'):
|
||||||
a = self.a
|
a = self.a
|
||||||
config = self.config()
|
|
||||||
result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason,
|
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
|
temporary = network_temp = None
|
||||||
try:
|
try:
|
||||||
|
config = self.config()
|
||||||
|
result.update(networkRequested=bool(config['networkPath']), baiduRequested=config['baidu'])
|
||||||
|
self.stage = '正在生成本机数据库副本'
|
||||||
local = a.DATA/'managed-backups'
|
local = a.DATA/'managed-backups'
|
||||||
local.mkdir(mode=0o700, exist_ok=True)
|
local.mkdir(mode=0o700, exist_ok=True)
|
||||||
instance = a.setting('backup_instance')
|
instance = a.setting('backup_instance')
|
||||||
@@ -133,6 +172,7 @@ class Backups:
|
|||||||
result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest)
|
result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest)
|
||||||
self.prune(local, prefix, config['keep'])
|
self.prune(local, prefix, config['keep'])
|
||||||
if config['networkPath']:
|
if config['networkPath']:
|
||||||
|
self.stage = '正在复制到网络目录'
|
||||||
network = self.network_folder(config['networkPath'])
|
network = self.network_folder(config['networkPath'])
|
||||||
network_temp = network/(name+'.partial')
|
network_temp = network/(name+'.partial')
|
||||||
shutil.copyfile(target, network_temp)
|
shutil.copyfile(target, network_temp)
|
||||||
@@ -143,6 +183,17 @@ class Backups:
|
|||||||
os.replace(network_temp, network/name)
|
os.replace(network_temp, network/name)
|
||||||
result['networkOk'] = True
|
result['networkOk'] = True
|
||||||
self.prune(network, prefix, config['keep'])
|
self.prune(network, prefix, config['keep'])
|
||||||
|
if config['baidu']:
|
||||||
|
self.stage = '正在上传百度网盘'
|
||||||
|
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance+'/'+name
|
||||||
|
uploaded = a.BAIDU.upload('shared', target, remote)
|
||||||
|
self.stage = '正在校验百度网盘副本'
|
||||||
|
a.BAIDU.verify_backup(remote, uploaded['fs_id'], result['bytes'], digest)
|
||||||
|
result.update(baiduOk=True, baiduPath=remote, baiduFileId=str(uploaded['fs_id']))
|
||||||
|
self.stage = '正在清理超过一个月的云端备份'
|
||||||
|
try: self.prune_cloud(instance, remote, result)
|
||||||
|
except Exception as error:
|
||||||
|
result.update(cloudCleanupOk=False, cloudCleanupError=str(error)[:300] or '过期备份尚未清理')
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
result['error'] = str(error)[:300] or '备份未完成'
|
result['error'] = str(error)[:300] or '备份未完成'
|
||||||
finally:
|
finally:
|
||||||
@@ -151,11 +202,14 @@ class Backups:
|
|||||||
if temporary_path and temporary_path.exists(): temporary_path.unlink()
|
if temporary_path and temporary_path.exists(): temporary_path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with a.LOCK:
|
try:
|
||||||
a.set_setting('backup_last', result)
|
with a.LOCK:
|
||||||
history = a.setting('backup_history', [])
|
a.set_setting('backup_last', result)
|
||||||
a.set_setting('backup_history', (history+[result])[-100:])
|
history = a.setting('backup_history', [])
|
||||||
self.running = False
|
a.set_setting('backup_history', (history+[result])[-100:])
|
||||||
|
finally:
|
||||||
|
with self.guard:
|
||||||
|
self.running = False; self.stage = ''
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def tick(self, now=None):
|
def tick(self, now=None):
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
def verify_backup(self, remote, fs_id, size, digest):
|
||||||
|
"""Read back into a small memory buffer before allowing retention deletion."""
|
||||||
|
sha = hashlib.sha256(); received = 0
|
||||||
|
with self.open_file('shared', dict(remotePath=remote, fsId=str(fs_id))) as response:
|
||||||
|
while chunk := response.read(65536):
|
||||||
|
received += len(chunk)
|
||||||
|
if received > size: raise self.a.Problem('云端备份大小不符,未清理旧备份', 502)
|
||||||
|
sha.update(chunk)
|
||||||
|
if received != size or sha.hexdigest() != digest:
|
||||||
|
raise self.a.Problem('云端备份校验不一致,未清理旧备份', 502)
|
||||||
|
|
||||||
|
def list_directory(self, directory):
|
||||||
|
items = []; seen = set()
|
||||||
|
for start in range(0, 20000, 1000):
|
||||||
|
result = self.request('shared', 'file', 'list', dir=directory, start=start, limit=1000, order='name')
|
||||||
|
page = result.get('list')
|
||||||
|
if not isinstance(page, list): raise self.a.Problem('网盘目录读取不完整,未继续清理', 502)
|
||||||
|
for item in page:
|
||||||
|
path = item.get('path', '')
|
||||||
|
if path in seen: raise self.a.Problem('网盘目录分页重复,未继续清理', 502)
|
||||||
|
seen.add(path); items.append(item)
|
||||||
|
if len(page) < 1000: return items
|
||||||
|
raise self.a.Problem('网盘目录过大,未继续清理', 502)
|
||||||
|
|
||||||
|
def delete_backup_file(self, remote):
|
||||||
|
# Restrict this capability to one generated database file, never a directory or media.
|
||||||
|
root = '/apps/'+self.public_config()['appFolder']+'/数据库备份/'
|
||||||
|
if not remote.startswith(root): raise self.a.Problem('备份清理路径不正确')
|
||||||
|
parts = remote[len(root):].split('/')
|
||||||
|
if len(parts) not in (2, 3) or not re.fullmatch('[a-f0-9]{16}', parts[0]):
|
||||||
|
raise self.a.Problem('备份清理路径不正确')
|
||||||
|
name = parts[-1]
|
||||||
|
if not re.fullmatch('vision-'+parts[0]+r'-\d{8}T\d{12}Z\.sqlite', name) or len(parts) == 3 and parts[1] != name:
|
||||||
|
raise self.a.Problem('备份清理文件名不正确')
|
||||||
|
result = self.request('shared', 'file', 'filemanager', {'async':0, 'filelist':json.dumps([remote])}, opera='delete')
|
||||||
|
info = result.get('info')
|
||||||
|
if result.get('taskid') or not isinstance(info, list) or len(info) != 1 or info[0].get('path') != remote or info[0].get('errno') != 0:
|
||||||
|
raise self.a.Problem('网盘未确认删除结果,请稍后重试', 502)
|
||||||
@@ -178,6 +178,14 @@ class Family:
|
|||||||
item[key] = value.strip()
|
item[key] = value.strip()
|
||||||
else:
|
else:
|
||||||
item[key] = a.clean_text(value, maximum, key == 'name')
|
item[key] = a.clean_text(value, maximum, key == 'name')
|
||||||
|
# Keep the legacy combined field verbatim. New structured locations are optional;
|
||||||
|
# old clients updating another field must not erase them.
|
||||||
|
for prefix in ('native', 'birth'):
|
||||||
|
for field in ('Country', 'Province', 'City', 'District', 'Town', 'Community', 'Address'):
|
||||||
|
key = prefix+field
|
||||||
|
value = data.get(key, (old or {}).get(key, ''))
|
||||||
|
if not isinstance(value, str): raise a.Problem('地域信息须为文字')
|
||||||
|
item[key] = a.clean_text(value, 200 if field == 'Address' else 80)
|
||||||
for key in ('birthDate', 'deathDate'):
|
for key in ('birthDate', 'deathDate'):
|
||||||
if item[key]:
|
if item[key]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ class Households:
|
|||||||
raise a.Problem('家庭不存在', 404)
|
raise a.Problem('家庭不存在', 404)
|
||||||
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
|
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
|
||||||
raise a.Problem('只能管理自己所属的家庭', 403)
|
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')
|
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
|
||||||
if kind not in ('home', 'office', 'factory'):
|
if kind not in ('home', 'office', 'factory'):
|
||||||
raise a.Problem('请选择家庭、办公场所或工厂')
|
raise a.Problem('请选择家庭、办公场所或工厂')
|
||||||
@@ -164,6 +166,7 @@ class Households:
|
|||||||
name=a.clean_text(data.get('name', ''), 80, True),
|
name=a.clean_text(data.get('name', ''), 80, True),
|
||||||
kind=kind,
|
kind=kind,
|
||||||
note=a.clean_text(data.get('note', ''), 300),
|
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())
|
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
|
||||||
for field in ('province','city','district'):
|
for field in ('province','city','district'):
|
||||||
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
|
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
|
||||||
|
|||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
"""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', 'trashedAt')}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
owned = [m for m in media.values() if m['ownerId'] == user['id']]
|
||||||
|
uploads = [self.public_media(m) for m in owned if not m.get('trashedAt') and m['status'] != 'removed']
|
||||||
|
uploads.sort(key=lambda m: m['at'], reverse=True)
|
||||||
|
trash = [self.public_media(m) for m in owned if m.get('trashedAt') or m['status'] == 'removed']
|
||||||
|
trash.sort(key=lambda m: m.get('trashedAt') or 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], trash=trash[: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 item.get('trashedAt') and mid not in (old or {}).get('mediaIds', []):
|
||||||
|
raise a.Problem('请先恢复已删除的附件再添加到记事', 409)
|
||||||
|
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.get('trashedAt'): raise a.Problem('请先恢复已删除的附件', 409)
|
||||||
|
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 not m.get('trashedAt') 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.get('trashedAt'): raise self.a.Problem('附件已删除,请先恢复', 409)
|
||||||
|
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.get('trashedAt'): raise self.a.Problem('附件已删除,请先恢复', 409)
|
||||||
|
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)
|
||||||
|
self.buffers.pop(key, None)
|
||||||
|
# Library deletion must not break existing published references or delete cloud files.
|
||||||
|
if not item.get('trashedAt'):
|
||||||
|
item['trashedAt'] = dt.datetime.now(dt.timezone.utc).isoformat()
|
||||||
|
self.a.save_object('journal_media', item)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
def restore_upload(self, user, key):
|
||||||
|
with self.guard, self.a.LOCK:
|
||||||
|
item = self.owned(user, 'journal_media', key)
|
||||||
|
if not item.get('trashedAt') and item['status'] != 'removed': return {'ok': True}
|
||||||
|
status = ('ready' if item.get('fsId') else 'receiving') if item['status'] == 'removed' else item['status']
|
||||||
|
if status == 'receiving':
|
||||||
|
active = sum(m['ownerId'] == user['id'] and m['status'] == 'receiving' and not m.get('trashedAt') for m in self.a.objects('journal_media'))
|
||||||
|
if active >= 4: raise self.a.Problem('请先完成或删除现有上传任务,最多同时保留 4 个', 409)
|
||||||
|
item.update(status=status, trashedAt=None)
|
||||||
|
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."""
|
"""Isolated regression tests: python -m unittest -v test_app."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import atexit
|
||||||
import http.client
|
import http.client
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -16,6 +17,7 @@ os.environ['VISION_DATA'] = _data.name
|
|||||||
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
|
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
|
||||||
import app
|
import app
|
||||||
import playback
|
import playback
|
||||||
|
atexit.register(app.DB.close)
|
||||||
|
|
||||||
|
|
||||||
class WebTests(unittest.TestCase):
|
class WebTests(unittest.TestCase):
|
||||||
@@ -105,7 +107,7 @@ class WebTests(unittest.TestCase):
|
|||||||
self.assertEqual(headers['Cache-Control'], 'no-store')
|
self.assertEqual(headers['Cache-Control'], 'no-store')
|
||||||
self.assertNotIn(b'__ASSET_VERSION__', body)
|
self.assertNotIn(b'__ASSET_VERSION__', body)
|
||||||
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
|
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()
|
version = (app.ROOT / 'VERSION').read_text().strip()
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
self.assertTrue(asset.endswith('?v=' + version))
|
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]
|
listed=self.request('/api/households',self.owner)[1]['households'][0]
|
||||||
self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed)
|
self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed)
|
||||||
self.assertIn('/api/households/cover?',listed['coverUrl'])
|
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())
|
self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data())
|
||||||
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
|
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
|
||||||
c=http.client.HTTPConnection(*self.server.server_address)
|
c=http.client.HTTPConnection(*self.server.server_address)
|
||||||
|
|||||||
@@ -80,3 +80,45 @@ class BackupTests(unittest.TestCase):
|
|||||||
'2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw']
|
'2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw']
|
||||||
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records))
|
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records))
|
||||||
self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data']))
|
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':'测试'}), patch.object(app.BAIDU,'verify_backup'), patch.object(app.BAIDU,'list_directory',return_value=[]):
|
||||||
|
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')
|
||||||
|
|
||||||
|
def test_cloud_cleanup_only_runs_after_verified_upload(self):
|
||||||
|
backup=self.source_file();app.set_setting('backup_config',dict(backup.config(),baidu=True))
|
||||||
|
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}), patch.object(app.BAIDU,'upload',return_value={'fs_id':123}), patch.object(backup,'prune_cloud') as prune:
|
||||||
|
with patch.object(app.BAIDU,'verify_backup',side_effect=RuntimeError('hash mismatch')):
|
||||||
|
result=backup.run();self.assertFalse(result['baiduOk']);prune.assert_not_called()
|
||||||
|
with patch.object(app.BAIDU,'verify_backup'):
|
||||||
|
result=backup.run();self.assertTrue(result['baiduOk']);prune.assert_called_once()
|
||||||
|
|
||||||
|
def test_retention_is_a_calendar_month_including_leap_and_year_boundaries(self):
|
||||||
|
for value, expected in [('2024-03-31','2024-02-29'),('2026-03-31','2026-02-28'),('2026-01-04','2025-12-04')]:
|
||||||
|
self.assertEqual(Backups.month_before(dt.datetime.fromisoformat(value)).date().isoformat(),expected)
|
||||||
|
|
||||||
|
def test_cloud_prune_limits_to_instance_and_expired_database_files(self):
|
||||||
|
backup=Backups(app);instance='a'*16;root='/apps/测试/数据库备份/'+instance
|
||||||
|
def name(stamp):return 'vision-'+instance+'-'+stamp+'.sqlite'
|
||||||
|
old=name('20260804T120000000000Z');nested=name('20260805T120000000000Z');recent=name('20261004T120000000000Z');boundary=name('20260904T120000000000Z')
|
||||||
|
rows=[dict(path=root+'/'+n,isdir=0) for n in (old,recent,boundary,'holiday.png','../'+old)]
|
||||||
|
rows+=[dict(path=root+'/'+nested,isdir=1),dict(path='/apps/其他/'+old,isdir=0)]
|
||||||
|
def listing(path):return rows if path==root else [dict(path=path+'/'+nested,isdir=0),dict(path=path+'/private.png',isdir=0)]
|
||||||
|
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'list_directory',side_effect=listing),patch.object(app.BAIDU,'delete_backup_file') as delete:
|
||||||
|
result={};backup.prune_cloud(instance,root+'/'+recent,result,dt.datetime(2026,10,4,12,tzinfo=dt.timezone.utc))
|
||||||
|
self.assertEqual([c.args[0] for c in delete.call_args_list],[root+'/'+old,root+'/'+nested+'/'+nested]);self.assertEqual(result['cloudDeleted'],2)
|
||||||
|
|
||||||
|
def test_cloud_cleanup_failure_keeps_successful_new_backup(self):
|
||||||
|
backup=self.source_file();app.set_setting('backup_config',dict(backup.config(),baidu=True))
|
||||||
|
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'upload',return_value={'fs_id':123}),patch.object(app.BAIDU,'verify_backup'),patch.object(backup,'prune_cloud',side_effect=RuntimeError('cleanup unavailable')):
|
||||||
|
result=backup.run()
|
||||||
|
self.assertTrue(result['baiduOk']);self.assertFalse(result['cloudCleanupOk']);self.assertNotIn('error',result)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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,/releaseJournalMedia/);assert.match(js,/removeAttribute\('src'\)/);assert.match(js,/getTracks\(\).forEach\(t=>t.stop\(\)\)/);
|
||||||
|
});
|
||||||
|
test('photos load by default and audio/video preload metadata with controls, never autoplay',()=>{
|
||||||
|
const vm=require('node:vm'),js=fs.readFileSync('web/journal.js','utf8');
|
||||||
|
const code=js.slice(js.indexOf('function journalMediaView('),js.indexOf('function bindJournalMedia('));
|
||||||
|
const c=vm.createContext({esc:s=>String(s).replaceAll('<','<'),journalMediaUrl:id=>'/media/journal?id='+id});vm.runInContext(code,c);
|
||||||
|
for(const mime of ['image/png','audio/webm','video/mp4']){
|
||||||
|
const html=c.journalMediaView({id:'a',name:'<test>',mime});assert.match(html,/src="\/media\/journal\?id=a"/);assert.doesNotMatch(html,/autoplay|<test>/);
|
||||||
|
if(mime.startsWith('image'))assert.match(html,/loading="lazy"/);else assert.match(html,/controls preload="metadata"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('successful save clears draft attachment ids, stops recorder, and reloads feed',()=>{
|
||||||
|
const js=fs.readFileSync('web/journal.js','utf8');
|
||||||
|
const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal'));
|
||||||
|
assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/);
|
||||||
|
assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/);
|
||||||
|
assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/);
|
||||||
|
});
|
||||||
|
test('attachment preview uses a closable dialog and deletion has a restore path',()=>{
|
||||||
|
const js=fs.readFileSync('web/journal.js','utf8'),html=fs.readFileSync('web/index.html','utf8');
|
||||||
|
assert.match(html,/<dialog id="journal-preview"/);assert.match(js,/addEventListener\('close'/);assert.match(js,/\/api\/journal\/restore-upload/);
|
||||||
|
assert.match(js,/journal-library-list/);assert.match(js,/if\(other!==media\)other.pause\(\)/);
|
||||||
|
});
|
||||||
|
test('structured place formatting and search preserve separate ancestral/birth/legacy locations',()=>{
|
||||||
|
const vm=require('node:vm'),js=fs.readFileSync('web/family.js','utf8');
|
||||||
|
const c=vm.createContext({});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
|
||||||
|
const person={nativeProvince:'山东省',nativeCity:'烟台市',nativeDistrict:'莱阳市',birthCity:'济南市',birthplace:'旧村名'};
|
||||||
|
assert.equal(c.personLocation(person,'native'),'山东省 · 烟台市 · 莱阳市');assert.equal(c.personLocation(person,'birth'),'济南市');assert.ok(c.personSearch(person).includes('旧村名'));
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
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'],'第一次修改')
|
||||||
|
|
||||||
|
def test_attachment_trash_restore_and_existing_reference_remain_visible(self):
|
||||||
|
m=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',m)
|
||||||
|
key=app.JOURNAL.save(self.actor,dict(self.note(),visibility='household',mediaIds=[m['id']]))['id']
|
||||||
|
self.assertEqual(self.request('/api/journal/remove-upload',self.actor,{'id':m['id']})[0],200)
|
||||||
|
snap=app.JOURNAL.snapshot(self.actor,{'mode':'mine'});self.assertEqual(snap['uploads'],[]);self.assertEqual(snap['trash'][0]['id'],m['id'])
|
||||||
|
self.assertEqual(app.JOURNAL.media(self.owner,m['id'])['id'],m['id'])
|
||||||
|
self.assertEqual(self.request('/api/journal',self.actor,dict(self.note(),mediaIds=[m['id']]))[0],409)
|
||||||
|
self.assertEqual(self.request('/api/journal',self.actor,app.get_object('journal_entries',key))[0],200)
|
||||||
|
for user in (self.owner,self.other):
|
||||||
|
self.assertIn(self.request('/api/journal/restore-upload',user,{'id':m['id']})[0],(403,404))
|
||||||
|
self.assertEqual(app.JOURNAL.snapshot(user,{})['trash'],[])
|
||||||
|
self.assertEqual(self.request('/api/journal/restore-upload',self.actor,{'id':m['id']})[0],200)
|
||||||
|
self.assertEqual(app.JOURNAL.snapshot(self.actor,{})['trash'],[])
|
||||||
|
|
||||||
|
def test_deleted_partial_upload_rejects_chunk_and_finish_and_can_restore(self):
|
||||||
|
m=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='receiving',size=8,name='x.png',mime='image/png',at='now',received=0)
|
||||||
|
app.save_object('journal_media',m);app.JOURNAL.remove_upload(self.actor,m['id'])
|
||||||
|
for action in (lambda:app.JOURNAL.chunk(self.actor,m['id'],0,b'123'),lambda:app.JOURNAL.finish_upload(self.actor,m['id'])):
|
||||||
|
with self.assertRaises(app.Problem):action()
|
||||||
|
app.JOURNAL.restore_upload(self.actor,m['id']);self.assertFalse(app.get_object('journal_media',m['id'])['trashedAt'])
|
||||||
|
|
||||||
|
def test_backup_readback_hash_check_and_constrained_cloud_delete(self):
|
||||||
|
data=b'fixture sqlite';stream=io.BytesIO(data)
|
||||||
|
with patch.object(app.BAIDU,'open_file',return_value=stream):
|
||||||
|
app.BAIDU.verify_backup('/test','1',len(data),hashlib.sha256(data).hexdigest())
|
||||||
|
with patch.object(app.BAIDU,'open_file',return_value=io.BytesIO(b'wrong')):
|
||||||
|
with self.assertRaises(app.Problem):app.BAIDU.verify_backup('/test','1',len(data),'wrong')
|
||||||
|
root='/apps/测试/数据库备份/'+'a'*16;name='vision-'+'a'*16+'-20260801T120000000000Z.sqlite';valid=root+'/'+name
|
||||||
|
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'request',return_value={'info':[{'path':valid,'errno':0}]}) as request:
|
||||||
|
app.BAIDU.delete_backup_file(valid)
|
||||||
|
for path in (root,root+'/../'+name,root+'/other.png',root+'/'+name+'/private.png','/apps/测试/资料/'+name):
|
||||||
|
with self.assertRaises(app.Problem):app.BAIDU.delete_backup_file(path)
|
||||||
|
self.assertEqual(request.call_count,1)
|
||||||
|
|
||||||
|
def test_person_structured_places_preserve_legacy_and_survive_old_clients(self):
|
||||||
|
data=dict(name='测试人物',birthplace='旧称原记录',nativeProvince='山东省',nativeCity='烟台市',nativeDistrict='莱阳市',nativeTown='测试镇',nativeCommunity='测试村',birthCountry='中国',birthCity='济南市')
|
||||||
|
person=app.FAMILY.person(data,None)
|
||||||
|
self.assertEqual(person['nativeCity'],'烟台市');self.assertEqual(person['birthplace'],'旧称原记录')
|
||||||
|
saved=app.FAMILY.person({'name':'新姓名','birthplace':'旧称原记录'},person)
|
||||||
|
self.assertEqual(saved['nativeCommunity'],'测试村');self.assertEqual(saved['birthCity'],'济南市')
|
||||||
|
with self.assertRaises(app.Problem):app.FAMILY.person(dict(data,nativeCity=[]),person)
|
||||||
@@ -54,3 +54,15 @@ test('image conversion resizes oversized output and closes its decoded bitmap',a
|
|||||||
assert.equal(await c.readImage({type:'image/png',size:100},960),'data:image/png;base64,AA==');
|
assert.equal(await c.readImage({type:'image/png',size:100},960),'data:image/png;base64,AA==');
|
||||||
assert.equal(canvas.width,768);assert.equal(canvas.height,384);assert.equal(closed,1);
|
assert.equal(canvas.width,768);assert.equal(canvas.height,384);assert.equal(closed,1);
|
||||||
});
|
});
|
||||||
|
test('settings opened before login resolves reloads once after the account is known',()=>{
|
||||||
|
let loads=0;const c=vm.createContext({applyBranding(){},applyAppearance(){},currentUser:{id:'root'},appearanceOwner:'',activePage:'settings',branding:{},loadSettings(){loads++;}});
|
||||||
|
vm.runInContext(part(source,'function onAppearanceSession(','function resetAppearance('),c);
|
||||||
|
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) : '';
|
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
|
||||||
}
|
}
|
||||||
function clearPrivateView(){
|
function clearPrivateView(){
|
||||||
|
if(typeof resetJournal==='function')resetJournal();
|
||||||
if(typeof resetAppearance==='function')resetAppearance();
|
if(typeof resetAppearance==='function')resetAppearance();
|
||||||
if(typeof clearSnapshot==='function')clearSnapshot();
|
if(typeof clearSnapshot==='function')clearSnapshot();
|
||||||
if(typeof resetDeviceView==='function')resetDeviceView();
|
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 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 stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
|
||||||
function showPage(page, options={}) {
|
function showPage(page, options={}) {
|
||||||
|
if(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
|
||||||
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
|
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(['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(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
|
||||||
if(page==='storage'&&!isPlatformAdmin())return;
|
if(page==='storage'&&!isPlatformAdmin())return;
|
||||||
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
|
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
|
||||||
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
||||||
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
|
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
|
||||||
activePage = page;
|
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;
|
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
|
||||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
$$('.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)));
|
$$('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 === 'users') loadUsers();
|
||||||
if(page==='heritage-records')loadHeritage();
|
if(page==='heritage-records')loadHeritage();
|
||||||
if(page==='settings'&&typeof loadSettings==='function')loadSettings();
|
if(page==='settings'&&typeof loadSettings==='function')loadSettings();
|
||||||
|
if(page==='journal'&&typeof loadJournal==='function')loadJournal();
|
||||||
}
|
}
|
||||||
function render() {
|
function render() {
|
||||||
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
|
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
|
||||||
|
|||||||
@@ -294,3 +294,28 @@ 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%)}
|
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(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}}
|
@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}
|
||||||
|
.journal-upload-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:18px;margin:20px 0}
|
||||||
|
.journal-upload-card{border:1px solid var(--line,#d9d7c8);border-radius:12px;padding:16px;background:var(--surface,#fffdf8);min-width:0;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.journal-upload-card:has(input:checked){border-color:#456f5f;box-shadow:0 0 0 1px #456f5f}
|
||||||
|
.journal-upload-select{display:flex!important;align-items:flex-start;gap:10px;overflow-wrap:anywhere;line-height:1.5}
|
||||||
|
.journal-upload-select input[type=checkbox]{width:18px;min-width:18px;height:18px;margin-top:3px;accent-color:#315a4e}
|
||||||
|
.journal-upload-select small{display:block;font-size:12px;color:var(--muted);margin-top:4px}
|
||||||
|
.journal-card-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:auto}
|
||||||
|
.journal-card-actions button{padding:8px 13px;font-size:13px}.journal-card-actions [data-remove-upload]{margin-left:auto;color:#954f40}
|
||||||
|
.journal-card-actions a,.journal-media-caption a{font-size:13px;color:var(--accent,#315a4e)}
|
||||||
|
.journal-attachment{margin:0;min-width:0}.journal-photo{display:block;position:relative;width:100%;padding:0!important;border:0!important;background:#e6e9df!important;border-radius:9px;overflow:hidden;color:#fff!important}
|
||||||
|
.journal-photo img{display:block;width:100%;min-height:120px;max-height:450px;object-fit:contain;aspect-ratio:16/10;background:#e6e9df;border-radius:9px}
|
||||||
|
.journal-photo-hint{position:absolute;right:10px;bottom:10px;padding:4px 9px;background:#213a30c9;border-radius:5px;font-size:12px}
|
||||||
|
.journal-upload-card .journal-photo img{height:180px;min-height:0;object-fit:cover}
|
||||||
|
.journal-attachment video{width:100%;max-height:450px;border-radius:8px;background:#1a2925}.journal-upload-card video{height:180px}
|
||||||
|
.journal-attachment audio{width:100%;max-width:100%}.journal-audio{background:#e9eee5;border:1px solid #d5decd;border-radius:10px;padding:14px}
|
||||||
|
.journal-audio-label{display:flex;align-items:center;gap:10px;color:#42614f;font-size:13px;letter-spacing:1px;margin:0 0 14px}.journal-audio-label span{font-size:28px}
|
||||||
|
.journal-media-state{display:block;font-size:12px;color:var(--muted);padding-top:6px;min-height:0}.journal-media-state:empty{display:none}
|
||||||
|
.journal-media-caption{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 16px}
|
||||||
|
.journal-trash{padding:12px 0;border-top:1px solid #d9d7c8}.journal-trash-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:10px 0;border-bottom:1px solid #d9d7c8;overflow-wrap:anywhere}
|
||||||
|
.journal-preview{width:min(960px,94vw);max-width:94vw;padding:24px;max-height:94vh}.journal-preview header,.journal-preview footer{display:flex;align-items:center;justify-content:space-between;gap:18px;position:static}.journal-preview header{margin-bottom:20px}.journal-preview h2{font-size:22px;overflow-wrap:anywhere;margin:0}.journal-preview footer{font-size:13px;margin-top:18px}
|
||||||
|
.journal-preview .journal-photo img{max-height:65vh;min-height:0;aspect-ratio:auto}.journal-preview .journal-photo{pointer-events:none}.journal-preview .journal-photo-hint{display:none}.journal-preview video{max-height:65vh}
|
||||||
|
html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink] .journal-audio{background:#293f37;border-color:#436152}html[data-theme=ink] .journal-audio-label{color:#d5e2ce}
|
||||||
|
@media(max-width:600px){.journal-upload-grid{grid-template-columns:1fr}.journal-preview{padding:16px}.journal-preview h2{font-size:17px}.journal-preview header button{flex-shrink:0}.journal-media{grid-template-columns:1fr}}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
|
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
|
||||||
|
historyFields.nativeCountry='籍贯 · 国家/地区';
|
||||||
|
historyFields.nativeProvince='籍贯 · 省/自治区/直辖市';
|
||||||
|
historyFields.nativeCity='籍贯 · 地级市/州/盟';
|
||||||
|
historyFields.nativeDistrict='籍贯 · 区/县/县级市';
|
||||||
|
historyFields.nativeTown='籍贯 · 乡镇/街道';
|
||||||
|
historyFields.nativeCommunity='籍贯 · 村/社区';
|
||||||
|
historyFields.nativeAddress='籍贯 · 详细地址/历史地名';
|
||||||
|
historyFields.birthCountry='出生地 · 国家/地区';
|
||||||
|
historyFields.birthProvince='出生地 · 省/自治区/直辖市';
|
||||||
|
historyFields.birthCity='出生地 · 地级市/州/盟';
|
||||||
|
historyFields.birthDistrict='出生地 · 区/县/县级市';
|
||||||
|
historyFields.birthTown='出生地 · 乡镇/街道';
|
||||||
|
historyFields.birthCommunity='出生地 · 村/社区';
|
||||||
|
historyFields.birthAddress='出生地 · 详细地址/历史地名';
|
||||||
|
|
||||||
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
|
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
|
||||||
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
|
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
|
||||||
function historyValue(key,value){
|
function historyValue(key,value){
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ class FamilyMap {
|
|||||||
const byId=new Map(data.people.map(p=>[p.id,p]));
|
const byId=new Map(data.people.map(p=>[p.id,p]));
|
||||||
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
|
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
|
||||||
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
|
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
|
||||||
const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term);
|
const matched=data.term&&personSearch(p).includes(data.term);
|
||||||
const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
|
const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
|
||||||
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
|
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
|
||||||
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
|
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
|
||||||
|
|||||||
+6
-4
@@ -6,6 +6,8 @@ const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
|
|||||||
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
|
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
|
||||||
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
|
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
|
||||||
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
|
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
|
||||||
|
function personLocation(p,prefix){return ['Country','Province','City','District','Town','Community','Address'].map(k=>p[prefix+k]).filter(Boolean).join(' · ');}
|
||||||
|
function personSearch(p){return [p.name,p.alias,p.birthplace,personLocation(p,'native'),personLocation(p,'birth')].join(' ').toLowerCase();}
|
||||||
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
||||||
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
||||||
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
||||||
@@ -30,7 +32,7 @@ async function loadFamily(resetView=false){
|
|||||||
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
|
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
|
||||||
function renderFamily(){
|
function renderFamily(){
|
||||||
const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase();
|
const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase();
|
||||||
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));
|
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term));
|
||||||
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
|
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
|
||||||
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
|
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
|
||||||
$('#new-person').hidden=!family.canEdit;
|
$('#new-person').hidden=!family.canEdit;
|
||||||
@@ -40,7 +42,7 @@ function renderFamily(){
|
|||||||
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
|
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
|
||||||
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
|
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
|
||||||
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
||||||
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯(祖籍)',personLocation(p,'native')],['出生地',personLocation(p,'birth')],...(p.birthplace?[['原地域记录',p.birthplace]]:[])].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
||||||
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
|
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
|
||||||
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
|
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,7 @@ function renderFamilyTree(){
|
|||||||
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
|
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
|
||||||
}
|
}
|
||||||
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
|
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
|
||||||
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
|
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
|
||||||
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
|
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
|
||||||
for(const [name,path]of [['person','person'],['relation','link']]){
|
for(const [name,path]of [['person','person'],['relation','link']]){
|
||||||
$('#'+name+'-form').addEventListener('submit',async e=>{
|
$('#'+name+'-form').addEventListener('submit',async e=>{
|
||||||
@@ -83,7 +85,7 @@ $('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderF
|
|||||||
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
|
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
|
||||||
$('#family-zoom-in').onclick=()=>familyMap.zoom(1.25);$('#family-zoom-out').onclick=()=>familyMap.zoom(.8);$('#family-fit').onclick=()=>familyMap.fit();
|
$('#family-zoom-in').onclick=()=>familyMap.zoom(1.25);$('#family-zoom-out').onclick=()=>familyMap.zoom(.8);$('#family-fit').onclick=()=>familyMap.fit();
|
||||||
$('#family-locate-self').onclick=()=>selectFamilyPerson(family.selfId,true);
|
$('#family-locate-self').onclick=()=>selectFamilyPerson(family.selfId,true);
|
||||||
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
|
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
|
||||||
let familyBodyOverflow='';
|
let familyBodyOverflow='';
|
||||||
function syncFamilyFullscreen(){
|
function syncFamilyFullscreen(){
|
||||||
const expanded=document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded');
|
const expanded=document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded');
|
||||||
|
|||||||
@@ -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;
|
if(!isAdmin())return;
|
||||||
const item=(state.households||[]).find(h=>h.id===id);
|
const item=(state.households||[]).find(h=>h.id===id);
|
||||||
if(!item&&!isPlatformAdmin())return;
|
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){
|
async function openLeadership(id){
|
||||||
try{
|
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');};
|
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
|
||||||
$('#new-household').onclick=()=>openHousehold();
|
$('#new-household').onclick=()=>openHousehold();
|
||||||
$('#household-form').addEventListener('submit',async e=>{
|
$('#household-form').addEventListener('submit',async e=>{
|
||||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
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),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
|
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;}
|
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
|
||||||
finally{b.disabled=false;}
|
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');}
|
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-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=()=>{coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
|
$('#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'));
|
||||||
|
|||||||
+13
-3
File diff suppressed because one or more lines are too long
+199
@@ -0,0 +1,199 @@
|
|||||||
|
'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 journalDraftMedia=new Set(),journalSelectedMedia=new Set();
|
||||||
|
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 releaseJournalMedia(root){
|
||||||
|
root.querySelectorAll('audio,video').forEach(v=>{v.pause();v.removeAttribute('src');v.load();});
|
||||||
|
root.querySelectorAll('img').forEach(img=>img.removeAttribute('src'));
|
||||||
|
}
|
||||||
|
function journalMediaView(m){
|
||||||
|
const name=esc(m.name),id=esc(m.id),src=esc(journalMediaUrl(m.id));
|
||||||
|
if(m.mime.startsWith('image/'))return `<figure class="journal-attachment"><button type="button" class="journal-photo" data-preview-media="${id}" aria-label="放大预览 ${name}"><img src="${src}" alt="${name}" loading="lazy" decoding="async"><span class="journal-photo-hint">点击放大</span></button><span class="journal-media-state" role="status">照片加载中…</span><button type="button" class="quiet" data-retry-media hidden>重新加载照片</button></figure>`;
|
||||||
|
const tag=m.mime.startsWith('audio/')?'audio':'video';
|
||||||
|
return `<figure class="journal-attachment journal-${tag}">${tag==='audio'?'<div class="journal-audio-label"><span aria-hidden="true">♪</span> 语音与音频</div>':''}<${tag} controls preload="metadata" playsinline src="${src}" aria-label="${name}"></${tag}><span class="journal-media-state" role="status">正在加载${tag==='audio'?'音频':'视频'}…</span><button type="button" class="quiet" data-retry-media hidden>重新加载</button></figure>`;
|
||||||
|
}
|
||||||
|
function bindJournalMedia(root){
|
||||||
|
root.querySelectorAll('.journal-attachment').forEach(box=>{
|
||||||
|
const media=box.querySelector('img,audio,video'),message=box.querySelector('.journal-media-state'),retry=box.querySelector('[data-retry-media]');
|
||||||
|
const ready=()=>{message.textContent='';retry.hidden=true;box.classList.remove('media-error');};
|
||||||
|
media.addEventListener(media.tagName==='IMG'?'load':'loadedmetadata',ready);
|
||||||
|
media.addEventListener('error',()=>{message.textContent=media.tagName==='IMG'?'照片暂未加载,请重试。':'暂时无法播放,请重试或下载原文件。';retry.hidden=false;box.classList.add('media-error');});
|
||||||
|
if(media.tagName==='IMG'&&media.complete&&media.naturalWidth)ready();
|
||||||
|
if(media.tagName!=='IMG'){
|
||||||
|
if(media.readyState>=1)ready();
|
||||||
|
media.addEventListener('waiting',()=>{message.textContent='正在缓冲…';});
|
||||||
|
media.addEventListener('playing',ready);media.addEventListener('canplay',ready);
|
||||||
|
media.addEventListener('play',()=>{$$('#journal-list audio,#journal-list video,#journal-upload-list audio,#journal-upload-list video,#journal-library-list audio,#journal-library-list video,#journal-preview audio,#journal-preview video').forEach(other=>{if(other!==media)other.pause();});});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function previewJournalMedia(id){
|
||||||
|
const all=[...(journalData?.uploads||[]),...(journalData?.trash||[]),...(journalData?.items||[]).flatMap(e=>e.media)];
|
||||||
|
const media=all.find(m=>m.id===id&&m.status==='ready');if(!media)return;
|
||||||
|
const dialog=$('#journal-preview');releaseJournalMedia($('#journal-preview-content'));
|
||||||
|
$('#journal-preview-title').textContent=media.name;$('#journal-preview-download').href=journalMediaUrl(id,true);
|
||||||
|
$('#journal-preview-content').innerHTML=journalMediaView(media);bindJournalMedia($('#journal-preview-content'));if(!dialog.open)dialog.showModal();
|
||||||
|
}
|
||||||
|
$('#journal-preview-close').onclick=()=>$('#journal-preview').close();
|
||||||
|
$('#journal-preview').addEventListener('close',()=>{releaseJournalMedia($('#journal-preview-content'));$('#journal-preview-content').replaceChildren();$('#journal-preview-download').removeAttribute('href');});
|
||||||
|
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();
|
||||||
|
for(const id of ['journal-list','journal-upload-list','journal-library-list','journal-preview-content'])releaseJournalMedia($('#'+id));
|
||||||
|
$('#journal-preview').close();
|
||||||
|
$('#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();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();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();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;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 journalUploadCard(m,library=false){
|
||||||
|
const id=esc(m.id),ready=m.status==='ready';
|
||||||
|
return `<div class="journal-upload-card" data-upload-card="${id}"><${library?'div':'label'} class="journal-upload-select">${library?'':`<input type="checkbox" value="${id}" ${ready?'':'disabled'} ${journalSelectedMedia.has(m.id)?'checked':''}>`}<span>${esc(m.name)}<small>${Math.ceil(m.size/1024)} KB · ${ready?'已存百度网盘':'等待继续上传'}</small></span></${library?'div':'label'}>${ready?journalMediaView(m):`<p class="description">${esc(m.error||'上传尚未完成,请选择原文件继续。')}</p>`}<div class="journal-card-actions">${ready?`<button type="button" class="quiet" data-preview-media="${id}">预览</button>${library?`<button type="button" class="quiet" data-use-upload="${id}">用于本条</button>`:`<button type="button" class="quiet" data-detach-upload="${id}">从本条移除</button>`}`:`<label class="quiet journal-resume">继续上传<input type="file" data-journal-resume="${id}"></label>`}<button type="button" class="quiet" data-remove-upload="${id}">删除</button></div></div>`;
|
||||||
|
}
|
||||||
|
function renderJournalUploads(){
|
||||||
|
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);
|
||||||
|
releaseJournalMedia($('#journal-upload-list'));releaseJournalMedia($('#journal-library-list'));
|
||||||
|
$('#journal-upload-list').innerHTML=available.filter(m=>journalDraftMedia.has(m.id)).map(m=>journalUploadCard(m)).join('');
|
||||||
|
const library=(journalData?.uploads||[]).filter(m=>!journalDraftMedia.has(m.id));
|
||||||
|
$('#journal-library-count').textContent=String(library.length);
|
||||||
|
$('#journal-library-list').innerHTML=$('#journal-library').open?(library.map(m=>journalUploadCard(m,true)).join('')||'<p class="description">暂无其他附件。</p>'):'';
|
||||||
|
const trash=journalData?.trash||[];$('#journal-trash').hidden=!trash.length;
|
||||||
|
$('#journal-trash-count').textContent=String(trash.length);
|
||||||
|
$('#journal-trash-list').innerHTML=trash.map(m=>`<div class="journal-trash-row"><span>${esc(m.name)}</span><button type="button" class="quiet" data-restore-upload="${esc(m.id)}">恢复</button></div>`).join('');
|
||||||
|
bindJournalMedia($('#journal-upload-list'));bindJournalMedia($('#journal-library-list'));
|
||||||
|
}
|
||||||
|
$('#journal-library').addEventListener('toggle',renderJournalUploads);
|
||||||
|
$('#journal-upload-list').addEventListener('change',e=>{if(e.target.type==='checkbox'){if(e.target.checked)journalSelectedMedia.add(e.target.value);else journalSelectedMedia.delete(e.target.value);}});
|
||||||
|
function renderJournalEntries(){
|
||||||
|
releaseJournalMedia($('#journal-list'));
|
||||||
|
$('#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=>`<div>${journalMediaView(m)}<p class="journal-media-caption">${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></p></div>`).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('');
|
||||||
|
bindJournalMedia($('#journal-list'));
|
||||||
|
}
|
||||||
|
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;journalDraftMedia.add(task.id);journalSelectedMedia.add(task.id);await loadJournal();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];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});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.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;}
|
||||||
|
if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;}
|
||||||
|
if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;}
|
||||||
|
if(b.hasAttribute('data-retry-media')){
|
||||||
|
const box=b.closest('.journal-attachment'),media=box.querySelector('img,audio,video');
|
||||||
|
b.hidden=true;box.classList.remove('media-error');box.querySelector('.journal-media-state').textContent='正在重新加载…';
|
||||||
|
if(media.tagName==='IMG'){const src=media.src;media.removeAttribute('src');media.src=src;}else media.load();return;
|
||||||
|
}
|
||||||
|
const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry;
|
||||||
|
if(!id&&!b.dataset.removeUpload&&!b.dataset.restoreUpload)return;b.disabled=true;
|
||||||
|
try{
|
||||||
|
if(b.dataset.removeUpload||b.dataset.restoreUpload){
|
||||||
|
if(journalUpload){journalMessage('请等待当前上传完成,或先暂停上传。');return;}
|
||||||
|
const restore=!!b.dataset.restoreUpload;
|
||||||
|
await api(restore?'/api/journal/restore-upload':'/api/journal/remove-upload',{id:b.dataset.restoreUpload||b.dataset.removeUpload});
|
||||||
|
if(!journalValid(g))return;
|
||||||
|
const result=await api('/api/journal?mode=mine');if(!journalValid(g))return;
|
||||||
|
journalData.uploads=result.uploads;journalData.trash=result.trash;if(!restore){journalDraftMedia.delete(b.dataset.removeUpload);journalSelectedMedia.delete(b.dataset.removeUpload);}
|
||||||
|
// Keep an editor's existing references current without replacing its draft or feed players.
|
||||||
|
for(const entry of journalData.items)for(const media of entry.media){const current=[...result.uploads,...result.trash].find(m=>m.id===media.id);if(current)Object.assign(media,current);}
|
||||||
|
renderJournalUploads();journalMessage(restore?'附件已恢复,可重新勾选使用。':'已移到“已删除附件”,可以恢复。已发布的记事和网盘原文件保留。');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});
|
||||||
+19
-10
@@ -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 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 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;
|
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=['设置中心','发布配置统一管理,每个账号保留自己的使用习惯。'];
|
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 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){
|
function applyAppearance(values=personalValues,defaults=branding.defaults){
|
||||||
@@ -26,6 +31,7 @@ function onAppearanceSession(preferences){
|
|||||||
applyBranding();if(!currentUser||!preferences)return;
|
applyBranding();if(!currentUser||!preferences)return;
|
||||||
const first=appearanceOwner!==currentUser.id;appearanceOwner=currentUser.id;
|
const first=appearanceOwner!==currentUser.id;appearanceOwner=currentUser.id;
|
||||||
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;applyAppearance();
|
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;applyAppearance();
|
||||||
|
if(first&&activePage==='settings'){loadSettings();return;}
|
||||||
if(first&&activePage==='households'){
|
if(first&&activePage==='households'){
|
||||||
let page=effectiveAppearance().startPage;
|
let page=effectiveAppearance().startPage;
|
||||||
if(page==='heritage'&&(currentHome()?.kind&¤tHome().kind!=='home'||!isAdmin()&¤tUser.familyAccess==='none'&&!['read','edit'].includes(currentUser.heritageAccess)))page='overview';
|
if(page==='heritage'&&(currentHome()?.kind&¤tHome().kind!=='home'||!isAdmin()&¤tUser.familyAccess==='none'&&!['read','edit'].includes(currentUser.heritageAccess)))page='overview';
|
||||||
@@ -33,6 +39,7 @@ function onAppearanceSession(preferences){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function resetAppearance(){
|
function resetAppearance(){
|
||||||
|
invalidateBrandImages();nextImageJob('cover');
|
||||||
appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;appearanceOwner='';brandDraft=null;brandLogo='';brandIcon='';coverDraft=undefined;
|
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();
|
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();
|
for(const selector of ['#brand-form','#preferences-form','#backup-form'])$(selector).reset();
|
||||||
@@ -43,6 +50,7 @@ function appearanceFields(container,values,inherit){
|
|||||||
for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k];
|
for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k];
|
||||||
}
|
}
|
||||||
function fillBrandForm(value){
|
function fillBrandForm(value){
|
||||||
|
invalidateBrandImages();
|
||||||
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
|
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
|
||||||
const form=$('#brand-form');form.reset();
|
const form=$('#brand-form');form.reset();
|
||||||
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
|
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
|
||||||
@@ -59,6 +67,7 @@ async function loadSettings(){
|
|||||||
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;
|
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;
|
||||||
appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();
|
appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();
|
||||||
if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;}
|
if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;}
|
||||||
|
if(typeof loadBaiduSettings==='function')loadBaiduSettings();
|
||||||
$('#settings-error').textContent='';
|
$('#settings-error').textContent='';
|
||||||
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;}
|
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;}
|
||||||
}
|
}
|
||||||
@@ -88,25 +97,25 @@ async function readImage(file,maxSide=512){
|
|||||||
}finally{bitmap.close();}
|
}finally{bitmap.close();}
|
||||||
}
|
}
|
||||||
for(const [id,type,max] of [['brand-logo-file','logo',256],['brand-icon-file','icon',96]]){
|
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};}
|
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=>{
|
$('#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='发布配置已保存。已有个人偏好继续保留。';}
|
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-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;}};
|
$('#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;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
|
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.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
|
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;后续备份步骤未完成':'备份失败'):r.baiduOk?(r.cloudCleanupError?'网盘备份已校验;过期副本清理待重试':r.cloudCleanupOk?'网盘备份已校验,过期副本清理完成':'本机备份完成,百度网盘已确认文件'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
|
||||||
function renderBackup(value){
|
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:'')+(value.last?.cloudCleanupError?':'+value.last.cloudCleanupError:'');
|
||||||
$('#backup-local-path').textContent='本机目录:'+value.localPath;
|
$('#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('');
|
$('#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.baiduPath?`<small>百度网盘:${esc(r.baiduPath)}</small>`:''}${r.cloudCleanupOk?`<small>保留最近一个自然月,本次删除 ${Number(r.cloudDeleted||0)} 份过期备份</small>`:''}${r.cloudCleanupError?`<small>${esc(r.cloudCleanupError)}</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);
|
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;}}
|
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;}}};
|
$('#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