"""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)