Files
ucvl-home-vision/appearance.py
T

153 lines
7.2 KiB
Python
Raw Permalink Normal View History

"""Public installation branding and private, account-scoped presentation preferences."""
import base64
import binascii
import copy
import struct
import zlib
CHOICES = {
'theme': ('paper', 'clean', 'ink'),
'layout': ('sidebar', 'top'),
'density': ('comfortable', 'compact'),
'backdrop': ('mountains', 'plain'),
'font': ('serif', 'sans'),
'startPage': ('households', 'overview', 'objects', 'heritage'),
}
DEFAULTS = dict(theme='paper', layout='sidebar', density='comfortable', backdrop='mountains',
font='serif', startPage='households')
BRAND = dict(name='赵府', subtitle='智能数字管家', motto='赵氏家宅 · 起居有序',
loginTitle='欢迎回来', loginNote='输入用户名和密码,进入家庭空间。',
footer='赵府 · 智能数字管家', logoData='', iconData='', defaults=DEFAULTS, revision=0)
TEXT_LIMITS = dict(name=40, subtitle=60, motto=100, loginTitle=40, loginNote=160, footer=120)
def png_bytes(value, maximum=256*1024):
"""Only bounded, canonical 8-bit RGB/RGBA PNGs; never accept SVG or external URLs."""
if not isinstance(value, str) or not value.startswith('data:image/png;base64,'):
raise ValueError('Logo 格式不正确,请通过上传按钮选择图片')
try:
raw = base64.b64decode(value.split(',', 1)[1], validate=True)
except (ValueError, binascii.Error):
raise ValueError('Logo 编码不正确')
if len(raw) > maximum or not raw.startswith(b'\x89PNG\r\n\x1a\n'):
raise ValueError(f'图片须为 {maximum//1024} KB 以内的 PNG 图片')
offset, header, compressed, ended = 8, None, bytearray(), False
while offset < len(raw):
if offset + 12 > len(raw):
raise ValueError('Logo 图片不完整')
size = struct.unpack('>I', raw[offset:offset+4])[0]
kind = raw[offset+4:offset+8]
if offset + size + 12 > len(raw):
raise ValueError('Logo 图片不完整')
data = raw[offset+8:offset+8+size]
crc = struct.unpack('>I', raw[offset+8+size:offset+12+size])[0]
if zlib.crc32(kind+data) != crc:
raise ValueError('Logo 图片校验失败')
if header is None:
if kind != b'IHDR' or size != 13:
raise ValueError('Logo 图片头不正确')
w, h, bits, color, compression, filter_type, interlace = struct.unpack('>IIBBBBB', data)
if not (1 <= w <= 1024 and 1 <= h <= 1024 and bits == 8 and color in (2, 6)
and compression == filter_type == interlace == 0):
raise ValueError('Logo 图片须经上传按钮转换,最长边不超过 1024 像素')
header = (w, h, 4 if color == 6 else 3)
elif kind == b'IHDR':
raise ValueError('Logo 图片头重复')
elif kind == b'IDAT':
compressed.extend(data)
elif kind == b'IEND':
ended = size == 0 and offset + 12 == len(raw)
break
elif kind[:1].isupper() and kind != b'PLTE':
raise ValueError('Logo 图片包含不支持的数据')
offset += size + 12
if not header or not ended or not compressed:
raise ValueError('Logo 图片不完整')
w, h, channels = header
expected = h * (1 + w * channels)
try:
decoder = zlib.decompressobj()
pixels = decoder.decompress(compressed, expected+1)
if not decoder.eof or decoder.unused_data or len(pixels) != expected:
raise ValueError()
if any(pixels[row*(1+w*channels)] > 4 for row in range(h)):
raise ValueError()
except (ValueError, zlib.error):
raise ValueError('Logo 图片像素数据不正确')
return raw
class Appearance:
def __init__(self, app):
self.a = app
def config(self):
return self.a.setting('appearance', copy.deepcopy(BRAND))
def public(self):
value = self.config()
return {**{k: value[k] for k in TEXT_LIMITS}, 'defaults': value['defaults'],
'revision': value['revision'],
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') else '/zhao-icon.png',
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') else '/zhao-icon.png'}
def validate_values(self, values, inherit=False):
if not isinstance(values, dict) or set(values) != set(CHOICES):
raise self.a.Problem('外观选项不完整,请重新读取设置')
if any(not isinstance(v, str) or v not in CHOICES[k]+(('default',) if inherit else ()) for k, v in values.items()):
raise self.a.Problem('外观选项不正确')
return dict(values)
def check_revision(self, data, previous):
revision = data.get('revision')
if type(revision) is not int or revision != previous.get('revision', 0):
raise self.a.Problem('设置已变化,请重新读取后保存', 409)
def save_config(self, data, actor):
a = self.a
if actor['role'] != 'admin':
raise a.Problem('发布配置仅限超级管理员修改', 403)
if set(data) - (set(TEXT_LIMITS) | {'logoData', 'iconData', 'defaults', 'revision'}):
raise a.Problem('发布配置包含未知字段')
with a.LOCK:
old = self.config()
self.check_revision(data, old)
fields = {k: a.clean_text(data.get(k, ''), limit, k in ('name', 'subtitle', 'loginTitle'))
for k, limit in TEXT_LIMITS.items()}
defaults = self.validate_values(data.get('defaults'))
logo = data.get('logoData', old.get('logoData', ''))
if not isinstance(logo, str):
raise a.Problem('Logo 格式不正确')
if logo:
try:
png_bytes(logo)
except ValueError as error:
raise a.Problem(str(error))
icon = data.get('iconData', old.get('iconData', ''))
if not isinstance(icon, str):
raise a.Problem('图标格式不正确')
if icon:
try: png_bytes(icon)
except ValueError as error: raise a.Problem(str(error))
result = dict(fields, defaults=defaults, logoData=logo, iconData=icon, revision=old['revision']+1)
a.set_setting('appearance', result)
a.audit('更新发布与品牌配置', actor['username'])
return result
def preferences(self, actor):
saved = self.a.setting('preferences:'+actor['id'], {'revision': 0, 'values': dict.fromkeys(CHOICES, 'default')})
defaults = self.config()['defaults']
effective = {k: defaults[k] if v == 'default' else v for k, v in saved['values'].items()}
return dict(saved, defaults=defaults, effective=effective)
def save_preferences(self, data, actor):
if set(data) != {'revision', 'values'}:
raise self.a.Problem('只能修改当前账号的外观偏好')
with self.a.LOCK:
old = self.preferences(actor)
self.check_revision(data, old)
values = self.validate_values(data.get('values'), inherit=True)
self.a.set_setting('preferences:'+actor['id'], dict(revision=old['revision']+1, values=values))
return self.preferences(actor)