diff --git a/docs/audits/2026-10-05-project-audit.md b/docs/audits/2026-10-05-project-audit.md new file mode 100644 index 0000000..b6091d1 --- /dev/null +++ b/docs/audits/2026-10-05-project-audit.md @@ -0,0 +1,83 @@ +# Frame Flow 项目审计 — 2026-10-05 + +审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。 +范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。 + +## 发现 + +### P1:队列扫描上限导致后续高优先级告警饥饿 + +`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过 +被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级 +告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。 + +Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10 +请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。 +建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。 + +### P2:会话失效未更新连接状态,并误清第一方登录 + +`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出 +UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据 +本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401 +都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。 + +Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。 +建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录, +在官方区域提供重新连接提示。 + +### P2:损坏的可选官方会话文件阻止整个服务启动 + +`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。 +DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会 +使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。 + +Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。 +建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。 + +### P2:持久化失败导致断开状态与重启状态不同 + +`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等 +导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token, +重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。 + +Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。 +建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。 + +### P2:Request ID 脱敏遗漏官方会话 + +`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys, +没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和 +字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。 +本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。 + +Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为 +X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。 + +## 验证 + +- pytest:154 passed,0 failed,0 warning。 +- Ruff:通过(应用和 tests)。 +- mypy:36 个源码文件通过。 +- pip-audit(requirements.lock):No known vulnerabilities found。 +- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。 +- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。 + +## 边界与下一步 + +测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。 +本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证, +然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。 +依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。 + +## 修复与复验 + +上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用 +独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供 +重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后 +才提交内存变更;Request ID 检查涵盖官方会话凭据。 + +复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件 +通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。 +已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知 +secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index a8ffe8f..dd3739c 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -147,7 +147,12 @@ def create_app(settings=None, start_worker=True): ) if any( s and s in request.state.request_id - for s in [settings.api_key, settings.api_token, *registry.keys] + for s in [ + settings.api_key, + settings.api_token, + *registry.keys, + *settings.additional_secrets, + ] ): request.state.request_id = uuid4().hex if request.method in ["POST", "PUT", "DELETE"]: diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 44a035e..9d120ca 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -217,11 +217,23 @@ class DisplayService: (device_id, now_iso()), ) + def queued_operations(self): + cursor_time, cursor_id = "", "" + while True: + rows = self.db.all( + "SELECT * FROM operations WHERE status='QUEUED' AND " + "(created_at>? OR (created_at=? AND id>?)) " + "ORDER BY created_at,id LIMIT 50", + (cursor_time, cursor_time, cursor_id), + ) + if not rows: + return + yield from rows + cursor_time, cursor_id = rows[-1]["created_at"], rows[-1]["id"] + def process_one(self): with self.lock: - for op in self.db.all( - "SELECT * FROM operations WHERE status='QUEUED' ORDER BY created_at LIMIT 50" - ): + for op in self.queued_operations(): if op["action"] not in ("render", "local-upload") and self.blocked( op["device_id"] ): diff --git a/pigway_frame_flow/errors.py b/pigway_frame_flow/errors.py index c0ad4dc..21024a5 100644 --- a/pigway_frame_flow/errors.py +++ b/pigway_frame_flow/errors.py @@ -3,6 +3,7 @@ from enum import StrEnum class ErrorCode(StrEnum): INVALID_REQUEST = "INVALID_REQUEST" + OFFICIAL_AUTH_REQUIRED = "OFFICIAL_AUTH_REQUIRED" UNAUTHORIZED = "UNAUTHORIZED" FORBIDDEN = "FORBIDDEN" DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" diff --git a/pigway_frame_flow/integrations/kokonna_official/connection.py b/pigway_frame_flow/integrations/kokonna_official/connection.py index 696bd51..18494c1 100644 --- a/pigway_frame_flow/integrations/kokonna_official/connection.py +++ b/pigway_frame_flow/integrations/kokonna_official/connection.py @@ -1,6 +1,7 @@ """Admin account connection; password transient, session persisted as a secret.""" import json +import logging import os import tempfile import threading @@ -44,14 +45,32 @@ class Connection: self.reader, self.path, self.secrets = reader, path, secrets self.username = "" self.lock = threading.RLock() - if path.exists(): - data = json.loads(path.read_text()) - reader.rpc = OfficialRPC(data["token"]) - reader._robot_id = data["robot_id"] - self.username = data["username"] - self.secrets.append(data["token"]) + try: + if path.exists(): + data = json.loads(path.read_text()) + if ( + not isinstance(data, dict) + or not isinstance(data.get("token"), str) + or not isinstance(data.get("username"), str) + or ( + data.get("robot_id") is not None + and (type(data["robot_id"]) is not int or data["robot_id"] <= 0) + ) + or "robot_id" not in data + ): + raise ValueError() + reader.rpc = OfficialRPC(data["token"]) + reader._robot_id = data["robot_id"] + self.username = data["username"] + self.secrets.append(data["token"]) + except (OSError, ValueError, KeyError, TypeError): + reader.rpc.disconnect() + reader._robot_id = None + logging.getLogger("frame-flow").warning( + "Official connection configuration unavailable; reconnect required" + ) - def _save(self): + def _save(self, token, username, robot_id): self.path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( mode="w", dir=self.path.parent, delete=False @@ -60,9 +79,9 @@ class Connection: try: json.dump( { - "token": self.reader.rpc._token, - "username": self.username, - "robot_id": self.reader._robot_id, + "token": token, + "username": username, + "robot_id": robot_id, }, f, ) @@ -73,6 +92,31 @@ class Connection: finally: temp.unlink(missing_ok=True) + def _commit(self, rpc, username, robot_id): + self._save(rpc._token, username, robot_id) + self.reader.rpc = rpc + self.reader._robot_id = robot_id + self.reader._cached = None + self.username = username + if rpc._token and rpc._token not in self.secrets: + self.secrets.append(rpc._token) + + def _query(self, call): + try: + return call() + except FlowError as error: + if error.code == E.OFFICIAL_AUTH_REQUIRED: + self.reader.rpc.disconnect() + self.reader._cached = None + self.reader._robot_id = None + try: + self._save("", self.username, None) + except OSError: + logging.getLogger("frame-flow").warning( + "Expired official connection could not be persisted" + ) + raise + def _devices(self, rpc): if self.reader.mock: return [ @@ -85,7 +129,9 @@ class Connection: ) ] try: - data = rpc.devices()["data"] + data = ( + self._query(rpc.devices) if rpc is self.reader.rpc else rpc.devices() + )["data"] return [ OfficialDevice( id=row["id"], @@ -119,12 +165,7 @@ class Connection: if not self.reader.mock: rpc.login(username, password) devices = self._devices(rpc) - self.reader.rpc = rpc - self.reader._robot_id = None - self.reader._cached = None - self.username = username - self.secrets.append(rpc._token) - self._save() + self._commit(rpc, username, None) return ConnectionState( authorized=True, username=username, @@ -143,20 +184,14 @@ class Connection: raise FlowError( E.DEVICE_NOT_FOUND, "Official device not in this account", 404 ) - self.reader._robot_id = robot_id - self.reader._cached = None - self._save() + self._commit(self.reader.rpc, self.username, robot_id) return self.state() def disconnect(self): with self.lock: - self.reader.rpc.disconnect() - self.reader._robot_id = None - self.reader._cached = None - self.username = "" - self._save() + self._commit(OfficialRPC(), "", None) return self.state() def widgets(self): with self.lock: - return self.reader.read() + return self._query(self.reader.read) diff --git a/pigway_frame_flow/integrations/kokonna_official/rpc.py b/pigway_frame_flow/integrations/kokonna_official/rpc.py index fb008f9..6db156f 100644 --- a/pigway_frame_flow/integrations/kokonna_official/rpc.py +++ b/pigway_frame_flow/integrations/kokonna_official/rpc.py @@ -42,13 +42,19 @@ class OfficialRPC: allow_redirects=False, ) if response.status_code in (401, 403): + self.disconnect() raise FlowError( - E.UNAUTHORIZED, "Official account authentication required", 401 + E.OFFICIAL_AUTH_REQUIRED, + "Official account authentication required", + 401, ) payload = response.json() error = payload.get("error") if isinstance(error, dict) and error.get("message") == "请重新登陆": - raise FlowError(E.UNAUTHORIZED, "Official account session expired", 401) + self.disconnect() + raise FlowError( + E.OFFICIAL_AUTH_REQUIRED, "Official account session expired", 401 + ) response.raise_for_status() if error or not isinstance(payload.get("result"), dict): raise ValueError() diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 53ba8c5..b1ac5b3 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -14,7 +14,7 @@ async function api(path,options={}){ const headers={...(state.csrf?{'X-CSRF-Token':state.csrf}:{}),...options.headers}; if(options.body&&!(options.body instanceof FormData)){headers['Content-Type']='application/json';options.body=JSON.stringify(options.body);} const response=await fetch(path,{...options,headers}); - if(!response.ok){if(response.status===401)forgetLogin();let error;try{error=(await response.json()).error;}catch{} + if(!response.ok){let error;try{error=(await response.json()).error;}catch{}if(response.status===401&&error?.code!=='OFFICIAL_AUTH_REQUIRED')forgetLogin(); const e=new Error((error?.code||response.status)+': '+(error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'))));e.code=error?.code;throw e;} if(response.status===204)return null; return options.binary?await response.blob():await response.json(); diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index b159e21..456515b 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -85,7 +85,7 @@ async function officialContentInfo(){ info.innerHTML=`${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}

${esc(label)}

${esc(currentLabel)}

${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}

${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}

`; const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`
${esc(x.name)}

${esc(x.tags.join(' · '))}

${esc(x.arg_keys.join(' · '))}
`).join('');}; info.querySelector('input').oninput=render;render();officialConnectionPanel(info); - }catch(e){info.querySelector('p').textContent=e.message;} + }catch(e){info.innerHTML=`${zh?'官方小组件':'Official widgets'}

${esc(e.message)}

`;officialConnectionPanel(info);} } async function officialConnectionPanel(info){ diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 22ff185..cdc7ed3 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -1,6 +1,6 @@ window.MESSAGES={ -zh:{INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'真实设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'真实设备 · 只读',writable:'真实设备 · 已授权写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除真实设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, -en:{INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} +zh:{OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'真实设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'真实设备 · 只读',writable:'真实设备 · 已授权写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除真实设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, +en:{OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} }; Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'}); diff --git a/scripts/ui-audit-regressions.cjs b/scripts/ui-audit-regressions.cjs new file mode 100644 index 0000000..e229846 --- /dev/null +++ b/scripts/ui-audit-regressions.cjs @@ -0,0 +1,3 @@ +/* Mock vendor expiration must preserve first-party login and show reconnect. */ +const assert=require('assert'),{chromium}=require('playwright'); +(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.route('**/api/v1/devices/*/widgets',r=>r.fulfill({status:401,json:{error:{code:'OFFICIAL_AUTH_REQUIRED',message:'Official account session expired',request_id:'fixture',details:{}}}}));await page.route('**/api/v1/devices/*/official/connection',r=>r.fulfill({json:{authorized:false,username:'fixture',robot_id:null,mock:true,devices:[]}}));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.locator('#official-login-form').waitFor();assert(await page.evaluate(()=>Boolean(state.token&&state.csrf)));assert(await page.evaluate(async()=>Boolean((await api('/api/v1/auth/me')).username)));await page.route('**/api/v1/test-first-party-expired',r=>r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Expired',request_id:'fixture',details:{}}}}));await page.evaluate(async()=>{try{await api('/api/v1/test-first-party-expired');}catch{}});assert(await page.evaluate(()=>state.token===''));assert(errors.length===0);await browser.close();console.log('Audit UI: official expiry preserves login, reconnect visible, own expiry clears login; PASS');})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py new file mode 100644 index 0000000..d9e7954 --- /dev/null +++ b/tests/test_audit_regressions.py @@ -0,0 +1,103 @@ +import json +from unittest.mock import Mock, patch + +import pytest + +from pigway_frame_flow.api.app import create_app +from pigway_frame_flow.errors import ErrorCode, FlowError +from pigway_frame_flow.integrations.kokonna_official.connection import Connection +from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader +from pigway_frame_flow.models import DisplayRequest + + +def enqueue(service, priority): + payload = DisplayRequest( + content={"type": "template", "template": "quote", "data": {}}, + options={"priority": priority, "duration": 3600, "restore_previous": True}, + ) + return service.submit( + "display", payload.model_dump(mode="json"), "living-room", "admin", "audit" + ) + + +def test_later_alert_is_not_starved_by_first_fifty_blocked_jobs(service): + enqueue(service, 50) + service.process_one() + for _ in range(51): + enqueue(service, 10) + high = enqueue(service, 90) + assert service.process_one() + assert service.operation(high)["status"] == "SYNCING" + assert service.registry.get("living-room").client.uploads == 2 + + +@pytest.mark.parametrize( + "content", + [ + "{broken", + "{}", + '{"token":123,"username":"user","robot_id":1}', + '{"token":"secret","username":"user","robot_id":true}', + ], +) +def test_broken_optional_secret_does_not_block_core_start(settings, content, caplog): + settings.config_dir.mkdir(parents=True) + (settings.config_dir / "official-living-room.secret").write_text(content) + app = create_app(settings, start_worker=False) + registry = app.state.service.registry + assert not registry.official["living-room"].state().authorized + assert registry.get("living-room").device().status == "online" + assert "secret" not in caplog.text + + +@pytest.mark.parametrize("action", ["disconnect", "select", "login"]) +def test_disk_failure_preserves_existing_connection(settings, action): + path = settings.config_dir / "official-test.secret" + conn = Connection(WidgetReader(mock=True), path, []) + conn.login("old-user", "not-saved") + if action != "select": + conn.select(1) + before = path.read_text() + with patch.object(conn, "_save", side_effect=OSError("fixture disk failure")): + with pytest.raises(OSError): + if action == "login": + conn.login("new-user", "not-saved") + elif action == "select": + conn.select(1) + else: + conn.disconnect() + assert conn.state().authorized + assert conn.state().username == "old-user" + assert conn.state().robot_id == (None if action == "select" else 1) + assert path.read_text() == before + assert Connection(WidgetReader(mock=True), path, []).state().authorized + + +def test_expired_session_invalidated_in_memory_and_on_disk(settings): + path = settings.config_dir / "official-test.secret" + conn = Connection(WidgetReader(), path, []) + conn._commit(conn.reader.rpc.__class__("expired-session"), "user", 1) + response = Mock(status_code=500) + response.json.return_value = {"error": {"code": -1, "message": "请重新登陆"}} + with ( + patch("requests.post", return_value=response), + pytest.raises(FlowError) as error, + ): + conn.widgets() + assert error.value.code == ErrorCode.OFFICIAL_AUTH_REQUIRED + assert not conn.state().authorized + assert json.loads(path.read_text())["token"] == "" + assert not Connection(WidgetReader(), path, []).state().authorized + + +def test_official_session_not_reflected_in_request_id(client): + client.post( + "/api/v1/devices/living-room/official/connection", + json={"username": "fixture", "password": "fixture"}, + ) + response = client.get("/health", headers={"X-Request-ID": "mock-session"}) + assert response.headers["X-Request-ID"] != "mock-session" + error = client.get( + "/api/v1/devices/missing", headers={"X-Request-ID": "mock-session"} + ) + assert error.json()["error"]["request_id"] != "mock-session" diff --git a/tests/test_official_module.py b/tests/test_official_module.py index 28d51df..8c7a2fa 100644 --- a/tests/test_official_module.py +++ b/tests/test_official_module.py @@ -52,7 +52,7 @@ def test_rpc_http_500_relogin_is_auth_error_not_cloud_failure(): pytest.raises(FlowError) as error, ): OfficialRPC("secret").devices() - assert error.value.code == ErrorCode.UNAUTHORIZED + assert error.value.code == ErrorCode.OFFICIAL_AUTH_REQUIRED assert "secret" not in str(error.value)