feat: configurable branding, private preferences, city photo cards and database backups

This commit is contained in:
Codex
2026-10-04 17:22:42 +08:00
parent 4ac0a0b516
commit 83e8981603
16 changed files with 788 additions and 22 deletions
+51 -4
View File
@@ -34,6 +34,8 @@ import genealogy
import households
import inventory
import heritage
import appearance
import backups
import ptz
ROOT = Path(__file__).resolve().parent
@@ -79,6 +81,8 @@ FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
HERITAGE = heritage.Heritage(sys.modules[__name__])
APPEARANCE = appearance.Appearance(sys.modules[__name__])
BACKUPS = backups.Backups(sys.modules[__name__])
def setting(key, default=None):
@@ -515,6 +519,15 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
def image(self, raw, public=False):
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(raw)))
self.send_header('Cache-Control', 'no-cache' if public else 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.end_headers()
self.wfile.write(raw)
def token(self):
try:
c = http.cookies.SimpleCookie(self.headers.get('Cookie', ''))
@@ -617,13 +630,13 @@ class Handler(BaseHTTPRequestHandler):
try:
path = url.urlsplit(self.path).path
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(131072 if path in ('/api/family/event','/api/heritage') else 16384)
data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage') else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'):
self.require_manager()
if path == '/api/logout':
@@ -631,6 +644,17 @@ class Handler(BaseHTTPRequestHandler):
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/preferences':
self.answer(APPEARANCE.save_preferences(data, self.user))
elif path == '/api/appearance':
self.require_admin()
self.answer(APPEARANCE.save_config(data, self.user))
elif path == '/api/backups/config':
self.require_admin()
self.answer(BACKUPS.save(data))
elif path == '/api/backups/run':
self.require_admin()
self.answer(BACKUPS.start())
elif path == '/api/account/password':
result = ACCOUNTS.change_password(data, self.user)
self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
@@ -817,11 +841,19 @@ class Handler(BaseHTTPRequestHandler):
parsed = url.urlsplit(self.path)
path = parsed.path
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == '/api/branding':
self.answer(APPEARANCE.public())
return
if path in ('/brand-logo.png', '/brand-icon.png'):
value = APPEARANCE.config().get('logoData' if path == '/brand-logo.png' else 'iconData', '')
raw = base64.b64decode(value.split(',', 1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
self.image(raw, public=True)
return
if path == '/api/session':
user = self.authenticated()
user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id'))
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
'setupRequired': not bool(objects('users'))})
'setupRequired': not bool(objects('users')), 'preferences': APPEARANCE.preferences(user) if user else None})
return
if path == '/healthz':
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
@@ -830,6 +862,20 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/preferences':
self.answer(APPEARANCE.preferences(self.user))
elif path == '/api/appearance':
self.require_admin()
self.answer(APPEARANCE.config())
elif path == '/api/backups':
self.require_admin()
self.answer(BACKUPS.status())
elif path == '/api/households/cover':
home = get_object('households', query.get('id'))
if not home or self.user['role'] != 'admin' and home['id'] != HOUSEHOLDS.scope(self.user):
raise Problem('封面不存在或未获授权', 404)
if not home.get('coverData'): raise Problem('尚未上传封面', 404)
self.image(base64.b64decode(home['coverData'].split(',', 1)[1]))
elif path == '/api/users':
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
@@ -923,7 +969,7 @@ class Handler(BaseHTTPRequestHandler):
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', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
@@ -978,6 +1024,7 @@ if __name__ == '__main__':
initialize()
threading.Thread(target=storage_watch, daemon=True).start()
threading.Thread(target=media_config_watch, daemon=True).start()
threading.Thread(target=BACKUPS.watch, daemon=True).start()
bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()]
port = int(os.environ.get('VISION_PORT', '8790'))
servers = []