Add verified camera settings, family version recovery and account self service

This commit is contained in:
Codex
2026-10-04 04:52:39 +08:00
parent 56a24c80e2
commit 96f0ef31fc
18 changed files with 969 additions and 37 deletions
+85 -19
View File
@@ -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'}