Add personal accounts, scoped access and editable family genealogy

This commit is contained in:
Codex
2026-10-03 17:41:52 +08:00
parent 84a84d9af0
commit b3aff9f2c4
13 changed files with 591 additions and 38 deletions
+50 -19
View File
@@ -27,6 +27,8 @@ import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import onvif
import playback
import identity
import genealogy
ROOT = Path(__file__).resolve().parent
os.umask(0o077)
@@ -66,6 +68,10 @@ class Problem(Exception):
self.status = status
ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
def setting(key, default=None):
with LOCK:
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
@@ -470,15 +476,22 @@ class Handler(BaseHTTPRequestHandler):
def authenticated(self):
token = self.token()
if not re.fullmatch('[a-f0-9]{64}', token):
return False
return None
with LOCK:
return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?',
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone())
row = DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?',
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone()
user = get_object('users', row['user_id']) if row else None
return user if user and not user['disabled'] else None
def require_auth(self):
if not self.authenticated():
self.user = self.authenticated()
if not self.user:
raise Problem('请先登录', 401)
def require_admin(self):
if self.user['role'] != 'admin':
raise Problem('此操作需要管理员权限', 403)
def body(self):
if self.headers.get_content_type() != 'application/json':
raise Problem('请求需要 JSON 格式')
@@ -509,31 +522,29 @@ class Handler(BaseHTTPRequestHandler):
ATTEMPTS[ip] = (count + 1, expiry)
if count >= 12:
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
account = setting('account')
accounts = objects('users')
password = data.get('password', '')
if not isinstance(password, str) or not 10 <= len(password) <= 128:
raise Problem('管理密码需要 10 至 128 个字符')
if setup:
if account:
if accounts:
raise Problem('管理员已经初始化', 409)
code = data.get('code', '')
expected = (DATA / 'setup-code.txt').read_text().strip()
if not isinstance(code, str) or not hmac.compare_digest(code, expected):
raise Problem('初始化码不正确', 403)
salt = secrets.token_hex(16)
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
set_setting('account', {'salt': salt, 'hash': derived})
account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin',
'password': password, 'familyAccess': 'edit'}, None)
(DATA / 'setup-code.txt').unlink(missing_ok=True)
audit('初始化管理员')
else:
if not account:
if not accounts:
raise Problem('请先初始化管理员', 409)
derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex()
if not hmac.compare_digest(derived, account['hash']):
raise Problem('密码不正确', 403)
account = ACCOUNTS.verify(data.get('username', ''), password)
if not account:
raise Problem('用户名或密码不正确', 403)
token = secrets.token_hex(32)
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
DB.execute('INSERT INTO sessions VALUES (?,?)', (hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7))
DB.execute('INSERT INTO sessions (hash,expires,user_id) VALUES (?,?,?)',
(hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7, account['id']))
DB.commit()
ATTEMPTS.pop(ip, None)
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
@@ -548,11 +559,17 @@ 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'):
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/users':
self.answer(ACCOUNTS.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
self.answer(save_site(data))
elif path == '/api/cameras':
@@ -684,7 +701,9 @@ class Handler(BaseHTTPRequestHandler):
path = parsed.path
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == '/api/session':
self.answer({'authenticated': self.authenticated(), 'setupRequired': not bool(setting('account'))})
user = self.authenticated()
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
'setupRequired': not bool(objects('users'))})
return
if path == '/healthz':
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
@@ -692,8 +711,14 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith(('/api/', '/media/')):
self.require_auth()
if path == '/api/state':
self.answer(status())
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')]})
elif path == '/api/family':
self.answer(FAMILY.snapshot(self.user))
elif path == '/api/audit':
self.require_admin()
with LOCK:
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
self.answer(rows)
@@ -701,6 +726,7 @@ class Handler(BaseHTTPRequestHandler):
c = get_object('cameras', query.get('camera'))
if not c:
raise Problem('请选择摄像头')
ACCOUNTS.camera(self.user, c)
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
if not 0 < (end - start).total_seconds() <= 172800:
raise Problem('每次查询最多两天')
@@ -712,11 +738,13 @@ class Handler(BaseHTTPRequestHandler):
suffix = path[len('/media/live/'):]
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', suffix):
raise Problem('视频路径不正确')
ACCOUNTS.camera(self.user, get_object('cameras', suffix[4:20]))
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
elif path == '/media/playback':
c = get_object('cameras', query.get('camera'))
if not c:
raise Problem('摄像头不存在', 404)
ACCOUNTS.camera(self.user, c)
start = parse_time(query.get('start'))
try:
duration = float(query.get('duration', '300'))
@@ -735,6 +763,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',
'/vendor/hls.min.js': 'vendor/hls.min.js'}
if path not in files:
raise Problem('页面不存在', 404)
@@ -760,12 +789,14 @@ class Handler(BaseHTTPRequestHandler):
def initialize():
global MEDIA_AUTH
FAMILY.initialize()
ACCOUNTS.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
set_setting('mediaSecret', secrets.token_hex(32))
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
if not setting('account') and not (DATA / 'setup-code.txt').exists():
if not objects('users') and not (DATA / 'setup-code.txt').exists():
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
os.chmod(DATA / 'setup-code.txt', 0o600)
write_media_config()