From 72a3252e256edd1b806702a325929f9e55609a91 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 10:43:17 +0800 Subject: [PATCH] feat: isolate households and add explicit ownership succession --- ARCHITECTURE.md | 22 ++++- README.md | 22 ++++- VERSION | 2 +- app.py | 69 +++++++++----- genealogy.py | 34 ++++--- households.py | 150 ++++++++++++++++++++++++++++++ identity.py | 48 ++++++++-- test_app.py | 2 +- test_households.py | 211 ++++++++++++++++++++++++++++++++++++++++++ test_households_ui.js | 32 +++++++ web/app.js | 28 ++++-- web/chinese-theme.css | 5 + web/family.js | 3 +- web/households.js | 56 +++++++++++ web/index.html | 11 ++- 15 files changed, 635 insertions(+), 60 deletions(-) create mode 100644 households.py create mode 100644 test_households.py create mode 100644 test_households_ui.js create mode 100644 web/households.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12833a1..b960f23 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,11 +27,11 @@ 当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。 -主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 +主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。摄像机参数与云台按设备开放的 ONVIF 能力设置,不自动导入不存在的通道,不把服务可达当成画面接通。 ## 管理 API -所有数据接口(除会话状态与健康检查)需要管理员 Cookie 会话。写接口接受 JSON,检查同源请求。 +所有数据接口(除会话状态与健康检查)需要账号 Cookie 会话,并依据家庭、角色和空间核验访问权限。写接口接受 JSON,检查同源请求。 | 接口 | 用途 | | --- | --- | @@ -53,14 +53,14 @@ 本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。 -下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。 +下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ 已支持方向控制、停止和预置位;语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。 配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。 ## 身份与家谱 -`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。 +`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。`households.py` 管理家庭归属、负责人、交接记录与请求范围。每个家庭有独立家谱与修订号,按账号另授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。 | API | 权限 | | --- | --- | @@ -72,3 +72,17 @@ | GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 | 人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。 + + +## 家庭边界与管理连续性 + +账号 → 一个家庭 → 多个空间 → 设备对象 / 镜头 / 录像机。人物、亲属关系、记事和历史版本也属于一个家庭。 + +- `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。 +- API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。 +- 平台级扫描、存储策略、审计只允许超级管理员。重复接入已属于其他家庭的已知设备 IP 会被拒绝。 +- `/api/households` 供超级管理员创建、家庭管理员修改本家庭名称和备注。`/api/households/leadership` 仅允许当前家主或超级管理员办理家主及备用负责人安排。 +- 家主、备用负责人必须是本家庭已启用的家庭管理员且不能相同。备用负责人预先持有管理权限,意外发生不影响设备管理;身份交接需要有权限的账号明确操作、填写原因、检查修订号。 +- 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。 +- 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。 +- 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。 diff --git a/README.md b/README.md index adcc445..3fa1c27 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 +回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。 @@ -76,10 +76,10 @@ mediamtx ./data/mediamtx.yml ## 账号与家谱(v0.1.11) -- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。 +- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置归属本家庭管理员;网络发现、全局存储与审计仅超级管理员可用。媒体播放请求逐次检查空间权限。 - 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。 - 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。 -- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。 +- 家谱是各家庭独立的家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。v0.1.23 起按家庭隔离人物、记事、历史版本与导出。 - 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。 - 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。 - 支持关系参照人物、搜索、全家关系图、人物名册和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。 @@ -183,3 +183,19 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 - 本版不创建新的家庭数据或修改已有账户和录像策略;新增界面及设备操作继续使用既有空间、管理与云台权限。录像机原有历史录像仍未适配,以上续播与片段工具用于小电脑本机录像。 预置位协议依据:[ONVIF PTZ Service 5.4](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。 + + +## 家庭隔离与权限交接(v0.1.23) + +- **超级管理员**(`admin` 角色):可建立家庭、切换家庭和管理全部资料;现有 `admin` 账号保持此角色。 +- **一家之主**:本家庭一位已启用的家庭管理员,由超级管理员首次指定。可管理本家庭设备、成员及其他家庭管理员,并办理家主交接。 +- **家庭管理员**:管理本家庭设备、空间、家谱与普通成员;有家主后,其他管理员账号的设置由家主或超级管理员办理。 +- **家庭成员**:仅能查看本家庭已授权空间的画面与录像;家谱、云台另行授权。新账号默认不获得设备和家谱权限。亲属称呼不授予权限。 + +在「家庭管理」新建家庭,进入该家庭,在「账号与关系」建立家庭管理员。回到「家庭管理 → 家主与权限交接」,指定一家之主和另一位备用负责人,填写说明并保存。**备用负责人已经拥有本家庭管理权限**,家主意外无法操作时可以继续管理设备和成员;家主身份由原家主或超级管理员正式交接,原家主保留家庭管理员权限。不会依据去世日期或未登录时长自动授权。不允许停用或降级尚在任的家主、备用负责人;须先更换安排。至少保留一位启用的家庭管理员。交接使用修订号防止并发覆盖,并保留最近 50 条记录。普通成员不能读取交接说明。 + +家庭隔离由服务器执行:账号、空间、对象、镜头、录像机、家谱人物、关系、事件与历史版本均有家庭归属。HLS 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。 + +升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。 + +这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。 diff --git a/VERSION b/VERSION index 7e72641..001d752 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.22 +0.1.23 diff --git a/app.py b/app.py index 91089ad..3b5cfcb 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,7 @@ import onvif import playback import identity import genealogy +import households import ptz ROOT = Path(__file__).resolve().parent @@ -73,6 +74,7 @@ class Problem(Exception): ACCOUNTS = identity.Accounts(sys.modules[__name__]) FAMILY = genealogy.Family(sys.modules[__name__]) +HOUSEHOLDS = households.Households(sys.modules[__name__]) def setting(key, default=None): @@ -144,9 +146,9 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o def serialized_save(function): @functools.wraps(function) - def save(body): + def save(body, *args, **kwargs): with LOCK: - return function(body) + return function(body, *args, **kwargs) return save @@ -162,11 +164,12 @@ def object_key(table, body): @serialized_save -def save_site(body): +def save_site(body, actor=None): + family = HOUSEHOLDS.prepare(actor, 'sites', body) key = object_key('sites', body) item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100, required=k == 'name') for k in SITE_FIELDS} - item.update(id=key, kind=body.get('kind', 'home')) + item.update(id=key, kind=body.get('kind', 'home'), familyId=family) if item['kind'] not in ('home', 'company', 'other'): raise Problem('请选择家庭、公司或其他用途') save_object('sites', item) @@ -175,7 +178,8 @@ def save_site(body): @serialized_save -def save_camera(body): +def save_camera(body, actor=None): + family = HOUSEHOLDS.prepare(actor, 'cameras', body, (('assets', 'assetId'), ('recorders', 'recorderId'), ('recorders', 'archiveRecorderId'))) key = object_key('cameras', body) old = get_object('cameras', key) or {} item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') @@ -201,7 +205,8 @@ def save_camera(body): raise Problem('关联的录像存储设备不存在') item['archiveRecorderId'] = archive_recorder item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')), - port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535)) + port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family) + HOUSEHOLDS.endpoint(actor, item['host']) for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]: path = clean_text(body.get(k, default), 500, required=True) if not path.startswith('/') or path.startswith('//') or '#' in path: @@ -224,14 +229,15 @@ def save_camera(body): @serialized_save -def save_asset(body): +def save_asset(body, actor=None): + family = HOUSEHOLDS.prepare(actor, 'assets', body, (('sites', 'siteId'),)) key = object_key('assets', body) site = body.get('siteId') if not get_object('sites', site): raise Problem('请选择所属空间') item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') for k in ('name', 'brand', 'model', 'serial', 'point', 'note')} - item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16)) + item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family) attached = [c for c in objects('cameras') if c.get('assetId') == key] if len(attached) > item['lensCount']: raise Problem('镜头数不能小于已经建立的通道数') @@ -245,7 +251,8 @@ def save_asset(body): @serialized_save -def save_recorder(body): +def save_recorder(body, actor=None): + family = HOUSEHOLDS.prepare(actor, 'recorders', body, (('sites', 'siteId'),)) key = object_key('recorders', body) old = get_object('recorders', key) or {} item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') @@ -254,7 +261,8 @@ def save_recorder(body): if not get_object('sites', site): raise Problem('请选择录像机所属空间') item.update(id=key, siteId=site, host=host_address(body.get('host', '')), - port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp')) + port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'), familyId=family) + HOUSEHOLDS.endpoint(actor, item['host']) if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'): raise Problem('接入驱动不正确') password = body.get('password', '') @@ -514,6 +522,11 @@ class Handler(BaseHTTPRequestHandler): self.user = self.authenticated() if not self.user: raise Problem('请先登录', 401) + self.user = HOUSEHOLDS.context(self.user, self.headers.get('X-Household-Id')) + + def require_manager(self): + if not HOUSEHOLDS.manager(self.user): + raise Problem('此操作需要家庭管理员权限', 403) def require_admin(self): if self.user['role'] != 'admin': @@ -598,7 +611,7 @@ class Handler(BaseHTTPRequestHandler): self.require_auth() if path not in ('/api/logout', '/api/account/password', '/api/family/restore', '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'): - self.require_admin() + self.require_manager() if path == '/api/logout': with LOCK: DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),)) @@ -622,7 +635,7 @@ class Handler(BaseHTTPRequestHandler): elif path == '/api/ptz/presets': c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) if data.get('action') != 'goto': - self.require_admin() + self.require_manager() try: result = ptz.CONTROLLER.preset_command(c, data) except ValueError as e: @@ -643,19 +656,26 @@ class Handler(BaseHTTPRequestHandler): self.answer(result) elif path == '/api/users': self.answer(ACCOUNTS.save(data, self.user)) + elif path == '/api/households': + self.answer(HOUSEHOLDS.save(data, self.user)) + elif path == '/api/households/leadership': + self.answer(HOUSEHOLDS.leadership(data, self.user)) elif path in ('/api/family/person', '/api/family/link', '/api/family/event'): self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user)) elif path == '/api/sites': - self.answer(save_site(data)) + self.answer(save_site(data, self.user)) elif path == '/api/cameras': - self.answer(save_camera(data)) + self.answer(save_camera(data, self.user)) elif path == '/api/assets': - self.answer(save_asset(data)) + self.answer(save_asset(data, self.user)) elif path == '/api/recorders': - self.answer(save_recorder(data)) + self.answer(save_recorder(data, self.user)) elif path == '/api/onvif': old = get_object('cameras', data.get('id')) or {} + if data.get('id'): + HOUSEHOLDS.owns(self.user, old) host = host_address(data.get('host', '')) + HOUSEHOLDS.endpoint(self.user, host) port = integer(data.get('port', 80), 1, 65535) username = clean_text(data.get('username', ''), 120, required=True) password = data.get('password') or old.get('password', '') @@ -667,6 +687,7 @@ class Handler(BaseHTTPRequestHandler): raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502) self.answer(result) elif path == '/api/storage': + self.require_admin() policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365), 'maxGB': integer(data.get('maxGB'), 5, 100000), 'reserveGB': integer(data.get('reserveGB'), 2, 1000)} @@ -675,6 +696,7 @@ class Handler(BaseHTTPRequestHandler): audit('更新录像保留策略') self.answer({'ok': True}) elif path == '/api/discover': + self.require_admin() try: network = ipaddress.ip_network(data.get('network', ''), strict=False) if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30: @@ -777,6 +799,7 @@ class Handler(BaseHTTPRequestHandler): query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()} if path == '/api/session': user = self.authenticated() + user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id')) self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user), 'setupRequired': not bool(objects('users'))}) return @@ -788,8 +811,11 @@ class Handler(BaseHTTPRequestHandler): if path == '/api/state': self.answer(ACCOUNTS.state(self.user, status())) elif path == '/api/users': - self.require_admin() - self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]}) + self.require_manager() + self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users') + if self.user['role'] == 'admin' or u['role'] != 'admin']}) + elif path == '/api/households': + self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)}) elif path == '/api/ptz/presets': c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) try: @@ -798,7 +824,7 @@ class Handler(BaseHTTPRequestHandler): raise Problem(str(e), 400) except Exception: raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502) - self.answer(dict(result, canManage=self.user['role'] == 'admin')) + self.answer(dict(result, canManage=HOUSEHOLDS.manager(self.user))) elif path == '/api/ptz': c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) try: @@ -811,7 +837,7 @@ class Handler(BaseHTTPRequestHandler): elif path == '/api/family': self.answer(FAMILY.snapshot(self.user)) elif path == '/api/camera/settings': - self.require_admin() + self.require_manager() c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera'))) try: result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main')) @@ -873,7 +899,7 @@ class Handler(BaseHTTPRequestHandler): raise Problem('接口不存在', 404) else: files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css', - '/family.js': 'family.js', '/kinship.js': 'kinship.js', + '/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js', '/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js', '/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css', '/family-events.js': 'family-events.js', @@ -911,6 +937,7 @@ def initialize(): global MEDIA_AUTH FAMILY.initialize() ACCOUNTS.initialize() + HOUSEHOLDS.initialize() if not setting('storage'): set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) if not setting('mediaSecret'): diff --git a/genealogy.py b/genealogy.py index 882d0f2..45c7478 100644 --- a/genealogy.py +++ b/genealogy.py @@ -24,35 +24,44 @@ class Family: self.a.DB.commit() def authorize(self, user, edit=False): - if user['role'] != 'admin' and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')): + self.a.HOUSEHOLDS.scope(user) + if not self.a.HOUSEHOLDS.manager(user) and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')): raise self.a.Problem('没有家谱' + ('编辑' if edit else '查看') + '权限', 403) def snapshot(self, user): self.authorize(user) with self.a.LOCK: - can_edit = user['role'] == 'admin' or user.get('familyAccess') == 'edit' - return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'), - 'links': self.a.objects('family_links'), - 'events': [e for e in self.a.objects('family_events') + can_edit = self.a.HOUSEHOLDS.manager(user) or user.get('familyAccess') == 'edit' + people = self.a.HOUSEHOLDS.objects(user, 'people') + return {'revision': self.a.setting(self.a.HOUSEHOLDS.revision_key(user), 0), 'people': people, + 'links': self.a.HOUSEHOLDS.objects(user, 'family_links'), + 'events': [e for e in self.a.HOUSEHOLDS.objects(user, 'family_events') if can_edit or (e.get('status') == 'published' and not e.get('archived'))], 'canEdit': can_edit, - 'selfId': user.get('personId', '')} + 'selfId': user.get('personId', '') if any(p['id'] == user.get('personId') for p in people) else '', + 'familyId': self.a.HOUSEHOLDS.scope(user)} def save(self, kind, data, user, restored_from=None): self.authorize(user, True) a = self.a with a.LOCK: - revision = a.setting('familyRevision', 0) + revision_key = a.HOUSEHOLDS.revision_key(user) + revision = a.setting(revision_key, 0) if type(data.get('revision')) is not int or data['revision'] != revision: raise a.Problem('家谱已被更新,请重新载入后再编辑', 409) if kind not in ('person', 'link', 'event'): raise a.Problem('家谱记录类型不存在', 404) table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind] + family_id = a.HOUSEHOLDS.prepare(user, table, data, + (('people', 'fromId'), ('people', 'toId')) if kind == 'link' else ()) + if kind == 'event' and isinstance(data.get('personIds', []), list): + for person in data.get('personIds', []): + a.HOUSEHOLDS.owns(user, a.get_object('people', person)) old = a.get_object(table, data.get('id')) if data.get('id') and not old: raise a.Problem('家谱记录不存在', 404) item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old) - item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username']) + item.update(familyId=family_id, updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username']) if kind == 'event': item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'], createdBy=old.get('createdBy', user['username']) if old else user['username']) @@ -63,10 +72,11 @@ class Family: item['publishedAt'] = item['updatedAt'] # Revision and record are committed together, so concurrent forms cannot overwrite silently. with a.DB: + change_revision = a.DB.execute('SELECT COALESCE(MAX(revision),0)+1 FROM family_changes').fetchone()[0] a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item))) - a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1))) + a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (revision_key, json.dumps(revision + 1))) a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)', - (revision + 1, kind, item['id'], json.dumps(old) if old else None, + (change_revision, kind, item['id'], json.dumps(old) if old else None, json.dumps(item), user['username'], item['updatedAt'], restored_from)) a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username']) return {'item': item, 'revision': revision + 1} @@ -79,6 +89,7 @@ class Family: with a.LOCK: if not table or not a.get_object(table, target_id): raise a.Problem('家谱记录不存在', 404) + a.HOUSEHOLDS.owns(user, a.get_object(table, target_id)) if before is not None: a.integer(before, 1, 2**53 - 1) rows = a.DB.execute('SELECT * FROM family_changes WHERE kind=? AND target_id=? AND revision 20 else None, - revision=a.setting('familyRevision', 0)) + revision=a.setting(a.HOUSEHOLDS.revision_key(user), 0)) def change(self, user, change_revision): self.authorize(user, True) @@ -100,6 +111,7 @@ class Family: row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone() if not row: raise self.a.Problem('修改记录不存在', 404) + self.a.HOUSEHOLDS.owns(user, json.loads(row['after_json'])) return dict(revision=row['revision'], kind=row['kind'], targetId=row['target_id'], before=json.loads(row['before_json']) if row['before_json'] else None, after=json.loads(row['after_json']), at=row['at'], actor=row['actor']) diff --git a/households.py b/households.py new file mode 100644 index 0000000..6d8afb6 --- /dev/null +++ b/households.py @@ -0,0 +1,150 @@ +"""Household ownership and request scope. A site is a location within one household.""" +import datetime as dt +import json +import re +import secrets + + +class Households: + TABLES = ('sites', 'assets', 'recorders', 'cameras', 'people', 'family_links', 'family_events', 'users') + + def __init__(self, app): + self.a = app + + @staticmethod + def manager(user): + return bool(user and user.get('role') in ('admin', 'family_admin')) + + def initialize(self): + a = self.a + with a.LOCK, a.DB: + a.DB.execute('CREATE TABLE IF NOT EXISTS households (id TEXT PRIMARY KEY, body TEXT NOT NULL)') + default = a.setting('defaultHousehold') + if not default: + default = secrets.token_hex(8) + a.DB.execute('INSERT INTO settings VALUES (?,?)', ('defaultHousehold', json.dumps(default))) + item = dict(id=default, name='赵府', note='原有家庭资料', createdAt=dt.datetime.now(dt.timezone.utc).isoformat()) + a.DB.execute('INSERT INTO households VALUES (?,?)', (default, json.dumps(item))) + # One-time ownership backfill preserves identifiers, credentials and device paths. + for table in self.TABLES: + for row in a.DB.execute('SELECT id,body FROM ' + table).fetchall(): + item = json.loads(row['body']) + if 'familyId' not in item: + item['familyId'] = default + a.DB.execute('UPDATE ' + table + ' SET body=? WHERE id=?', (json.dumps(item), row['id'])) + for row in a.DB.execute('SELECT revision,before_json,after_json FROM family_changes').fetchall(): + for column in ('before_json', 'after_json'): + item = json.loads(row[column]) if row[column] else None + if item is not None and 'familyId' not in item: + item['familyId'] = default + a.DB.execute('UPDATE family_changes SET ' + column + '=? WHERE revision=?', + (json.dumps(item), row['revision'])) + + def family_of(self, item): + # Legacy in-process import helpers remain in the migrated default household. + return item.get('familyId', self.a.setting('defaultHousehold')) if item is not None else None + + def scope(self, user): + family = (user or {}).get('_familyId') or self.family_of(user or {}) + if not family or not self.a.get_object('households', family): + raise self.a.Problem('账号尚未分配有效家庭,请联系平台管理员', 403) + return family + + def context(self, user, requested=None): + if not user: + return None + own = self.family_of(user) + if requested and user['role'] != 'admin' and requested != own: + raise self.a.Problem('没有这个家庭的访问权限', 403) + result = dict(user, _familyId=requested or own) + self.scope(result) + return result + + def owns(self, user, item): + if not item or self.family_of(item) != self.scope(user): + raise self.a.Problem('记录不存在或不属于当前家庭', 404) + return item + + def objects(self, user, table): + family = self.scope(user) + return [x for x in self.a.objects(table) if self.family_of(x) == family] + + def prepare(self, user, table, body, references=()): + family = self.scope(user) + if body.get('familyId', family) != family: + raise self.a.Problem('不能修改记录的家庭归属', 403) + if body.get('id'): + if not isinstance(body['id'], str) or not re.fullmatch('[a-f0-9]{16}', body['id']): + raise self.a.Problem('对象编号不正确') + self.owns(user, self.a.get_object(table, body['id'])) + for target, key in references: + if body.get(key): + self.owns(user, self.a.get_object(target, body[key])) + return family + + def endpoint(self, user, host): + family = self.scope(user) + if any(item.get('host') == host and self.family_of(item) != family + for table in ('cameras', 'recorders') for item in self.a.objects(table)): + raise self.a.Problem('该设备地址已归属其他家庭,不能重复接入', 403) + + def list(self, user): + rows = self.a.objects('households') if user['role'] == 'admin' else [self.a.get_object('households', self.scope(user))] + def label(uid): + account = self.a.get_object('users', uid) + return account.get('name', '') if account else '' + return [dict({k: v for k, v in row.items() if k != 'leadershipHistory' or self.manager(user)}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')), + canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'], + members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')), + sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows] + + def leadership(self, data, user): + """Explicit handover; a backup already has management access during an emergency.""" + a = self.a + with a.LOCK: + home = a.get_object('households', data.get('id')) + if not home or home['id'] != self.scope(user): + raise a.Problem('请先进入要管理的家庭', 404) + if user['role'] != 'admin' and user['id'] != home.get('ownerId'): + raise a.Problem('只有一家之主或超级管理员可以交接管理权', 403) + if data.get('revision') != home.get('leadershipRevision', 0): + raise a.Problem('家庭负责人已变更,请重新载入', 409) + owner_id = a.clean_text(data.get('ownerId', ''), 32, True) + successor_id = a.clean_text(data.get('successorId', ''), 32) + reason = a.clean_text(data.get('reason', ''), 300, True) + if owner_id == successor_id: + raise a.Problem('备用负责人必须是另一位家庭管理员') + for uid in filter(None, (owner_id, successor_id)): + account = self.owns(user, a.get_object('users', uid)) + if account['role'] != 'family_admin' or account['disabled']: + raise a.Problem('负责人须为本家庭已启用的家庭管理员') + record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason, + previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id) + home.update(ownerId=owner_id, successorId=successor_id, + leadershipRevision=home.get('leadershipRevision', 0) + 1, + leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:]) + a.save_object('households', home) + a.audit('家庭负责人交接', user['username'] + ' / ' + home['name']) + return {'ok': True} + + def save(self, data, user): + a = self.a + if not self.manager(user): + raise a.Problem('需要家庭管理员权限', 403) + with a.LOCK: + old = a.get_object('households', data.get('id')) + if data.get('id') and not old: + raise a.Problem('家庭不存在', 404) + if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)): + raise a.Problem('只能管理自己所属的家庭', 403) + item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8), + name=a.clean_text(data.get('name', ''), 80, True), + note=a.clean_text(data.get('note', ''), 300), + createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat()) + a.save_object('households', item) + a.audit('保存家庭', item['name']) + return item + + def revision_key(self, user): + family = self.scope(user) + return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family diff --git a/identity.py b/identity.py index f495392..efbc04a 100644 --- a/identity.py +++ b/identity.py @@ -30,7 +30,7 @@ class Accounts: @staticmethod def public(user): - return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None + return {k: v for k, v in user.items() if k not in ('salt', 'hash') and not k.startswith('_')} if user else None def password(self, password): if not isinstance(password, str) or not 8 <= len(password) <= 128: @@ -50,6 +50,9 @@ class Accounts: def save(self, data, actor): a = self.a with a.LOCK: + if actor and not a.HOUSEHOLDS.manager(actor): + raise a.Problem('需要家庭管理员权限', 403) + family = a.HOUSEHOLDS.prepare(actor, 'users', data) old = a.get_object('users', data.get('id')) if data.get('id') and not old: raise a.Problem('账号不存在', 404) @@ -61,29 +64,47 @@ class Accounts: raise a.Problem('用户名已存在', 409) role = data.get('role', 'member') access = data.get('familyAccess', 'none') - if role not in ('admin', 'member') or access not in ('none', 'read', 'edit'): + if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'): raise a.Problem('权限选项不正确') + if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')): + raise a.Problem('家庭管理员不能创建或修改超级管理员', 403) + home = a.get_object('households', family) + if actor and actor['role'] != 'admin' and home.get('ownerId') and actor['id'] != home['ownerId']: + if (role == 'family_admin' and not old) or (old and old['id'] != actor['id'] and + (role == 'family_admin' or old['role'] == 'family_admin')): + raise a.Problem('请由一家之主或超级管理员管理家庭管理员账号', 403) sites = data.get('siteIds', []) if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites): raise a.Problem('请选择有效空间') + for site in sites: + a.HOUSEHOLDS.owns(actor, a.get_object('sites', site)) ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False) if not isinstance(ptz_control, bool): raise a.Problem('云台权限格式不正确') disabled = data.get('disabled', False) if not isinstance(disabled, bool): raise a.Problem('账号状态不正确') + if old and uid in (home.get('ownerId'), home.get('successorId')) and (disabled or role != 'family_admin'): + raise a.Problem('请先交接家主或更换备用负责人,再停用或调整此账号') if old and old['role'] == 'admin' and (role != 'admin' or disabled): if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')): raise a.Problem('至少保留一个启用的管理员') + if old and old['role'] == 'family_admin' and (role != 'family_admin' or disabled): + if not any(u['id'] != uid and u['role'] == 'family_admin' and not u['disabled'] + for u in a.HOUSEHOLDS.objects(actor, 'users')): + raise a.Problem('至少保留一个启用的家庭管理员') if actor and actor['id'] == uid and (disabled or role != actor['role']): raise a.Problem('不能停用或降低当前登录账号的管理权限') related = a.clean_text(data.get('relatedToId', ''), 32) person = a.clean_text(data.get('personId', ''), 32) if related and related != uid and not a.get_object('users', related): raise a.Problem('关系参照账号不存在') + if related and related != uid: + a.HOUSEHOLDS.owns(actor, a.get_object('users', related)) if person: if not a.get_object('people', person): raise a.Problem('家谱人物不存在') + a.HOUSEHOLDS.owns(actor, a.get_object('people', person)) if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')): raise a.Problem('这个人物已经关联其他账号') password = data.get('password', '') @@ -91,11 +112,11 @@ class Accounts: raise a.Problem('密码格式不正确') credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')} user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True), - role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control, + role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control, personId=person, relatedToId=related, relationship=a.clean_text(data.get('relationship', ''), 40), **credentials) # Revoke sessions when credentials or authorization change, not for label edits. - if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl')): + if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')): a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,)) a.save_object('users', user) a.audit('更新账号' if old else '建立账号', username) @@ -128,13 +149,18 @@ class Accounts: def camera(self, user, camera): if not camera: raise self.a.Problem('摄像头不存在', 404) - if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']: + if user['role'] != 'admin': + self.a.HOUSEHOLDS.owns(user, camera) + site = self.a.get_object('sites', camera.get('siteId')) + if site: + self.a.HOUSEHOLDS.owns(user, site) + if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in user['siteIds']: raise self.a.Problem('没有这个空间的访问权限', 403) return camera def control(self, user, camera): self.camera(user, camera) - if user['role'] != 'admin' and not user.get('ptzControl', False): + if not self.a.HOUSEHOLDS.manager(user) and not user.get('ptzControl', False): raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403) if not camera.get('enabled'): raise self.a.Problem('摄像头已停用', 409) @@ -142,7 +168,15 @@ class Accounts: def state(self, user, state): state['user'] = self.public(user) - if user['role'] == 'admin': + family = self.a.HOUSEHOLDS.scope(user) + state['familyId'] = family + state['households'] = self.a.HOUSEHOLDS.list(user) + for key in ('sites', 'assets', 'cameras', 'recorders'): + state[key] = [x for x in state[key] if self.a.HOUSEHOLDS.family_of(x) == family] + if user['role'] != 'admin': + state['scan'] = {} + state['storage'] = {} + if self.a.HOUSEHOLDS.manager(user): return state permitted = set(user['siteIds']) state['sites'] = [s for s in state['sites'] if s['id'] in permitted] diff --git a/test_app.py b/test_app.py index e214ae2..fd5e634 100644 --- a/test_app.py +++ b/test_app.py @@ -105,7 +105,7 @@ class WebTests(unittest.TestCase): self.assertEqual(headers['Cache-Control'], 'no-store') self.assertNotIn(b'__ASSET_VERSION__', body) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) - self.assertEqual(len(assets), 19) + self.assertEqual(len(assets), 20) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) diff --git a/test_households.py b/test_households.py new file mode 100644 index 0000000..7075f55 --- /dev/null +++ b/test_households.py @@ -0,0 +1,211 @@ +"""Two-household HTTP isolation and management succession regressions.""" +import hashlib +import http.client +import json +import secrets +import sqlite3 +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +# Reuse only module initialization; never inherit the original WebTests. +from test_app import app + + +class HouseholdTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + db = sqlite3.connect(':memory:', check_same_thread=False) + db.row_factory = sqlite3.Row + self.addCleanup(db.close) + db.executescript(''' + CREATE TABLE settings (key TEXT PRIMARY KEY,value TEXT NOT NULL); + CREATE TABLE sessions (hash TEXT PRIMARY KEY,expires REAL NOT NULL); + CREATE TABLE audit (id INTEGER PRIMARY KEY,at TEXT,action TEXT,name TEXT); + ''') + for table in ('sites', 'assets', 'recorders', 'cameras'): + db.execute('CREATE TABLE '+table+' (id TEXT PRIMARY KEY,body TEXT NOT NULL)') + for attr, value in [('DB', db), ('DATA', Path(self.temp.name)), + ('write_media_config', lambda: None)]: + p = patch.object(app, attr, value); p.start(); self.addCleanup(p.stop) + app.initialize() + self.root = app.ACCOUNTS.save(dict(username='admin', password='fixture-only', role='admin'), None) + self.aid = self.root['familyId'] + self.bid = app.HOUSEHOLDS.save({'name': '乙家庭'}, self.root)['id'] + self.roots = {h: app.HOUSEHOLDS.context(self.root, h) for h in (self.aid, self.bid)} + self.managers, self.members, self.sites, self.cameras, self.assets = {}, {}, {}, {}, {} + for i, home in enumerate((self.aid, self.bid)): + actor = self.roots[home] + self.sites[home] = app.save_site({'name': 'home-'+str(i)}, actor) + self.assets[home] = app.save_asset(dict(name='asset-'+str(i), siteId=self.sites[home]['id']), actor) + self.cameras[home] = app.save_camera(dict(name='camera-'+str(i), assetId=self.assets[home]['id'], + host='192.168.3.'+str(10+i), enabled=True), actor) + self.managers[home] = app.ACCOUNTS.save(dict(username='manager'+str(i), password='fixture-only', role='family_admin'), actor) + self.members[home] = app.ACCOUNTS.save(dict(username='member'+str(i), password='fixture-only', + siteIds=[self.sites[home]['id']], familyAccess='edit', ptzControl=True), actor) + self.owner, self.other = self.managers[self.aid], self.managers[self.bid] + self.backup = app.ACCOUNTS.save(dict(username='backup', password='fixture-only', role='family_admin'), self.root) + self.tokens = {} + for user in [self.root, self.backup, *self.managers.values(), *self.members.values()]: + token = secrets.token_hex(32); self.tokens[user['id']] = token + db.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id'])) + db.commit() + def state(): + return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}} + p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop) + self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start() + self.addCleanup(self.stop_server) + + def stop_server(self): + self.server.shutdown(); self.server.server_close(); self.thread.join() + + def request(self, path, user=None, data=None, home=None): + user = user or self.owner + c = http.client.HTTPConnection(*self.server.server_address, timeout=3) + headers = {'Cookie': 'vision='+self.tokens[user['id']]} + if home: headers['X-Household-Id'] = home + if data is not None: headers['Content-Type'] = 'application/json' + c.request('GET' if data is None else 'POST', path, json.dumps(data) if data is not None else None, headers) + response = c.getresponse(); raw = response.read(); code = response.status; c.close() + return code, json.loads(raw) + + def leadership(self, user=None, **fields): + data = dict(id=self.aid, ownerId=self.owner['id'], successorId=self.backup['id'], revision=0, reason='提前安排备用负责人') + return self.request('/api/households/leadership', user or self.root, data=dict(data, **fields)) + + def test_state_and_accounts_are_scoped(self): + for home in (self.aid, self.bid): + for user in (self.managers[home], self.members[home]): + code, body = self.request('/api/state', user) + self.assertEqual(code, 200) + for table in ('sites','assets','cameras'): + self.assertEqual(len(body[table]), 1) + self.assertEqual(body['cameras'][0]['id'], self.cameras[home]['id']) + self.assertEqual([h['id'] for h in body['households']], [home]) + self.assertEqual(body['storage'], {}); self.assertEqual(body['scan'], {}) + code, body = self.request('/api/users') + self.assertEqual(code, 200) + self.assertTrue(all(u['familyId']==self.aid and u['role']!='admin' for u in body['users'])) + self.assertNotIn('hash', json.dumps(body)); self.assertNotIn('salt', json.dumps(body)) + + def test_forged_household_header_denied_root_can_switch(self): + for user in (self.owner, self.members[self.aid]): + for path in ('/api/session','/api/state','/api/family','/api/households'): + self.assertEqual(self.request(path, user, home=self.bid)[0], 403) + code, body = self.request('/api/state', self.root, home=self.bid) + self.assertEqual(code, 200); self.assertEqual(body['familyId'], self.bid) + self.assertEqual(body['cameras'][0]['id'], self.cameras[self.bid]['id']) + + def test_guessed_camera_ids_cannot_access_media_or_controls(self): + camera = self.cameras[self.bid]['id'] + paths = [f'/media/live/cam_{camera}_{quality}/{file}' for quality in ('main','sub','hd','compat') + for file in ('index.m3u8','init.mp4','segment1.mp4')] + paths += [f'/api/recordings?camera={camera}', f'/media/playback?camera={camera}', + f'/media/playback?camera={camera}&download=1', f'/api/ptz?camera={camera}', + f'/api/ptz/presets?camera={camera}', f'/api/camera/settings?camera={camera}'] + with patch.object(app.Handler, 'proxy') as proxy: + for user in (self.owner,self.members[self.aid]): + for path in paths: + with self.subTest(path=path): self.assertIn(self.request(path,user)[0], (403,404)) + for path in ('/api/ptz','/api/ptz/presets','/api/camera/settings'): + self.assertIn(self.request(path,user,dict(camera=camera, action='stop'))[0], (403,404)) + proxy.assert_not_called() + + def test_own_media_access_and_ungranted_site(self): + def proxy(handler,*args,**kwargs): handler.answer({'ok':True}) + camera = self.cameras[self.aid]['id']; path = f'/media/live/cam_{camera}_hd/index.m3u8' + with patch.object(app.Handler,'proxy',proxy): + self.assertEqual(self.request(path,self.members[self.aid])[0],200) + member=dict(self.members[self.aid],siteIds=[]) + # Update DB directly to retain token and exercise per-request permission lookup. + app.save_object('users',dict(app.get_object('users',member['id']),siteIds=[])) + self.assertEqual(self.request(path,member)[0],403) + self.assertEqual(self.request(path,self.owner)[0],200) + + def test_cross_household_edits_references_and_ownership_blocked(self): + cases=[('/api/sites',dict(self.sites[self.bid],name='stolen')), + ('/api/assets',dict(name='bad',siteId=self.sites[self.bid]['id'])), + ('/api/cameras',dict(name='bad',assetId=self.assets[self.bid]['id'])), + ('/api/recorders',dict(name='bad',siteId=self.sites[self.bid]['id'])), + ('/api/sites',dict(self.sites[self.aid],familyId=self.bid))] + for path,data in cases: self.assertIn(self.request(path,data=data)[0],(403,404)) + self.assertEqual(self.request('/api/sites',data=dict(self.sites[self.aid],name='own edit'))[0],200) + + def test_other_device_address_and_onvif_credentials_protected(self): + with patch.object(app.onvif,'inspect') as inspect: + for data in (dict(id=self.cameras[self.bid]['id']),dict(host=self.cameras[self.bid]['host'],username='admin')): + self.assertIn(self.request('/api/onvif',data=data)[0],(403,404)) + inspect.assert_not_called() + self.assertEqual(self.request('/api/cameras',data=dict(name='duplicate',assetId=self.assets[self.aid]['id'],host=self.cameras[self.bid]['host']))[0],403) + + def test_manager_cannot_escalate_or_touch_another_family_user(self): + for data in (dict(username='root2',password='fixture-only',role='admin'), + dict(self.other,name='stolen'), dict(self.root,name='hijack'), + dict(username='cross',password='fixture-only',siteIds=[self.sites[self.bid]['id']]), + dict(username='cross',password='fixture-only',relatedToId=self.other['id'])): + self.assertIn(self.request('/api/users',data=data)[0],(403,404)) + + def test_global_operations_require_super_admin(self): + for path,data in (('/api/storage',{}),('/api/discover',{}),('/api/households',{'name':'unauthorized'})): + self.assertEqual(self.request(path,data=data)[0],403) + self.assertEqual(self.request('/api/audit')[0],403) + self.assertEqual(self.request('/api/households',self.members[self.aid],{'name':'unauthorized'})[0],403) + + def test_genealogy_revisions_history_restore_and_refs_isolated(self): + a = app.FAMILY.save('person',dict(name='A private',revision=0),self.owner)['item'] + b = app.FAMILY.save('person',dict(name='B private',revision=0),self.other)['item'] + self.assertEqual(self.request('/api/family')[1]['revision'],1) + self.assertEqual([p['name'] for p in self.request('/api/family')[1]['people']],['A private']) + revision=app.FAMILY.history(self.other,'person',b['id'])['items'][0]['revision'] + for path in (f'/api/family/history?kind=person&id={b["id"]}', f'/api/family/change?revision={revision}'): + self.assertEqual(self.request(path)[0],404) + for path,data in (('/api/family/restore',dict(changeRevision=revision,side='after',revision=1)), + ('/api/family/person',dict(b,revision=1)), + ('/api/family/link',dict(fromId=a['id'],toId=b['id'],kind='parent',revision=1)), + ('/api/family/event',dict(personIds=[b['id']],revision=1))): + self.assertIn(self.request(path,data=data)[0],(403,404)) + self.assertEqual(self.request('/api/users',data=dict(username='crossperson',password='fixture-only',personId=b['id']))[0],404) + + def test_leadership_backup_and_explicit_emergency_handover(self): + self.assertEqual(self.leadership()[0],200) + # Backup remains operational even if the owner's browser is unavailable. + self.assertEqual(self.request('/api/sites',self.backup,dict(name='emergency operation'))[0],200) + self.assertEqual(self.leadership(self.backup,revision=1,ownerId=self.backup['id'],successorId='')[0],403) + self.assertEqual(self.leadership(self.root,revision=1,ownerId=self.backup['id'],successorId=self.owner['id'],reason='家主无法操作,由超级管理员办理交接')[0],200) + home=app.get_object('households',self.aid) + self.assertEqual(home['ownerId'],self.backup['id']);self.assertEqual(len(home['leadershipHistory']),2) + self.assertEqual(app.get_object('users',self.owner['id'])['role'],'family_admin') + self.assertEqual(self.leadership(self.owner,revision=2)[0],403) + self.assertEqual(self.request('/api/households',self.members[self.aid])[1]['households'][0].get('leadershipHistory'),None) + + def test_leadership_conflicts_and_account_lockout_prevention(self): + self.assertEqual(self.leadership()[0],200) + self.assertEqual(self.leadership()[0],409) + for uid in (self.owner['id'],self.backup['id']): + user=app.get_object('users',uid) + for fields in ({'disabled':True},{'role':'member'}): + self.assertEqual(self.request('/api/users',self.root,dict(user,**fields))[0],400) + self.assertEqual(self.request('/api/users',self.backup,dict(self.owner,password='hijack-password'))[0],403) + self.assertEqual(self.leadership(revision=1,successorId=self.other['id'])[0],404) + self.assertEqual(self.leadership(revision=1,successorId=self.owner['id'])[0],400) + self.assertEqual(self.leadership(revision=1,ownerId=self.members[self.aid]['id'])[0],400) + # Editing household metadata must preserve leadership and its history. + self.assertEqual(self.request('/api/households',data=dict(id=self.aid,name='renamed'))[0],200) + self.assertEqual(app.get_object('households',self.aid)['ownerId'],self.owner['id']) + + def test_legacy_migration_idempotent_preserves_credentials(self): + user=app.get_object('users',self.owner['id']);user.pop('familyId') + app.save_object('users',user) + before=app.DB.execute('SELECT * FROM sessions ORDER BY hash').fetchall() + app.HOUSEHOLDS.initialize();app.HOUSEHOLDS.initialize() + saved=app.get_object('users',user['id']);self.assertEqual(saved.pop('familyId'),self.aid) + self.assertEqual(saved,user) + self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')]) + + +if __name__=='__main__': unittest.main() diff --git a/test_households_ui.js b/test_households_ui.js new file mode 100644 index 0000000..97cf6ea --- /dev/null +++ b/test_households_ui.js @@ -0,0 +1,32 @@ +const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'); +const app=fs.readFileSync('web/app.js','utf8'),homes=fs.readFileSync('web/households.js','utf8'); +const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length)); +test('API uses current household scope for each new request',async()=>{ + const calls=[];const c=vm.createContext({sessionGeneration:0,householdScope:'house-a',fetch:async(p,o)=>{calls.push(o);return {ok:true,json:async()=>({})};}}); + vm.runInContext(part(app,'async function api(','function toast('),c); + await c.api('/api/state');c.householdScope='house-b';await c.api('/api/users',{}); + assert.equal(calls[0].headers['X-Household-Id'],'house-a');assert.equal(calls[1].headers['X-Household-Id'],'house-b'); +}); +test('switch destroys old private state before requesting another household',async()=>{ + const events=[];const c=vm.createContext({state:{familyId:'a'},currentUser:{id:'root'},isPlatformAdmin:()=>true, + clearPrivateView(){events.push('clear');c.currentUser=null;c.state={};},auth:async()=>{events.push('auth');assert.equal(c.householdScope,'b');assert.equal(c.currentUser.id,'root');}}); + vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b'); + assert.deepEqual(events,['clear','auth']); +}); +test('family administrator cannot invoke household switching',async()=>{ + const c=vm.createContext({state:{familyId:'a'},isPlatformAdmin:()=>false,clearPrivateView:()=>{throw Error('should not switch');}}); + vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b'); +}); +test('household switch invalidates outstanding API data',async()=>{ + let finish;const c=vm.createContext({sessionGeneration:0,householdScope:'a',fetch:()=>new Promise(r=>finish=r)}); + vm.runInContext(part(app,'async function api(','function toast('),c); + const pending=c.api('/api/users');c.sessionGeneration++;c.householdScope='b';finish({ok:true,json:async()=>({users:['private-a']})}); + await assert.rejects(pending,e=>e.stale===true); +}); +test('owner is visibly distinct from family and super administrators',()=>{ + const c=vm.createContext({state:{households:[{ownerId:'owner'}]}}); + vm.runInContext(part(app,'const roleName =','function applyPermissions(')+';globalThis.label=roleName;',c); + assert.equal(c.label({id:'owner',role:'family_admin'}),'一家之主'); + assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员'); + assert.equal(c.label({id:'root',role:'admin'}),'超级管理员'); +}); diff --git a/web/app.js b/web/app.js index 0026f97..522a73d 100644 --- a/web/app.js +++ b/web/app.js @@ -6,22 +6,32 @@ const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其 let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0; let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0; -const isAdmin = () => currentUser?.role === 'admin'; +let householdScope = ''; +const isPlatformAdmin = () => currentUser?.role === 'admin'; +const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role); +const roleName = user => user.role==='admin'?'超级管理员':user.role==='family_admin'?((state.households||[]).some(h=>h.ownerId===user.id)?'一家之主':'家庭管理员'):'家庭成员'; function applyPermissions(){ const admin=isAdmin(); + $('nav [data-page="storage"]').hidden=!isPlatformAdmin(); $('#account-password').hidden=!loggedIn; $$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin); + $('nav [data-page="storage"]').hidden=!isPlatformAdmin(); + $('.discovery').hidden=!isPlatformAdmin(); + $('#scan-result').hidden=!isPlatformAdmin(); + $('#new-household').hidden=!isPlatformAdmin(); + $('#household-context').hidden=!loggedIn; + $('#household-filter').disabled=!isPlatformAdmin(); $('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none'); - $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; + $('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : ''; } function clearPrivateView(){ if(typeof clearSnapshot==='function')clearSnapshot(); if(typeof clearCameraSettings==='function')clearCameraSettings(); sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false; if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); - currentUser=null;stopPlayers();resetPlayback(); + currentUser=null;householdScope='';stopPlayers();resetPlayback(); state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}}; - for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list'])$('#'+id).replaceChildren(); + for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list'])$('#'+id).replaceChildren(); if(typeof clearFamilyState==='function')clearFamilyState(); $$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close()); $$('dialog form').forEach(f=>f.reset()); @@ -30,7 +40,7 @@ function clearPrivateView(){ const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']}; async function api(path, data) { const generation=sessionGeneration; - const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)}); + const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:{...(data === undefined ? {} : {'Content-Type':'application/json'}),...(typeof householdScope!=='undefined'&&householdScope?{'X-Household-Id':householdScope}:{})}, body:data === undefined ? undefined : JSON.stringify(data)}); let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); } if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true}); if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); } @@ -44,11 +54,12 @@ function setOptions(select, html) { const old = select.value; select.innerHTML = function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; } function showPage(page) { if(!isAdmin()&&!['live','playback','family'].includes(page))return; + if(page==='storage'&&!isPlatformAdmin())return; if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();} if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen(); activePage = page; - $('.metrics').hidden=['family','users'].includes(page); - $('.filter-row').hidden=['family','users'].includes(page); + $('.metrics').hidden=['family','users','households'].includes(page); + $('.filter-row').hidden=['family','users','households'].includes(page); $$('.page').forEach(el => el.hidden = el.id !== 'page-' + page); $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page)); $('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1]; @@ -79,13 +90,14 @@ function render() { drawTimeline(); } if (activePage === 'storage') renderStorage(); + if(typeof renderHouseholds==='function')renderHouseholds(); applyPermissions(); if(activePage==='live'&&typeof wallSync==='function')wallSync(); } function empty(title, description, button) { return `

${esc(title)}

${esc(description)}

${button || ''}
`; } function renderLive() { const cameras = filtered(), quality = $('#quality').value; - const key = ZhaoLivePlayer.key(cameras, quality); + const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+ZhaoLivePlayer.key(cameras, quality); if (key === liveKey) { updateLiveStatus(cameras); return; } stopPlayers(); liveKey = key; $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); diff --git a/web/chinese-theme.css b/web/chinese-theme.css index 3f3d631..7fb69b8 100644 --- a/web/chinese-theme.css +++ b/web/chinese-theme.css @@ -76,6 +76,9 @@ header p { color: #686f60; font-size: 12px; letter-spacing: .5px; } .metrics span { color: #6f7260; } .metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; } .filter-row { color: #6e6b5f; margin: 24px 0; } +.household-context { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: -5px 0 24px; padding: 12px 16px; border-left: 3px solid var(--gold); background: #eae8dc; } +.household-context span { font-size: 11px; color: var(--muted); } +.household-context select { min-width: 140px; max-width: 240px; } .section-head { margin-top: 25px; } .section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; } .wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; } @@ -243,3 +246,5 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; } footer { gap: 16px; flex-wrap: wrap; font-size: 9px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } + +.household-actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.household-context select{max-width:100%}.household-context label{min-width:0} diff --git a/web/family.js b/web/family.js index 6649997..75523bd 100644 --- a/web/family.js +++ b/web/family.js @@ -67,13 +67,14 @@ for(const [name,path]of [['person','person'],['relation','link']]){ async function loadUsers(){ if(!isAdmin())return;const id=currentUser.id; try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f; - $('#user-list').innerHTML=`
${users.map(u=>``).join('')}
账号角色 / 状态人物关系家谱权限摄像头空间
${esc(u.name)}${esc(u.username)}${u.role==='admin'?'管理员':'个人账号'}${u.disabled?' · 已停用':''}${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}家谱:${esc(personName(u.personId))}${u.role==='admin'?'管理':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}${u.role==='admin'?'全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}
`; + $('#user-list').innerHTML=`
${users.map(u=>``).join('')}
账号角色 / 状态人物关系家谱权限摄像头空间
${esc(u.name)}${esc(u.username)}${roleName(u)}${u.disabled?' · 已停用':''}${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}家谱:${esc(personName(u.personId))}${u.role!=='member'?'管理本家庭':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}${u.role!=='member'?'本家庭全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}
`; }catch(e){toast(e.message);} } function openUser(id){ if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{}; f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML=''+users.map(u=>``).join(''); fillForm(f,u);f.elements.password.required=!u.id; + f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin(); $('#user-sites').innerHTML=state.sites.map(s=>``).join('');$('#user-dialog').showModal(); } $('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); diff --git a/web/households.js b/web/households.js new file mode 100644 index 0000000..60ff3a2 --- /dev/null +++ b/web/households.js @@ -0,0 +1,56 @@ +'use strict'; +pageInfo.households=['家庭管理','一户一家园,资料各有归属。']; +function renderHouseholds(){ + const homes=state.households||[]; + setOptions($('#household-filter'),homes.map(h=>``).join('')); + $('#household-filter').value=state.familyId||''; + if(activePage!=='households')return; + $('#household-list').innerHTML=homes.map(h=>`
${h.id===state.familyId?'当前家庭':'独立家庭'}

${esc(h.name)}

${esc(h.note||'一户一家园')}

一家之主
${esc(h.ownerName||'待指定')}
备用负责人
${esc(h.successorName||'待指定')}
账号 / 空间
${h.members} 个 / ${h.sites} 处
${!h.successorId?'

尚未指定备用负责人,建议提前安排家人接管。

':''}
${h.canAssignOwner?``:''}
${h.leadershipHistory?.length?`
交接记录${h.leadershipHistory.slice().reverse().map(r=>`

${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}
${esc(r.reason)}

`).join('')}
`:''}
`).join(''); +} +async function switchHousehold(id){ + if(!isPlatformAdmin()||!id||id===state.familyId)return; + const user=currentUser; + // Invalidate outstanding API responses, video players and dialogs before changing scope. + clearPrivateView(); + householdScope=id;currentUser=user;loggedIn=true; + await auth(); +} +function openHousehold(id){ + if(!isAdmin())return; + const item=(state.households||[]).find(h=>h.id===id); + if(!item&&!isPlatformAdmin())return; + fillForm($('#household-form'),item||{});$('#household-dialog').showModal(); +} +async function openLeadership(id){ + try{ + if(id!==state.familyId)await switchHousehold(id); + const home=(state.households||[]).find(h=>h.id===id); + if(!home?.canAssignOwner)return; + const result=await api('/api/users'); + const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled); + const f=$('#leadership-form'),options=candidates.map(u=>``).join(''); + f.elements.ownerId.innerHTML=''+options; + f.elements.successorId.innerHTML=''+options; + fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''}); + $('#leadership-dialog').showModal(); + }catch(error){if(!error.stale)toast(error.message);} +} +$('#leadership-form').addEventListener('submit',async e=>{ + e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; + try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('家庭负责人已更新,交接记录已保存');} + catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;} +}); +$('#household-filter').onchange=e=>switchHousehold(e.target.value); +$('#new-household').onclick=()=>openHousehold(); +$('#household-form').addEventListener('submit',async e=>{ + e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; + try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('家庭档案已保存');} + catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;} + finally{b.disabled=false;} +}); +document.addEventListener('click',async e=>{ + const b=e.target.closest('button');if(!b)return; + if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold); + if(b.dataset.leadership)await openLeadership(b.dataset.leadership); + if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('users');} +}); diff --git a/web/index.html b/web/index.html index 2c59992..4cff9bc 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ - +
赵氏家宅 · 起居有序

实时画面

从一个空间,看到每一个位置。

等待连接
+
摄像头—
已接通码流—
录像占用—
空间档案—
家庭与公司,分别管理
@@ -63,7 +65,8 @@ - + +
赵府智家 · 家庭空间独立部署 / 数据自有
灯火可亲 · 归家有时

欢迎回来

使用自己的账号,进入家庭空间。

@@ -71,7 +74,9 @@

镜头通道配置

直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。

账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

摄像头对象

录像机配置

驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。

-

账号档案

允许查看摄像头的空间

个人账号仅能观看已选空间的实时画面和回放;管理员可以管理全部空间。

+

家主与权限交接

先在本家庭建立至少两位家庭管理员,再指定家主和备用负责人。备用负责人现在就能管理本家庭的设备与成员;家主无法操作时可继续管理,并联系超级管理员办理家主身份交接。

交接后原家主仍保留家庭管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。

+

家庭档案

创建后,进入该家庭的“账号与关系”,建立家庭管理员,再分配设备和空间。

+

账号档案

允许查看摄像头的空间

家庭成员仅能观看本家庭已选空间的实时画面和回放;家庭管理员可以管理本家庭全部空间。超级管理员负责整个平台。

家谱人物

出生日期日期不详可留空

连接人物关系

每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。