From 96f0ef31fcc14a0a4524b3ebfb09d5ade094c53b Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 04:52:39 +0800 Subject: [PATCH] Add verified camera settings, family version recovery and account self service --- README.md | 33 +++++- VERSION | 2 +- app.py | 104 +++++++++++++++---- camera_settings.py | 225 ++++++++++++++++++++++++++++++++++++++++ genealogy.py | 61 ++++++++++- identity.py | 26 +++++ onvif.py | 8 +- ptz.py | 4 +- test_app.py | 208 ++++++++++++++++++++++++++++++++++++- test_camera_settings.py | 122 ++++++++++++++++++++++ test_ui_logic.js | 50 +++++++++ web/app.js | 20 +++- web/camera-settings.js | 56 ++++++++++ web/family-events.js | 2 +- web/family-history.js | 62 +++++++++++ web/family-map.css | 12 +++ web/family.js | 4 +- web/index.html | 7 +- 18 files changed, 969 insertions(+), 37 deletions(-) create mode 100644 camera_settings.py create mode 100644 test_camera_settings.py create mode 100644 web/camera-settings.js create mode 100644 web/family-history.js diff --git a/README.md b/README.md index aa88f07..ad5384b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 +回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.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 使用管理员与个人账号分权。 @@ -131,3 +131,34 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 - 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。 - 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。 - 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。 + + +## 摄像机参数、版本留存与账号自助(v0.1.19) + +### 摄像机设置 + +管理员在实时画面或镜头通道中点击“摄像机设置”。服务通过已保存的设备凭据读取能力,精确匹配当前镜头的主 / 子码流路径(包括查询参数)。不接受客户端提供的任意设备服务地址。 + +- 显示设备型号、固件、镜头视频源及编码。图像设置作用于该物理镜头,主、子码流共用。 +- 按设备实际支持范围提供亮度、对比度、饱和度、锐度、日夜模式、背光补偿、宽动态及白平衡。 +- 主、子码流分别提供分辨率、帧率、码率上限、画质和关键帧间隔;未开放的参数不会出现。 +- 保留现有视频编码及设备扩展字段。有些设备使用 H264 兼容结构报告 H265,不能仅凭标准 Encoding 字段就改写编码。 +- 保存前重新读取、验证范围并检查参数版本,同一物理设备串行保存。保存后回读实际值,未确认或不一致会提示重新核对,不自动反复写入。 +- 修改码流可能短暂中断实时画面,并影响录像机收到的码流。当前设备参数写入仅管理员可用。 +- 双向对讲、自动跟踪、告警灯 / 警笛、画面翻转、隐私遮蔽、云录像、消息推送尚未接入;界面明确列出这些边界。设备宣告支持某个 ONVIF 服务不等于本系统已经实现该功能。 + +协议依据:[ONVIF Imaging Service](https://www.onvif.org/specs/srv/img/ONVIF-Imaging-Service-Spec-v1606.pdf)、[ONVIF Media Service](https://www.onvif.org/specs/srv/media/ONVIF-Media-Service-Spec-v240.pdf)。本系统按设备返回的选项校验与设置,不依赖萤石云账号。 + +### 家谱修改记录 + +人物档案、亲属关系及记事详情都有历史入口。每次保存将修改前 / 后内容、操作者与时间、家谱版本号写入 `family_changes`,与实际记录一起提交。可以分页查阅、对照旧版并选择恢复;恢复会重新验证现有家谱约束,并生成新版本,不删除后续历史。不提供创建前的空版本恢复。 + +历史包含曾撤回的内容,只对家谱编辑者和管理员开放。读取版本后若家谱发生变化,恢复会拒绝并要求重新读取。历史从本版本开始记录;升级前的内容会在下一次修改时作为“修改前”保存。历史属于私有数据库,不进入公共源码或普通家谱 JSON 导出。 + +### 我的密码 + +所有登录账号都可通过侧栏“修改我的密码”填写当前密码、新密码及确认。服务器验证当前密码,并限制连续错误次数。成功后撤销该账号全部登录会话,其他账号不受影响;账号角色和人物关联不变。系统不会把密码记录到操作日志。 + +### 并发与数据一致性 + +设备对象的校验与保存串行执行;镜头数量限制不能被同时提交绕过。摄像头对象移动空间时,所有镜头归属在同一数据库事务更新。录像机地址或账号更新后,关联通道读取最新端点。无效对象编号、错误 Origin 和非法地址类型会返回明确错误。 diff --git a/VERSION b/VERSION index f8bc4c6..d8a023e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.18 +0.1.19 diff --git a/app.py b/app.py index 4344e84..c066e20 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,8 @@ import base64 import concurrent.futures import datetime as dt import hashlib +import functools +import camera_settings import hmac import http.client import http.cookies @@ -126,6 +128,8 @@ def integer(value, lo, hi): def host_address(value): try: + if not isinstance(value, str): + raise ValueError() ip = ipaddress.ip_address(value) if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved: raise ValueError() @@ -138,10 +142,28 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o 'building', 'unit', 'floor', 'door', 'address', 'note') +def serialized_save(function): + @functools.wraps(function) + def save(body): + with LOCK: + return function(body) + return save + + +def object_key(table, body): + key = body.get('id', '') + if key in ('', None): + return secrets.token_hex(8) + if not isinstance(key, str) or not re.fullmatch('[a-f0-9]{16}', key): + raise Problem('对象编号不正确') + if not get_object(table, key): + raise Problem('对象不存在,请重新载入', 404) + return key + + +@serialized_save def save_site(body): - key = body.get('id') or secrets.token_hex(8) - if not re.fullmatch('[a-f0-9]{16}', key): - raise Problem('空间编号不正确') + 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')) @@ -152,10 +174,9 @@ def save_site(body): return item +@serialized_save def save_camera(body): - key = body.get('id') or secrets.token_hex(8) - if not re.fullmatch('[a-f0-9]{16}', key): - raise Problem('设备编号不正确') + key = object_key('cameras', body) old = get_object('cameras', key) or {} item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') for k in ('name', 'point', 'username', 'model')} @@ -202,10 +223,9 @@ def save_camera(body): return public_camera(item) +@serialized_save def save_asset(body): - key = body.get('id') or secrets.token_hex(8) - if not re.fullmatch('[a-f0-9]{16}', key): - raise Problem('对象编号不正确') + key = object_key('assets', body) site = body.get('siteId') if not get_object('sites', site): raise Problem('请选择所属空间') @@ -215,19 +235,18 @@ def save_asset(body): attached = [c for c in objects('cameras') if c.get('assetId') == key] if len(attached) > item['lensCount']: raise Problem('镜头数不能小于已经建立的通道数') - with LOCK: - save_object('assets', item) + with DB: + DB.execute('INSERT OR REPLACE INTO assets VALUES (?,?)', (key, json.dumps(item))) for c in attached: c['siteId'] = site - save_object('cameras', c) + DB.execute('UPDATE cameras SET body=? WHERE id=?', (json.dumps(c), c['id'])) audit('保存摄像头对象', item['name']) return item +@serialized_save def save_recorder(body): - key = body.get('id') or secrets.token_hex(8) - if not re.fullmatch('[a-f0-9]{16}', key): - raise Problem('录像机编号不正确') + key = object_key('recorders', body) old = get_object('recorders', key) or {} item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') for k in ('name', 'brand', 'model', 'username')} @@ -249,7 +268,13 @@ def save_recorder(body): def public_camera(c): - return {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c['password'])} + result = {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c.get('password'))} + if c.get('sourceKind') == 'recorder': + recorder = get_object('recorders', c.get('recorderId')) + if recorder: + result.update({k: recorder[k] for k in ('host', 'port', 'username')}) + result['passwordConfigured'] = bool(recorder.get('password')) + return result def stream_name(c, quality='main'): @@ -512,8 +537,14 @@ class Handler(BaseHTTPRequestHandler): def validate_origin(self): origin = self.headers.get('Origin') - if origin and url.urlsplit(origin).netloc != self.headers.get('Host'): - raise Problem('请从系统页面提交操作', 403) + if origin: + try: + parsed = url.urlsplit(origin) + valid = parsed.scheme in ('http', 'https') and parsed.netloc == self.headers.get('Host') + except ValueError: + valid = False + if not valid: + raise Problem('请从系统页面提交操作', 403) def login(self, data, setup=False): ip = self.client_address[0] @@ -564,13 +595,29 @@ class Handler(BaseHTTPRequestHandler): self.login(data, path == '/api/setup') return self.require_auth() - if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'): + if path not in ('/api/logout', '/api/account/password', '/api/family/restore', + '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'): self.require_admin() if path == '/api/logout': with LOCK: DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),)) DB.commit() self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') + elif path == '/api/account/password': + result = ACCOUNTS.change_password(data, self.user) + self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') + elif path == '/api/family/restore': + self.answer(FAMILY.restore(data, self.user)) + elif path == '/api/camera/settings': + c = ACCOUNTS.camera(self.user, get_object('cameras', data.get('camera'))) + try: + result = camera_settings.CONTROLLER.save(c, data) + except ValueError as e: + raise Problem(str(e)) + except Exception: + raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502) + audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username']) + self.answer(result) elif path == '/api/ptz': c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) try: @@ -741,6 +788,23 @@ class Handler(BaseHTTPRequestHandler): self.answer(result) elif path == '/api/family': self.answer(FAMILY.snapshot(self.user)) + elif path == '/api/camera/settings': + self.require_admin() + c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera'))) + try: + result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main')) + except ValueError as e: + raise Problem(str(e)) + except Exception: + raise Problem('读取摄像机参数失败,请检查连接、设备账号或 ONVIF 服务', 502) + self.answer(result) + elif path in ('/api/family/history', '/api/family/change'): + try: + number = int(query['before' if path.endswith('history') else 'revision']) if ('before' if path.endswith('history') else 'revision') in query else None + except ValueError: + raise Problem('修改记录编号不正确') + self.answer(FAMILY.history(self.user, query.get('kind'), query.get('id'), number) + if path.endswith('history') else FAMILY.change(self.user, number)) elif path == '/api/audit': self.require_admin() with LOCK: @@ -791,6 +855,8 @@ class Handler(BaseHTTPRequestHandler): '/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js', '/family-map.css': 'family-map.css', '/family-events.js': 'family-events.js', + '/family-history.js': 'family-history.js', + '/camera-settings.js': 'camera-settings.js', '/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js', '/zhao-icon.png': 'zhao-icon.png', '/vendor/hls.min.js': 'vendor/hls.min.js'} diff --git a/camera_settings.py b/camera_settings.py new file mode 100644 index 0000000..7e2bfb5 --- /dev/null +++ b/camera_settings.py @@ -0,0 +1,225 @@ +"""Capability-driven, per-lens ONVIF settings. Never accepts an endpoint from a client.""" +import copy +import hashlib +import json +import math +import threading +import urllib.parse as url +import xml.etree.ElementTree as ET +from xml.sax.saxutils import escape +import onvif + +IMAGING = 'http://www.onvif.org/ver20/imaging/wsdl' +T = '{' + onvif.SCHEMA + '}' +M = '{' + onvif.MEDIA + '}' +I = '{' + IMAGING + '}' +IMAGE_FIELDS = { + 'brightness': ('Brightness', 'Brightness', '亮度'), + 'contrast': ('Contrast', 'Contrast', '对比度'), + 'saturation': ('ColorSaturation', 'ColorSaturation', '饱和度'), + 'sharpness': ('Sharpness', 'Sharpness', '锐度'), + 'dayNight': ('IrCutFilter', 'IrCutFilterModes', '日夜模式'), + 'backlight': ('BacklightCompensation/Mode', 'BacklightCompensation/Mode', '背光补偿'), + 'wdr': ('WideDynamicRange/Mode', 'WideDynamicRange/Mode', '宽动态'), + 'wdrLevel': ('WideDynamicRange/Level', 'WideDynamicRange/Level', '宽动态强度'), + 'whiteBalance': ('WhiteBalance/Mode', 'WhiteBalance/Mode', '白平衡'), +} +ENUMS = {'dayNight': ('ON', 'OFF', 'AUTO'), 'backlight': ('ON', 'OFF'), + 'wdr': ('ON', 'OFF'), 'whiteBalance': ('AUTO', 'MANUAL')} +VIDEO_FIELDS = {'fps': 'RateControl/FrameRateLimit', 'bitrate': 'RateControl/BitrateLimit', + 'quality': 'Quality', 'gop': 'H264/GovLength'} + + +def path(value): + return '/'.join(T + part for part in value.split('/')) + + +def number(value): + try: + result = float(value) + return result if math.isfinite(result) else None + except (TypeError, ValueError): + return None + + +def limits(root, name): + if root is None: + return None + minimum, maximum = number(root.findtext(path(name + '/Min'))), number(root.findtext(path(name + '/Max'))) + return dict(min=minimum, max=maximum) if minimum is not None and maximum is not None and minimum <= maximum else None + + +def uri_path(value): + parsed = url.urlsplit(value) + return parsed.path, sorted(url.parse_qsl(parsed.query, keep_blank_values=True)) + + +class Settings: + def __init__(self): + self.guard = threading.Lock() + self.locks = {} + + def call(self, camera, endpoint, namespace, operation, body=''): + return onvif.request(camera['host'], endpoint[0], endpoint[1], namespace, operation, + body, camera['username'], camera['password']) + + @staticmethod + def endpoint(camera, address): + parsed = url.urlsplit(address or '') + if parsed.scheme != 'http' or parsed.hostname != camera['host'] or parsed.username or parsed.password: + raise ValueError('设备返回了不匹配的服务地址') + return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '') + + def discover(self, camera, quality): + if camera.get('sourceKind', 'direct') != 'direct': + raise ValueError('摄像机参数需要直连摄像头;录像机通道请在录像机中配置') + if not camera.get('enabled'): + raise ValueError('请先启用这个镜头通道') + if quality not in ('main', 'sub'): + raise ValueError('请选择主码流或子码流') + device = (camera.get('onvifPort', 80), '/onvif/device_service') + caps = self.call(camera, device, onvif.DEVICE, 'GetCapabilities', 'All') + services = {} + for name in ('Media', 'Imaging', 'PTZ', 'Events', 'Recording', 'Replay'): + address = caps.findtext('.//' + T + name + '/' + T + 'XAddr') + if address: + services[name] = self.endpoint(camera, address) + if 'Media' not in services: + raise ValueError('设备没有提供媒体配置服务') + profiles = self.call(camera, services['Media'], onvif.MEDIA, 'GetProfiles') + wanted = camera['mainPath' if quality == 'main' else 'subPath'] + selected = None + # Exact path AND query matching prevents selecting the other lens on shared paths. + for profile in profiles.findall('.//' + M + 'Profiles')[:16]: + token = profile.get('token', '') + content = ('RTP-UnicastRTSP' + '' + escape(token) + '') + response = self.call(camera, services['Media'], onvif.MEDIA, 'GetStreamUri', content) + uri = response.findtext('.//' + T + 'Uri') + if uri and url.urlsplit(uri).hostname == camera['host'] and uri_path(uri) == uri_path(wanted): + selected = profile + break + if selected is None: + raise ValueError('未找到与当前镜头及码流路径精确匹配的配置,请先核对通道') + source = selected.findtext(path('VideoSourceConfiguration/SourceToken')) + encoder = selected.find(T + 'VideoEncoderConfiguration') + if not source or encoder is None: + raise ValueError('设备没有返回镜头或编码器配置') + return dict(device=device, services=services, source=source, profile=selected.get('token'), encoder=encoder) + + def read(self, camera, quality='main'): + info = self.discover(camera, quality) + return self.details(camera, info), info + + def details(self, camera, info): + result = dict(profile=info['profile'], source=info['source'], device={}, imaging={}, video={}, notes=[], + advertisedServices=list(info['services'])) + try: + device = self.call(camera, info['device'], onvif.DEVICE, 'GetDeviceInformation') + result['device'] = {key: device.findtext('.//{' + onvif.DEVICE + '}' + key, '') + for key in ('Manufacturer', 'Model', 'FirmwareVersion', 'HardwareId')} + except Exception: + result['notes'].append('设备型号读取失败,可重试') + encoder = info['encoder'] + values = {key: number(encoder.findtext(path(field))) for key, field in VIDEO_FIELDS.items()} + values['resolution'] = encoder.findtext(path('Resolution/Width'), '') + 'x' + encoder.findtext(path('Resolution/Height'), '') + codec = encoder.get('encoding') or encoder.findtext(T + 'Encoding', '') + result['video'] = dict(values=values, options={}, codec=codec) + try: + body = '' + escape(encoder.get('token', '')) + '' + opts = self.call(camera, info['services']['Media'], onvif.MEDIA, 'GetVideoEncoderConfigurationOptions', body).find('.//' + M + 'Options') + # Some EZVIZ devices expose H265 through the H264 compatibility schema. + # Keep the entire original XML (including vendor encoding attribute) when writing. + schema_codec = encoder.findtext(T + 'Encoding', '') + if schema_codec not in ('H264', 'MPEG4', 'JPEG'): + raise ValueError('unsupported encoding schema') + codec_opts = opts.find(T + schema_codec) + for key, field in [('fps', 'FrameRateRange'), ('gop', 'GovLengthRange')]: + limit = limits(codec_opts, field) + if limit and values[key] is not None:result['video']['options'][key] = limit + bitrate = limits(opts, 'Extension/' + schema_codec + '/BitrateRange') + quality = limits(opts, 'QualityRange') + if bitrate and values['bitrate'] is not None:result['video']['options']['bitrate'] = bitrate + if quality and values['quality'] is not None:result['video']['options']['quality'] = quality + resolutions = [r.findtext(T + 'Width', '') + 'x' + r.findtext(T + 'Height', '') + for r in codec_opts.findall(T + 'ResolutionsAvailable')] + if resolutions:result['video']['options']['resolution'] = {'choices': list(dict.fromkeys(resolutions))} + except Exception: + result['notes'].append('码流可调范围读取失败,暂不可修改码流参数') + result['video']['options'] = {} + if 'Imaging' in info['services']: + try: + body = '' + escape(info['source']) + '' + settings = self.call(camera, info['services']['Imaging'], IMAGING, 'GetImagingSettings', body).find('.//' + I + 'ImagingSettings') + options = self.call(camera, info['services']['Imaging'], IMAGING, 'GetOptions', body).find('.//' + I + 'ImagingOptions') + if settings is None or options is None:raise ValueError('missing imaging response') + info['imagingXML'] = settings + for key, (field, option, label) in IMAGE_FIELDS.items(): + value = settings.findtext(path(field)) + if value is None:continue + if key in ENUMS: + choices = [v.text for v in options.findall(path(option)) if v.text in ENUMS[key]] + rule = {'choices': list(dict.fromkeys(choices))} if choices else None + else: + value, rule = number(value), limits(options, option) + if rule and value is not None:result['imaging'][key] = dict(value=value, label=label, **rule) + except Exception: + result['notes'].append('图像参数读取失败或设备未开放,暂不可修改图像参数') + else: + result['notes'].append('设备未开放图像调节服务') + fingerprint = dict(source=info['source'], profile=info['profile'], encoder=encoder.get('token'), + imaging=result['imaging'], video=result['video']) + result['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest() + return result + + @staticmethod + def validate(values, rules, integer_fields=()): + if not isinstance(values, dict) or not values or any(k not in rules for k in values): + raise ValueError('提交的参数未由设备开放,或没有需要保存的参数') + for key, value in values.items(): + rule = rules[key] + if 'choices' in rule: + if not isinstance(value, str) or value not in rule['choices']:raise ValueError('参数选项不受设备支持') + elif (type(value) not in (int, float) or not math.isfinite(value) or not rule['min'] <= value <= rule['max'] + or (key in integer_fields and int(value) != value)): + raise ValueError('参数超出设备允许范围') + + def save(self, camera, data): + key = (camera['host'], camera.get('onvifPort', 80)) + with self.guard:lock = self.locks.setdefault(key, threading.Lock()) + if not lock.acquire(blocking=False):raise ValueError('这台摄像头正在保存参数,请稍后重试') + try: + current, info = self.read(camera, data.get('quality', 'main')) + if data.get('revision') != current['revision']: + raise ValueError('设备参数已变化,请重新读取后再修改') + section, values = data.get('section'), data.get('values') + if section == 'imaging': + self.validate(values, current['imaging']) + settings = copy.deepcopy(info['imagingXML']) + for key, value in values.items():settings.find(path(IMAGE_FIELDS[key][0])).text = str(value) + body = '' + escape(info['source']) + '' + ET.tostring(settings, encoding='unicode') + 'true' + endpoint, namespace, operation = info['services']['Imaging'], IMAGING, 'SetImagingSettings' + elif section == 'video': + self.validate(values, current['video']['options'], ('fps', 'bitrate', 'gop')) + settings = copy.deepcopy(info['encoder']);settings.tag = M + 'Configuration' + for key, value in values.items(): + if key == 'resolution': + for field, size in zip(('Width', 'Height'), value.split('x')):settings.find(path('Resolution/' + field)).text = size + else:settings.find(path(VIDEO_FIELDS[key])).text = str(int(value) if key in ('fps', 'bitrate', 'gop') else value) + body = ET.tostring(settings, encoding='unicode') + 'true' + endpoint, namespace, operation = info['services']['Media'], onvif.MEDIA, 'SetVideoEncoderConfiguration' + else:raise ValueError('请选择图像或码流设置') + self.call(camera, endpoint, namespace, operation, body) + try: + fresh, _ = self.read(camera, data.get('quality', 'main')) + actual = {k: v['value'] for k, v in fresh['imaging'].items()} if section == 'imaging' else fresh['video']['values'] + confirmed = all(actual.get(k) == v for k, v in values.items()) + return dict(ok=True, verified=confirmed, settings=fresh, + message='设备已保存并回读确认' if confirmed else '设备已响应,但回读值与提交值不同,请核对设备实际参数') + except Exception: + return dict(ok=True, verified=False, settings=None, message='设备已响应,暂未读回参数;请重新读取确认,不要反复提交') + finally: + lock.release() + + +CONTROLLER = Settings() diff --git a/genealogy.py b/genealogy.py index 871b4f8..882d0f2 100644 --- a/genealogy.py +++ b/genealogy.py @@ -15,6 +15,11 @@ class Family: CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS family_changes ( + revision INTEGER PRIMARY KEY, kind TEXT NOT NULL, target_id TEXT NOT NULL, + before_json TEXT, after_json TEXT NOT NULL, actor TEXT NOT NULL, + at TEXT NOT NULL, restored_from INTEGER); + CREATE INDEX IF NOT EXISTS family_changes_target ON family_changes(kind,target_id,revision); ''') self.a.DB.commit() @@ -33,7 +38,7 @@ class Family: 'canEdit': can_edit, 'selfId': user.get('personId', '')} - def save(self, kind, data, user): + def save(self, kind, data, user, restored_from=None): self.authorize(user, True) a = self.a with a.LOCK: @@ -57,12 +62,60 @@ class Family: elif item['status'] == 'published': item['publishedAt'] = item['updatedAt'] # Revision and record are committed together, so concurrent forms cannot overwrite silently. - 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.commit() + with a.DB: + 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 INTO family_changes VALUES (?,?,?,?,?,?,?,?)', + (revision + 1, 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} + def history(self, user, kind, target_id, before=None): + # Previous drafts and withdrawn text are visible only to family editors. + self.authorize(user, True) + a = self.a + table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}.get(kind) + with a.LOCK: + if not table or not a.get_object(table, target_id): + raise a.Problem('家谱记录不存在', 404) + 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)) + + def change(self, user, change_revision): + self.authorize(user, True) + self.a.integer(change_revision, 1, 2**53 - 1) + with self.a.LOCK: + row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone() + if not row: + raise self.a.Problem('修改记录不存在', 404) + 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']) + + def restore(self, data, user): + self.authorize(user, True) + with self.a.LOCK: + change = self.change(user, data.get('changeRevision')) + side = data.get('side') + if side not in ('before', 'after') or change[side] is None: + raise self.a.Problem('请选择有效的历史版本;创建前没有可恢复的内容') + # Reuse all current relationship/date/publication validation. Restore is a new edit, + # not an overwrite of history or a rollback of other people's records. + return self.save(change['kind'], dict(change[side], revision=data.get('revision')), + user, restored_from=change['revision']) + def event(self, data, old): a = self.a item = {'id': old['id'] if old else secrets.token_hex(8)} diff --git a/identity.py b/identity.py index e7a7039..f495392 100644 --- a/identity.py +++ b/identity.py @@ -4,11 +4,13 @@ import hmac import json import re import secrets +import time class Accounts: def __init__(self, app): self.a = app + self.password_attempts = {} def initialize(self): a = self.a @@ -99,6 +101,30 @@ class Accounts: a.audit('更新账号' if old else '建立账号', username) return self.public(user) + def change_password(self, data, actor): + a = self.a + with a.LOCK: + current = a.get_object('users', actor['id']) + if not current or current['disabled']: + raise a.Problem('请重新登录', 401) + now = time.time() + self.password_attempts = {k: v for k, v in self.password_attempts.items() if v[1] > now} + count, expiry = self.password_attempts.get(current['id'], (0, now + 900)) + if count >= 8: + raise a.Problem('当前密码尝试次数过多,请 15 分钟后重试', 429) + if not self.verify(current['username'], data.get('currentPassword')): + self.password_attempts[current['id']] = (count + 1, expiry) + raise a.Problem('当前密码不正确', 403) + if data.get('newPassword') == data.get('currentPassword'): + raise a.Problem('新密码不能与当前密码相同') + credentials = self.password(data.get('newPassword')) + with a.DB: + a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(dict(current, **credentials)), current['id'])) + a.DB.execute('DELETE FROM sessions WHERE user_id=?', (current['id'],)) + self.password_attempts.pop(current['id'], None) + a.audit('本人修改密码', current['username']) + return {'ok': True} + def camera(self, user, camera): if not camera: raise self.a.Problem('摄像头不存在', 404) diff --git a/onvif.py b/onvif.py index 4a61a68..d147db0 100644 --- a/onvif.py +++ b/onvif.py @@ -13,6 +13,12 @@ MEDIA = 'http://www.onvif.org/ver10/media/wsdl' SCHEMA = 'http://www.onvif.org/ver10/schema' +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # Camera services must stay on the validated device endpoint. + return None + + def request(host, port, endpoint, namespace, operation, content, username, password): created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') nonce = secrets.token_bytes(16) @@ -30,7 +36,7 @@ def request(host, port, endpoint, namespace, operation, content, username, passw target = 'http://' + host + ':' + str(port) + endpoint passwords = urllib.request.HTTPPasswordMgrWithDefaultRealm() passwords.add_password(None, target, username, password) - opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPDigestAuthHandler(passwords)) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords)) req = urllib.request.Request(target, data=envelope.encode(), headers={ 'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'}) with opener.open(req, timeout=5) as response: diff --git a/ptz.py b/ptz.py index 732df75..76a6ad3 100644 --- a/ptz.py +++ b/ptz.py @@ -53,7 +53,9 @@ class Controller: body = 'RTP-UnicastRTSP' + escape(p.get('token', '')) + '' root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body) uri = root.find('.//' + T + 'Uri') - if uri is not None and url.urlsplit(uri.text).path == url.urlsplit(c['mainPath']).path: + actual, wanted_uri = url.urlsplit(uri.text if uri is not None else ''), url.urlsplit(c['mainPath']) + if (actual.hostname == c['host'] and actual.path == wanted_uri.path + and sorted(url.parse_qsl(actual.query, keep_blank_values=True)) == sorted(url.parse_qsl(wanted_uri.query, keep_blank_values=True))): selected = p break if selected is None: diff --git a/test_app.py b/test_app.py index d9d6025..a8796ee 100644 --- a/test_app.py +++ b/test_app.py @@ -39,9 +39,9 @@ class WebTests(unittest.TestCase): app.DB.close() _data.cleanup() - def request(self, path, authenticated=True, data=None, origin=None): + def request(self, path, authenticated=True, data=None, origin=None, token=None): c = http.client.HTTPConnection(*self.server.server_address, timeout=3) - headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {} + headers = {'Cookie': 'vision=' + (token or 'f' * 64)} if authenticated else {} if origin: headers['Origin'] = origin if data is not None: @@ -78,7 +78,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), 13) + self.assertEqual(len(assets), 15) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) @@ -345,6 +345,208 @@ class WebTests(unittest.TestCase): with self.assertRaises(app.Problem): app.media_json('/list?path=example', playback=True) + def family_save(self, kind, data): + return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin) + + def test_history_records_before_after_and_restore_as_new_version(self): + first = self.family_save('person', {'name': 'History original', 'biography': 'Original story'}) + second = self.family_save('person', dict(first['item'], name='History changed')) + change = app.FAMILY.change(self.admin, second['revision']) + self.assertEqual(change['before']['name'], 'History original') + self.assertEqual(change['after']['name'], 'History changed') + restored = app.FAMILY.restore({'changeRevision': second['revision'], 'side': 'before', + 'revision': app.setting('familyRevision')}, self.admin) + self.assertEqual(restored['item']['name'], 'History original') + self.assertGreater(restored['revision'], second['revision']) + entries = app.FAMILY.history(self.admin, 'person', first['item']['id'])['items'] + self.assertEqual(len(entries), 3) + self.assertEqual(entries[0]['restoredFrom'], second['revision']) + self.assertEqual(app.FAMILY.change(self.admin, second['revision']), change) + + def test_history_rejects_stale_restore_without_writing(self): + first = self.family_save('person', {'name': 'Stale restore'}) + second = self.family_save('person', dict(first['item'], name='Latest')) + with self.assertRaises(app.Problem) as caught: + app.FAMILY.restore(dict(changeRevision=first['revision'], side='after', revision=first['revision']), self.admin) + self.assertEqual(caught.exception.status, 409) + self.assertEqual(app.get_object('people', first['item']['id'])['name'], 'Latest') + self.assertEqual(app.setting('familyRevision'), second['revision']) + + def test_restore_revalidates_current_birth_order(self): + old = self.family_save('person', {'name': 'Restore parent', 'birthDate': '1980-01-01'}) + parent = self.family_save('person', dict(old['item'], birthDate='1960-01-01'))['item'] + child = self.family_save('person', {'name': 'Restore child', 'birthDate': '1970-01-01'})['item'] + self.family_save('link', dict(fromId=parent['id'], toId=child['id'], kind='parent', lineage='biological')) + with self.assertRaises(app.Problem): + app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin) + self.assertEqual(app.get_object('people', parent['id'])['birthDate'], '1960-01-01') + + def test_restore_link_cannot_create_new_cycle(self): + a = self.family_save('person', {'name': 'Cycle A'})['item'] + b = self.family_save('person', {'name': 'Cycle B'})['item'] + old = self.family_save('link', dict(fromId=a['id'], toId=b['id'], kind='parent')) + self.family_save('link', dict(old['item'], active=False)) + self.family_save('link', dict(fromId=b['id'], toId=a['id'], kind='parent')) + with self.assertRaises(app.Problem): + app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin) + self.assertFalse(app.get_object('family_links', old['item']['id'])['active']) + + def test_restore_event_preserves_first_publication_and_validates_scope(self): + draft = self.family_save('event', dict(title='Restored story', eventDate='2020-01-01', body='Draft', status='draft')) + published = self.family_save('event', dict(draft['item'], body='Published', status='published')) + restored = app.FAMILY.restore(dict(changeRevision=draft['revision'], side='after', revision=app.setting('familyRevision')), self.admin) + self.assertEqual(restored['item']['status'], 'draft') + self.assertEqual(restored['item']['publishedAt'], published['item']['publishedAt']) + self.assertNotIn(draft['item']['id'], [e['id'] for e in app.FAMILY.snapshot(dict(role='member', familyAccess='read'))['events']]) + for side in ['before', 'invalid']: + with self.assertRaises(app.Problem): + app.FAMILY.restore(dict(changeRevision=draft['revision'], side=side, revision=app.setting('familyRevision')), self.admin) + + def test_history_pagination_is_complete_and_omits_story_bodies(self): + item = self.family_save('person', {'name': 'Pagination', 'biography': 'Private full story'})['item'] + for i in range(23):item = self.family_save('person', dict(item, note=str(i)))['item'] + first = app.FAMILY.history(self.admin, 'person', item['id']) + second = app.FAMILY.history(self.admin, 'person', item['id'], first['nextBefore']) + revisions = [v['revision'] for v in first['items'] + second['items']] + self.assertEqual(len(revisions), 24) + self.assertEqual(len(set(revisions)), 24) + self.assertIsNone(second['nextBefore']) + self.assertNotIn('Private full story', json.dumps(first)) + + def test_history_and_restore_endpoints_enforce_edit_permission(self): + item = self.family_save('person', {'name': 'Permission history'}) + reader = app.ACCOUNTS.save(dict(username='history-reader', password='test-reader', familyAccess='read'), self.admin) + token = 'd' * 64 + with app.LOCK: + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, reader['id'])) + app.DB.commit() + path = '/api/family/history?kind=person&id=' + item['item']['id'] + for target in [path, '/api/family/change?revision=' + str(item['revision'])]: + self.assertEqual(self.request(target, token=token)[0], 403) + self.assertEqual(self.request(target, authenticated=False)[0], 401) + self.assertEqual(self.request(target)[0], 200) + self.assertEqual(self.request('/api/family/restore', token=token, data={})[0], 403) + for number in ['bad', '-1', '0', '99999999999999999999999']: + self.assertEqual(self.request('/api/family/change?revision=' + number)[0], 400) + + def test_family_history_transaction_rolls_back_on_storage_failure(self): + before = app.setting('familyRevision', 0) + with app.LOCK: + app.DB.execute("CREATE TRIGGER fail_history BEFORE INSERT ON family_changes BEGIN SELECT RAISE(ABORT, 'test failure'); END") + app.DB.commit() + try: + import sqlite3 + with self.assertRaises(sqlite3.IntegrityError):self.family_save('person', {'name': 'Must not persist'}) + self.assertEqual(app.setting('familyRevision', 0), before) + self.assertFalse(any(p['name']=='Must not persist' for p in app.objects('people'))) + finally: + with app.LOCK:app.DB.execute('DROP TRIGGER fail_history');app.DB.commit() + + def test_concurrent_family_saves_accept_only_one_revision(self): + from concurrent.futures import ThreadPoolExecutor + revision = app.setting('familyRevision', 0); barrier = threading.Barrier(2) + def save(index): + barrier.wait() + try:app.FAMILY.save('person', dict(name='Concurrent '+str(index), revision=revision), self.admin);return 200 + except app.Problem as e:return e.status + with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 409]) + self.assertEqual(app.setting('familyRevision'), revision+1) + + def test_password_change_revokes_all_own_sessions_and_keeps_other_accounts(self): + member = app.ACCOUNTS.save(dict(username='password-member', password='old-password', familyAccess='read'), self.admin) + tokens = ['1'*64, '2'*64] + with app.LOCK: + for token in tokens:app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, member['id'])) + app.DB.commit() + code, headers, _ = self.request('/api/account/password', token=tokens[0], data=dict(currentPassword='old-password', newPassword='new-password', id=self.admin['id'], role='admin')) + self.assertEqual(code, 200);self.assertIn('Max-Age=0', headers['Set-Cookie']) + self.assertIsNone(app.ACCOUNTS.verify(member['username'], 'old-password')) + self.assertEqual(app.ACCOUNTS.verify(member['username'], 'new-password')['role'], 'member') + for token in tokens:self.assertEqual(self.request('/api/family', token=token)[0], 401) + self.assertEqual(self.request('/api/users')[0], 200) + + def test_password_validation_and_rate_limit(self): + actor = app.ACCOUNTS.save(dict(username='password-validation', password='old-password'), self.admin) + for data in [dict(currentPassword='old-password', newPassword='tiny'),dict(currentPassword='old-password', newPassword='old-password')]: + with self.assertRaises(app.Problem):app.ACCOUNTS.change_password(data, actor) + for _ in range(8): + with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='wrong', newPassword='new-password'), actor) + self.assertEqual(caught.exception.status, 403) + with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='old-password', newPassword='new-password'), actor) + self.assertEqual(caught.exception.status, 429) + self.assertIsNotNone(app.ACCOUNTS.verify(actor['username'], 'old-password')) + self.assertEqual(self.request('/api/account/password', authenticated=False, data={})[0], 401) + self.assertEqual(self.request('/api/account/password', data={}, origin='http://[bad')[0], 403) + + def test_object_ids_and_hosts_reject_malformed_input_without_500(self): + for endpoint in ['sites', 'assets', 'cameras', 'recorders']: + for value in [[], {}, 17, True]: + self.assertEqual(self.request('/api/'+endpoint, data={'id':value})[0], 400) + self.assertEqual(self.request('/api/'+endpoint, data={'id':'9'*16})[0], 404) + for value in [None, 123, [], {}, True]: + with self.assertRaises(app.Problem):app.host_address(value) + + def test_concurrent_camera_capacity_and_asset_space_move(self): + from concurrent.futures import ThreadPoolExecutor + site = app.save_site({'name':'Capacity space'}) + other = app.save_site({'name':'Moved space'}) + asset = app.save_asset(dict(name='Single lens', siteId=site['id'], lensCount=1)) + barrier = threading.Barrier(2) + def save(index): + barrier.wait() + try:app.save_camera(dict(name='Concurrent lens '+str(index), assetId=asset['id'], host='192.168.0.2'));return 200 + except app.Problem as e:return e.status + with patch.object(app, 'write_media_config'): + with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 400]) + attached = [c for c in app.objects('cameras') if c.get('assetId')==asset['id']] + self.assertEqual(len(attached), 1) + app.save_asset(dict(asset, siteId=other['id'])) + self.assertEqual(app.get_object('cameras', attached[0]['id'])['siteId'], other['id']) + + def test_recorder_updates_are_reflected_in_channel_endpoint_without_password(self): + site = app.save_site({'name':'Recorder test'}) + asset = app.save_asset(dict(name='Recorder lens', siteId=site['id'], lensCount=1)) + with patch.object(app, 'write_media_config'): + r = app.save_recorder(dict(name='NVR test', siteId=site['id'], host='192.168.0.8', username='test', password='private')) + camera = app.save_camera(dict(name='Channel', assetId=asset['id'], sourceKind='recorder', recorderId=r['id'])) + app.save_recorder(dict(r, host='192.168.0.9', username='updated')) + current = app.public_camera(app.get_object('cameras', camera['id'])) + self.assertEqual(current['host'], '192.168.0.9') + self.assertEqual(current['username'], 'updated') + self.assertTrue(current['passwordConfigured']);self.assertNotIn('password', current) + + def test_camera_settings_admin_only_and_errors_are_explicit(self): + user = app.ACCOUNTS.save(dict(username='settings-member', password='settings-pass', siteIds=['test-site'] if app.get_object('sites','test-site') else []), self.admin) + token = '3'*64 + with app.LOCK: + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,user['id']));app.DB.commit() + path = '/api/camera/settings?camera='+'a'*16 + self.assertEqual(self.request(path,token=token)[0],403) + self.assertEqual(self.request('/api/camera/settings',data={},token=token)[0],403) + self.assertEqual(self.request(path,authenticated=False)[0],401) + with patch.object(app.camera_settings.CONTROLLER,'read',return_value=({'device':{'Model':'Example'},'revision':'one'},{})): + self.assertEqual(self.request(path)[0],200) + with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=ValueError('unsupported')): + self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],400) + with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=OSError()): + self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],502) + + def test_asset_move_rolls_back_all_channels_on_failure(self): + import sqlite3 + site = app.save_site({'name':'Original site atomic'}) + other = app.save_site({'name':'New site atomic'}) + asset = app.save_asset(dict(name='Atomic move',siteId=site['id'],lensCount=1)) + with patch.object(app,'write_media_config'): + camera = app.save_camera(dict(name='Atomic lens',assetId=asset['id'],host='192.168.0.2')) + with app.LOCK: + app.DB.execute("CREATE TRIGGER fail_camera_move BEFORE UPDATE ON cameras BEGIN SELECT RAISE(ABORT,'test'); END");app.DB.commit() + try: + with self.assertRaises(sqlite3.IntegrityError):app.save_asset(dict(asset,siteId=other['id'])) + self.assertEqual(app.get_object('assets',asset['id'])['siteId'],site['id']) + self.assertEqual(app.get_object('cameras',camera['id'])['siteId'],site['id']) + finally: + with app.LOCK:app.DB.execute('DROP TRIGGER fail_camera_move');app.DB.commit() + if __name__ == '__main__': unittest.main() diff --git a/test_camera_settings.py b/test_camera_settings.py new file mode 100644 index 0000000..6d5aaf5 --- /dev/null +++ b/test_camera_settings.py @@ -0,0 +1,122 @@ +"""ONVIF contract tests against synthetic devices; no real camera is modified.""" +import copy +import unittest +from unittest.mock import patch +from xml.etree import ElementTree as ET +import camera_settings as cs +import onvif + + +def xml(inner): + return ET.fromstring(''+inner+'') + + +class SettingsTests(unittest.TestCase): + def setUp(self): + self.controller=cs.Settings() + self.camera=dict(host='192.168.0.2',username='test',password='private',sourceKind='direct',enabled=True, + mainPath='/stream?profile=lens2',subPath='/sub?profile=lens2') + self.encoder=xml('H264' + '192010803' + '152048' + '60Main' + '1PT5S')[0] + self.info=dict(device=(80,'/onvif/device_service'),services={'Media':(80,'/media'),'Imaging':(80,'/imaging')}, + source='source2',profile='lens2',encoder=self.encoder) + self.imaging=xml('50AUTO' + 'MANUAL') + self.image_options=xml('0100' + 'AUTOON') + self.video_options=xml('05' + '115' + '19201080' + '2562048') + self.calls=[] + + def call(self,camera,endpoint,ns,operation,body=''): + self.calls.append((operation,body)) + if operation=='GetDeviceInformation':return xml('') + if operation=='GetImagingSettings':return copy.deepcopy(self.imaging) + if operation=='GetOptions':return self.image_options + if operation=='GetVideoEncoderConfigurationOptions':return self.video_options + if operation=='SetImagingSettings': + root=xml(body.replace('xmlns:', 'xmlns:')) + self.imaging=xml(ET.tostring(root.find(cs.I+'ImagingSettings'),encoding='unicode')) + return xml('') + return xml('') + + def test_device_ranges_and_vendor_codec_are_exposed_without_credentials(self): + with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info) + self.assertEqual(data['imaging']['brightness']['max'],100) + self.assertEqual(data['imaging']['dayNight']['choices'],['AUTO','ON']) + self.assertEqual(data['video']['codec'],'H265') + self.assertEqual(data['video']['options']['bitrate']['max'],2048) + self.assertNotIn('private',str(data)) + self.assertNotIn('sharpness',data['imaging']) + + def test_rejects_bad_values_and_unsupported_controls(self): + rule={'brightness':dict(min=0,max=100)} + for value in [True,101,-1,float('nan'),float('inf'),'50']: + with self.assertRaises(ValueError):self.controller.validate({'brightness':value},rule) + for values in [{},{'unknown':1},None,[]]: + with self.assertRaises(ValueError):self.controller.validate(values,rule) + with self.assertRaises(ValueError):self.controller.validate({'fps':1.5},{'fps':dict(min=1,max=15)},('fps',)) + with self.assertRaises(ValueError):self.controller.validate({'mode':'OFF'},{'mode':dict(choices=['AUTO'])}) + + def test_imaging_write_preserves_other_fields_and_verifies_readback(self): + with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call): + data,_=self.controller.read(self.camera) + result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60},quality='main')) + self.assertTrue(result['verified']) + sent=next(body for op,body in self.calls if op=='SetImagingSettings') + self.assertIn('MANUAL',sent);self.assertIn('source2',sent);self.assertIn('ForcePersistence',sent) + + def test_stale_revision_never_issues_device_write(self): + with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call): + with self.assertRaises(ValueError):self.controller.save(self.camera,dict(revision='old',section='imaging',values={'brightness':60})) + self.assertFalse(any(op.startswith('Set') for op,_ in self.calls)) + + def test_video_write_retains_codec_extension_multicast_and_other_settings(self): + with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call): + data,_=self.controller.read(self.camera) + result=self.controller.save(self.camera,dict(revision=data['revision'],section='video',values={'fps':10})) + sent=next(body for op,body in self.calls if op=='SetVideoEncoderConfiguration') + self.assertIn('encoding="H265"',sent);self.assertIn('Multicast',sent);self.assertIn('SessionTimeout',sent) + self.assertIn('>10<',sent);self.assertFalse(result['verified']) # Synthetic device deliberately ignores this write. + + def test_device_timeout_after_save_does_not_falsely_claim_confirmation(self): + with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info) + with patch.object(self.controller,'read',side_effect=[(data,self.info),OSError()]),patch.object(self.controller,'call',return_value=xml('')): + result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60})) + self.assertFalse(result['verified']);self.assertIsNone(result['settings']) + + def test_onvif_requests_do_not_follow_redirects(self): + self.assertIsNone(onvif.NoRedirect().redirect_request(None, None, 302, 'Found', {}, 'http://other.example/')) + + def test_network_endpoints_and_recorder_channels_are_rejected(self): + for address in ['http://evil.example/api','https://192.168.0.2/api','http://u:p@192.168.0.2/api','file:///etc/passwd']: + with self.assertRaises(ValueError):self.controller.endpoint(self.camera,address) + for overrides in [dict(sourceKind='recorder'),dict(enabled=False)]: + with self.assertRaises(ValueError):self.controller.discover(dict(self.camera,**overrides),'main') + with self.assertRaises(ValueError):self.controller.discover(self.camera,'bad') + + def test_exact_profile_path_and_query_selects_second_lens(self): + selected=[] + def call(c,e,ns,op,body=''): + if op=='GetCapabilities':return xml('http://192.168.0.2/media') + if op=='GetProfiles':return xml(''.join('source'+str(i)+''+ET.tostring(self.encoder,encoding='unicode')+'' for i in [1,2])) + if op=='GetStreamUri': + token='lens2' if 'lens2' in body else 'lens1';selected.append(token) + return xml('rtsp://192.168.0.2/stream?profile='+token+'') + with patch.object(self.controller,'call',call):info=self.controller.discover(self.camera,'main') + self.assertEqual(info['source'],'source2');self.assertEqual(selected,['lens1','lens2']) + + def test_failed_options_disable_writes_but_keep_other_section_readable(self): + def call(*args): + if args[3]=='GetVideoEncoderConfigurationOptions':raise OSError() + return self.call(*args) + with patch.object(self.controller,'call',call):data=self.controller.details(self.camera,self.info) + self.assertEqual(data['video']['options'],{});self.assertIn('brightness',data['imaging']);self.assertTrue(data['notes']) + + +if __name__=='__main__':unittest.main() diff --git a/test_ui_logic.js b/test_ui_logic.js index 1ac7701..8a2db64 100644 --- a/test_ui_logic.js +++ b/test_ui_logic.js @@ -72,3 +72,53 @@ test('older family request errors cannot repopulate logged-out view',async()=>{ vm.runInContext(section('family.js','async function loadFamily(','function relationText('),c); const p=c.loadFamily();c.loggedIn=false;c.familyGeneration++;pending.reject(Error('late error'));assert.equal(await p,false);assert.equal(messages,0); }); + +const historySource=section('family-history.js','async function showFamilyChange(','function prepareFamilyRestore('); +test('history prose is never translated as an enum value',()=>{ + const c=context({personName:id=>'Person '+id});vm.runInContext(section('family-history.js','function historyValue(','async function openFamilyHistory('),c); + assert.equal(c.historyValue('body','published'),'published');assert.equal(c.historyValue('status','published'),'已发布'); + assert.equal(c.historyValue('personIds',['a','b']),'Person a、Person b');assert.equal(c.historyValue('active',false),'否'); +}); +test('history detail from a previous selection cannot replace newer detail',async()=>{ + const a=deferred(),b=deferred(),calls=[a,b],elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);}; + const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>calls.shift().promise,$,historyFields:{name:'Name'},esc:String,historyValue:(_,v)=>v}); + vm.runInContext(historySource,c);const old=c.showFamilyChange(1),latest=c.showFamilyChange(2); + b.resolve({revision:2,before:null,after:{name:'New'}});await latest;const html=$('#history-detail').innerHTML; + a.resolve({revision:1,before:null,after:{name:'Old'}});await old;assert.equal($('#history-detail').innerHTML,html);assert.equal(c.historySelection.revision,2); +}); +test('closing history prevents late private detail from rendering',async()=>{ + const pending=deferred(),detail={};const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>pending.promise,$:()=>detail}); + vm.runInContext(historySource,c);const request=c.showFamilyChange(1);c.historyContext=null;c.historyDetailGeneration++;pending.resolve({after:{name:'Private'}});await request; + assert.equal(detail.innerHTML,undefined);assert.equal(c.historySelection,null); +}); +test('password form requires matching, distinct and bounded new password',()=>{ + const c=context();vm.runInContext(section('app.js','function validateNewPassword(','$(\'#account-password\').onclick='),c); + for(const data of [{newPassword:'different',confirmPassword:'mismatch'},{newPassword:'samepass',confirmPassword:'samepass',currentPassword:'samepass'},{newPassword:'short',confirmPassword:'short'},{newPassword:'x'.repeat(129),confirmPassword:'x'.repeat(129)}])assert.throws(()=>c.validateNewPassword(data)); + assert.doesNotThrow(()=>c.validateNewPassword({newPassword:'new-password',confirmPassword:'new-password',currentPassword:'old-password'})); +}); +test('history restore sends the reviewed revision and never the full text',async()=>{ + let sent;const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){}});return elements.get(s);}; + const c=context({historyContext:{revision:10},historySelection:{revision:3,before:{name:'Original'}},historyDetailGeneration:1,$,$$:()=>[], + api:async(path,data)=>{sent={path,data};},loadFamilyHistory:async()=>{},loadFamily:async()=>true,toast:()=>{}}); + vm.runInContext(section('family-history.js','async function restoreFamilyChange(','$(\'#history-more\').onclick='),c);await c.restoreFamilyChange('before'); + assert.equal(sent.path,'/api/family/restore');assert.equal(JSON.stringify(sent.data),JSON.stringify({changeRevision:3,side:'before',revision:10}));assert.equal(c.historySelection,null); +}); + +test('camera form sends only changed and advertised parameters',()=>{ + const c=context();vm.runInContext(section('camera-settings.js','function changedCameraValues(','async function saveCameraSettings('),c); + const form={elements:{brightness:{value:'60'},dayNight:{value:'AUTO'},malicious:{value:'anything'}}}; + const settings={imaging:{brightness:{value:50,min:0,max:100},dayNight:{value:'AUTO',choices:['AUTO','ON']}}}; + assert.equal(JSON.stringify(c.changedCameraValues(form,'imaging',settings)),JSON.stringify({brightness:60})); +}); +test('late camera settings cannot populate a closed or different camera dialog',async()=>{ + const pending=deferred(),elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{value:'main'});return elements.get(s);};let renders=0; + const c=context({cameraSettingsContext:{id:'old'},cameraSettingsGeneration:0,$,$$:()=>[],URLSearchParams,api:()=>pending.promise,renderCameraSettings:()=>renders++}); + vm.runInContext(section('camera-settings.js','async function loadCameraSettings(','async function openCameraSettings('),c);const request=c.loadCameraSettings();c.cameraSettingsContext={id:'new'};c.cameraSettingsGeneration++;pending.resolve({device:{}});await request;assert.equal(renders,0); +}); +test('ambiguous device save requires re-read before another write',async()=>{ + const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};const buttons=[{disabled:false}]; + const c=context({cameraSettingsContext:{id:'camera',quality:'main',settings:{revision:'old'}},cameraSettingsGeneration:0,$,$$:()=>buttons, + changedCameraValues:()=>({brightness:60}),api:async()=>{throw Error('connection lost');},toast:()=>{}}); + vm.runInContext(section('camera-settings.js','async function saveCameraSettings(','for(const [id,section]'),c);await c.saveCameraSettings({},'imaging'); + assert.equal(c.cameraSettingsContext.settings,null);assert.equal(buttons[0].disabled,true);assert.equal($('#camera-settings-reload').disabled,false); +}); diff --git a/web/app.js b/web/app.js index d364d9b..c2009d8 100644 --- a/web/app.js +++ b/web/app.js @@ -9,11 +9,13 @@ let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGenera const isAdmin = () => currentUser?.role === 'admin'; function applyPermissions(){ const admin=isAdmin(); - $$('[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]').forEach(el=>el.hidden=!admin); + $('#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="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none'); $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; } function clearPrivateView(){ + 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(); @@ -85,7 +87,7 @@ function renderLive() { const key = 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('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); + $('#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('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); for (const c of cameras.filter(c=>c.enabled)) { const video = $(`video[data-camera="${c.id}"]`); players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); @@ -107,7 +109,7 @@ function renderCameras() { $('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测'; $('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`
${esc(s.host)} · RTSP 服务响应
`).join('') || (state.scan.at ? '

本次没有发现响应 RTSP 554 端口的设备。

' : ''); const cameras=filtered(); - $('#camera-list').innerHTML=cameras.length?`
${cameras.map(c=>``).join('')}
设备 / 位置所属空间地址码流状态录像
${esc(c.name)}${esc(c.point||'位置待完善')}${esc(siteName(c.siteId))}${esc(c.host)}:${c.port}${c.ready?'已接通':c.enabled?'未接通':'停用'}${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}
`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。'); + $('#camera-list').innerHTML=cameras.length?`
${cameras.map(c=>``).join('')}
设备 / 位置所属空间地址码流状态录像
${esc(c.name)}${esc(c.point||'位置待完善')}${esc(siteName(c.siteId))}${esc(c.host)}:${c.port}${c.ready?'已接通':c.enabled?'未接通':'停用'}${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}
`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。'); } function renderSites() { const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value); @@ -170,3 +172,15 @@ $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').el handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)})); $('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}}; + +function validateNewPassword(values){ + if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致'); + if(values.newPassword===values.currentPassword)throw Error('新密码不能与当前密码相同'); + if(values.newPassword.length<8||values.newPassword.length>128)throw Error('密码需要 8 至 128 个字符'); +} +$('#account-password').onclick=()=>{if(!loggedIn)return;const f=$('#password-form');f.reset();f.querySelector('.form-error').textContent='';$('#password-account').textContent='当前账号:'+currentUser.username;$('#password-dialog').showModal();}; +$('#password-form').addEventListener('submit',async e=>{ + e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; + try{const data=formValues(f);validateNewPassword(data);await api('/api/account/password',{currentPassword:data.currentPassword,newPassword:data.newPassword});clearPrivateView();await auth();toast('密码已修改,请用新密码重新登录');} + catch(err){if(!err.stale)f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;} +}); diff --git a/web/camera-settings.js b/web/camera-settings.js new file mode 100644 index 0000000..80b848d --- /dev/null +++ b/web/camera-settings.js @@ -0,0 +1,56 @@ +'use strict'; +let cameraSettingsContext=null,cameraSettingsGeneration=0; +const videoLabels={resolution:'分辨率',fps:'帧率(帧/秒)',bitrate:'码率上限(kbps)',quality:'画质等级',gop:'关键帧间隔(帧)'}; +function clearCameraSettings(){cameraSettingsGeneration++;cameraSettingsContext=null;for(const id of ['camera-settings-info','camera-image-fields','camera-video-fields','camera-settings-status'])$('#'+id).replaceChildren();} +function settingsInput(key,label,value,rule){ + const enumLabel=v=>key==='dayNight'?({AUTO:'自动日夜',ON:'日间(红外截止)',OFF:'夜间(红外通过)'})[v]||v:({ON:'开启',OFF:'关闭',AUTO:'自动',MANUAL:'手动'})[v]||v; + return ``; +} +function renderCameraSettings(data){ + const d=data.device; + $('#camera-settings-info').innerHTML=`
${[['厂商',d.Manufacturer],['型号',d.Model],['固件',d.FirmwareVersion],['镜头视频源',data.source],['码流配置',data.profile],['设备报告编码',data.video.codec]].map(([k,v])=>`
${esc(k)}
${esc(v||'未提供')}
`).join('')}
`; + $('#camera-image-fields').innerHTML=Object.entries(data.imaging).map(([k,r])=>settingsInput(k,r.label,r.value,r)).join('')||'

设备未提供可调图像参数。

'; + $('#camera-video-fields').innerHTML=Object.entries(data.video.options).map(([k,r])=>settingsInput(k,videoLabels[k],data.video.values[k],r)).join('')||'

设备未提供可调码流参数。

'; + $('#camera-settings-status').textContent=data.notes.join(';'); + $('#camera-image-form button[type=submit]').disabled=!Object.keys(data.imaging).length; + $('#camera-video-form button[type=submit]').disabled=!Object.keys(data.video.options).length; +} +async function loadCameraSettings(){ + if(!cameraSettingsContext)return;const context=cameraSettingsContext,generation=++cameraSettingsGeneration; + context.settings=null;context.quality=$('#camera-settings-quality').value; + $('#camera-settings-status').textContent='正在读取设备实际参数…'; + $$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true; + try{const result=await api('/api/camera/settings?'+new URLSearchParams({camera:context.id,quality:context.quality})); + if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;context.settings=result;renderCameraSettings(result); + }catch(e){if(!e.stale&&generation===cameraSettingsGeneration)$('#camera-settings-status').textContent=e.message;} + finally{if(generation===cameraSettingsGeneration)$('#camera-settings-reload').disabled=false;} +} +async function openCameraSettings(id){ + if(!isAdmin())return;clearCameraSettings();cameraSettingsContext={id,settings:null,quality:'main'}; + $('#camera-settings-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像机')+' · 参数设置';$('#camera-settings-quality').value='main';$('#camera-settings-quality').disabled=false; + $('#camera-settings-dialog').showModal();await loadCameraSettings(); +} +function changedCameraValues(form,section,settings){ + const rules=section==='imaging'?settings.imaging:settings.video.options,values={}; + for(const [key,rule]of Object.entries(rules)){ + const value=rule.choices?form.elements[key].value:Number(form.elements[key].value); + if(value!==(section==='imaging'?rule.value:settings.video.values[key]))values[key]=value; + } + return values; +} +async function saveCameraSettings(form,section){ + const context=cameraSettingsContext;if(!context?.settings)return; + const values=changedCameraValues(form,section,context.settings);if(!Object.keys(values).length){toast('参数没有变化');return;} + const generation=++cameraSettingsGeneration; + $$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;$('#camera-settings-quality').disabled=true; + $('#camera-settings-status').textContent='正在保存并读取设备返回值…'; + try{const result=await api('/api/camera/settings',{camera:context.id,quality:context.quality,revision:context.settings.revision,section,values}); + if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return; + context.settings=result.settings;if(result.settings)renderCameraSettings(result.settings);$('#camera-settings-status').textContent=result.message;toast(result.message); + }catch(e){if(!e.stale&&generation===cameraSettingsGeneration){context.settings=null;$('#camera-settings-status').textContent=e.message+'。请点击“重新读取”核对设备状态。';}} + finally{if(generation===cameraSettingsGeneration){$('#camera-settings-reload').disabled=false;$('#camera-settings-quality').disabled=false;}} +} +for(const [id,section]of [['camera-image-form','imaging'],['camera-video-form','video']])$('#'+id).addEventListener('submit',e=>{e.preventDefault();saveCameraSettings(e.currentTarget,section);}); +$('#camera-settings-reload').onclick=loadCameraSettings;$('#camera-settings-quality').onchange=loadCameraSettings; +$('#camera-settings-dialog').addEventListener('close',clearCameraSettings); +document.addEventListener('click',e=>{const b=e.target.closest('[data-camera-settings]');if(b)openCameraSettings(b.dataset.cameraSettings);}); diff --git a/web/family-events.js b/web/family-events.js index 4c912ac..187b1c0 100644 --- a/web/family-events.js +++ b/web/family-events.js @@ -35,7 +35,7 @@ function openFamilyEvent(id){ const e=(family.events||[]).find(e=>e.id===id);if(!e)return; // All content is plain text. External reports are links, never embedded HTML. const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:''; - $('#event-detail-content').innerHTML=`
${esc(eventStatus(e))}

${esc(e.title)}

${e.summary?`

${esc(e.summary)}

`:''}
关联人物${e.personIds.length?e.personIds.map(id=>``).join(''):'全家记事'}
${esc(e.body||'此条记事暂未填写详细正文。')}
${safeSource?`阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗`:e.sourceName?`

资料来源:${esc(e.sourceName)}

`:''}

最近整理:${esc(new Date(e.updatedAt).toLocaleString())}

${family.canEdit?``:''}`; + $('#event-detail-content').innerHTML=`
${esc(eventStatus(e))}

${esc(e.title)}

${e.summary?`

${esc(e.summary)}

`:''}
关联人物${e.personIds.length?e.personIds.map(id=>``).join(''):'全家记事'}
${esc(e.body||'此条记事暂未填写详细正文。')}
${safeSource?`阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗`:e.sourceName?`

资料来源:${esc(e.sourceName)}

`:''}

最近整理:${esc(new Date(e.updatedAt).toLocaleString())}

${family.canEdit?``:''}`; $('#event-detail-dialog').showModal(); } function editFamilyEvent(id,personId=''){ diff --git a/web/family-history.js b/web/family-history.js new file mode 100644 index 0000000..96f510a --- /dev/null +++ b/web/family-history.js @@ -0,0 +1,62 @@ +'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:'报道链接'}; +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 historyValue(key,value){ + if(value===undefined||value===null||value==='')return '未填写'; + if(key==='personIds')return value.length?value.map(personName).join('、'):'全家'; + if(key==='fromId'||key==='toId')return personName(value); + if(typeof value==='boolean')return value?'是':'否'; + const labels={male:'男',female:'女',unknown:'待确认',alive:'在世',deceased:'已故',draft:'草稿',published:'已发布',parent:'父母 → 子女',spouse:'配偶',biological:'亲生',adoptive:'收养',step:'继亲',unspecified:'未注明'}; + return ['gender','lifeStatus','status','kind','lineage'].includes(key)?labels[value]||String(value):String(value); +} +async function openFamilyHistory(kind,id){ + if(!family.canEdit)return;clearFamilyHistory(); + if($('#event-detail-dialog').open)$('#event-detail-dialog').close(); + historyContext={kind,id,revision:family.revision,nextBefore:null}; + $('#history-title').textContent='修改记录 · '+({person:personName(id),link:'亲属关系',event:family.events.find(e=>e.id===id)?.title||'家族记事'})[kind]; + $('#history-dialog').showModal();await loadFamilyHistory(); +} +async function loadFamilyHistory(more=false){ + if(!historyContext)return;const context=historyContext,generation=++historyGeneration,b=$('#history-more');b.disabled=true; + try{const result=await api('/api/family/history?'+new URLSearchParams({kind:context.kind,id:context.id,...(more&&context.nextBefore?{before:context.nextBefore}:{})})); + if(!loggedIn||generation!==historyGeneration||context!==historyContext)return; + context.nextBefore=result.nextBefore;if(!more)context.revision=result.revision; + const html=result.items.map(c=>``).join(''); + if(more)$('#history-list').insertAdjacentHTML('beforeend',html);else $('#history-list').innerHTML=html||'

暂无修改记录。此功能从本次升级开始记录;升级前的内容会在下一次修改时保留。

'; + b.hidden=!context.nextBefore;$('#history-error').textContent=''; + }catch(e){if(!e.stale&&generation===historyGeneration)$('#history-error').textContent=e.message;}finally{if(generation===historyGeneration)b.disabled=false;} +} +async function showFamilyChange(revision){ + const context=historyContext,generation=++historyDetailGeneration;historySelection=null;$('#history-detail').textContent='正在读取版本…'; + try{const c=await api('/api/family/change?revision='+revision); + if(!loggedIn||generation!==historyDetailGeneration||context!==historyContext)return; + historySelection=c; + const keys=Object.keys(historyFields).filter(k=>k in c.after||k in (c.before||{})); + $('#history-detail').innerHTML=`

版本 ${c.revision} 的内容对照

恢复只更新这条记录。已发生的后续修改仍可在历史中查阅。

${['before','after'].map(side=>`

${side==='before'?'本次修改前':'本次修改后'}

${c[side]?`
${keys.map(k=>`
${esc(historyFields[k])}
${esc(historyValue(k,c[side][k]))}
`).join('')}
`:'

创建前没有记录。

'}
`).join('')}
`; + $('#history-error').textContent=''; + }catch(e){if(!e.stale&&generation===historyDetailGeneration)$('#history-detail').textContent=e.message;} +} +function prepareFamilyRestore(side){ + const c=historySelection;if(!c?.[side])return;const value=c[side],box=$('#history-confirm'); + box.hidden=false;box.innerHTML=`

将用版本 ${c.revision} ${side==='before'?'修改前':'修改后'}的内容替换这条记录的当前内容。${c.kind==='event'?'恢复后记事状态:'+esc(eventStatus(value))+'。':''}

`;box.scrollIntoView({block:'nearest'}); +} +async function restoreFamilyChange(side){ + const context=historyContext,c=historySelection;if(!context||!c?.[side])return; + const generation=historyDetailGeneration;$$('#history-confirm button').forEach(b=>b.disabled=true); + try{await api('/api/family/restore',{changeRevision:c.revision,side,revision:context.revision}); + if(!loggedIn||context!==historyContext)return; + historyDetailGeneration++;historySelection=null;$('#history-detail').replaceChildren(); + await loadFamilyHistory();const loaded=await loadFamily();if(loggedIn)toast(loaded?'已恢复,原有版本仍保留在修改记录中':'已恢复,读取最新家谱失败,请重新载入'); + }catch(e){if(!e.stale&&context===historyContext){$('#history-error').textContent=e.message;if(e.status===409)$('#history-error').append(document.createTextNode('。请关闭并重新打开修改记录,核对当前内容后再选择恢复。'));}} + finally{if(generation===historyDetailGeneration)$$('#history-confirm button').forEach(b=>b.disabled=false);} +} +$('#history-more').onclick=()=>loadFamilyHistory(true); +$('#history-dialog').addEventListener('close',clearFamilyHistory); +document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + if(b.dataset.historyKind)openFamilyHistory(b.dataset.historyKind,b.dataset.historyId); + if(b.dataset.historyChange)showFamilyChange(Number(b.dataset.historyChange)); + if(b.dataset.historyPrepare)prepareFamilyRestore(b.dataset.historyPrepare); + if(b.dataset.historyRestore)restoreFamilyChange(b.dataset.historyRestore); + if(b.id==='history-cancel-restore')$('#history-confirm').hidden=true; +}); diff --git a/web/family-map.css b/web/family-map.css index 90ee03d..ea59fae 100644 --- a/web/family-map.css +++ b/web/family-map.css @@ -6,3 +6,15 @@ @media(max-width:600px){.family-event-intro{flex-wrap:wrap;padding:8px 15px 15px}.family-event-filters{padding:15px;gap:10px}.family-event-filters label{font-size:11px}.family-event-filters .check{line-height:1.8;flex-wrap:nowrap}.family-timeline{padding:0 15px 22px}.timeline-event{gap:8px}.timeline-event>time{font-size:10px;flex-basis:55px}.timeline-event:before{left:62px}.timeline-event:after{left:58px}.timeline-event-body{padding:13px;margin-left:9px}.timeline-event-body>strong{font-size:15px}.family-view-tabs button{padding:8px 10px}#event-detail-title{font-size:22px}.event-detail-meta{flex-wrap:wrap}.event-editor-actions{flex-wrap:wrap}} .family-workspace.family-expanded{position:fixed;inset:0;z-index:8;} + +/* Versions remain readable on a small screen, including long family narratives. */ +#history-dialog{width:min(1040px,94vw)} +#history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0} +.history-entry{text-align:left;display:flex;flex-direction:column;align-items:flex-start;gap:6px;white-space:normal} +.history-entry small,.history-entry span{color:var(--muted);font-size:12px} +.history-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px} +.history-columns section{min-width:0}.history-columns dd{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto} +.history-columns dl>div{padding:10px;border-bottom:1px solid #30434a}.history-changed{background:#27443e66;border-radius:8px} +#history-confirm{padding:16px;border:1px solid #74d0b7;border-radius:12px;margin-top:18px} +#history-confirm button{margin:6px}#history-error:empty{display:none} +@media(max-width:650px){.history-columns{grid-template-columns:1fr}#history-list{max-height:200px}} diff --git a/web/family.js b/web/family.js index d126fef..6649997 100644 --- a/web/family.js +++ b/web/family.js @@ -5,7 +5,7 @@ let family={people:[],links:[],events:[],revision:0,canEdit:false}, users=[], fo const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id)); const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事']; $('#relationship-choices').innerHTML=relationships.map(r=>``).join(''); -function clearFamilyState(){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 personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';} function peopleOptions(blank=true){return (blank?'':'')+family.people.map(p=>``).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??'';}} @@ -40,7 +40,7 @@ function renderFamily(){ $('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p); if(!p){$('#family-detail').innerHTML='

人物档案

点击图中的人物查看生平,或录入第一位人物。

';return;} const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id); - $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',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])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; + $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',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])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id)); $('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'
◎ 本人 · 当前账号关联人物
':''}`); } diff --git a/web/index.html b/web/index.html index 517e991..132c305 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,8 @@ 赵府智家 · 家庭空间 + +
ZHAOFU HOME / FAMILY

实时画面

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

等待连接
@@ -74,6 +76,9 @@

连接人物关系

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

写一则家族记事

事情发生的日期 *
关联人物(可多选)

未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。

正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。

FAMILY CHRONICLE
+

修改记录

人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。

+

修改我的密码

保存后,这个账号在所有设备上都需要用新密码重新登录。

+

摄像机参数

图像与日夜模式

作用于当前物理镜头,主、子码流共用这些图像参数。

码流与清晰度

作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。

其他萤石功能的接入情况
  • 云台方向微调:在实时画面的“云台”中使用,按设备能力启用。
  • 实时声音与全屏:通过画面播放器控制。
  • 双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。
  • 录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。

功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。