diff --git a/README.md b/README.md index 324907c..a298a49 100644 --- a/README.md +++ b/README.md @@ -213,3 +213,20 @@ compatible; new endpoints are included in generated OpenAPI and contract tests. The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings. 每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。 + + +### 官方账号连接 / Official account connection + +在 Templates → 官方小组件中,由管理员输入 KoKonna 官方账号和密码,查询账号下 +的设备后手动关联当前 Frame Flow 设备。此流程不绑定或解绑云端设备,也不改变 +图片或订阅。现有 API Key 接入继续负责设备、图库和显示能力。 + +密码不保存;会话与关联信息保存在 `/config/official-{device_id}.secret`,权限 0600。 +容器重启可恢复连接。会话过期需要重新登录;断开连接会清除本地会话,不撤销 +官方服务器上的其他登录。局域网访问沿用现有部署方式。 + +Official account connection is admin-only and separate from API Key access. +Login, device discovery, local association and disconnect are available through +`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted; +server-side sessions are stored as restricted secret files. No official widget +mutation or other extra RPC write is enabled by connecting. diff --git a/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md b/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md index 4b75879..88e715b 100644 --- a/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md +++ b/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md @@ -51,8 +51,8 @@ reset, firmware, payment or subscription mutation was executed during research. - API Key device/gallery/upload/display support remains unchanged. - Official widget panel separately explains account authorization requirements. -- The existing WidgetReader uses a server-side official session secret; direct - account/password connection UI and automatic session renewal are not implemented. +- The existing WidgetReader uses a server-side official session secret; admin + account/password connection UI is implemented; automatic session renewal is not. - Live `NO_WIDGET` requires explicit null current-widget association and null robot association ID; missing fields remain `CURRENT_UNVERIFIED`. - Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 89f19c3..a8ffe8f 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -20,6 +20,11 @@ from pigway_frame_flow.core.registry import DeviceRegistry from pigway_frame_flow.display.service import DisplayService from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.integrations.kokonna_official.connection import ( + ConnectionState, + LoginRequest, + SelectRequest, +) from pigway_frame_flow.models import ( Accepted, Asset, @@ -753,6 +758,48 @@ def create_app(settings=None, start_worker=True): weather_provider=weather.config().provider, ) + def official_connection(device_id): + registry.get(device_id) + return registry.official[device_id] + + @app.get( + "/api/v1/devices/{device_id}/official/connection", + response_model=ConnectionState, + tags=["Official"], + dependencies=[Depends(require("admin"))], + ) + def connection_state(device_id: str, discover: bool = False): + return official_connection(device_id).state(discover) + + @app.post( + "/api/v1/devices/{device_id}/official/connection", + response_model=ConnectionState, + tags=["Official"], + dependencies=[Depends(require("admin"))], + ) + def connection_login(device_id: str, body: LoginRequest): + return official_connection(device_id).login( + body.username, body.password.get_secret_value() + ) + + @app.put( + "/api/v1/devices/{device_id}/official/connection", + response_model=ConnectionState, + tags=["Official"], + dependencies=[Depends(require("admin"))], + ) + def connection_select(device_id: str, body: SelectRequest): + return official_connection(device_id).select(body.robot_id) + + @app.delete( + "/api/v1/devices/{device_id}/official/connection", + response_model=ConnectionState, + tags=["Official"], + dependencies=[Depends(require("admin"))], + ) + def connection_disconnect(device_id: str): + return official_connection(device_id).disconnect() + @app.get( "/api/v1/devices/{device_id}/widgets", response_model=__import__( diff --git a/pigway_frame_flow/core/registry.py b/pigway_frame_flow/core/registry.py index fcb4dde..fa04c5b 100644 --- a/pigway_frame_flow/core/registry.py +++ b/pigway_frame_flow/core/registry.py @@ -6,6 +6,7 @@ from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver from pigway_frame_flow.drivers.kokonna.mock import MockClient from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import 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.settings import secret @@ -26,7 +27,7 @@ class DeviceRegistry: } ) self.drivers: dict[str, FrameDeviceDriver] = {} - self.official: dict[str, WidgetReader] = {} + self.official: dict[str, Connection] = {} self.keys = [] for id, entry in devices.items(): if not entry.get("enabled", True): @@ -56,7 +57,11 @@ class DeviceRegistry: widgets = WidgetReader( widget_token, entry.get("widget_robot_id"), settings.mock ) - self.official[id] = widgets + self.official[id] = Connection( + widgets, + settings.config_dir / f"official-{id}.secret", + settings.additional_secrets, + ) self.drivers[id] = KokonnaDriver(client, id, settings.sanitize) def get(self, id: str) -> FrameDeviceDriver: @@ -66,4 +71,4 @@ class DeviceRegistry: def official_widgets(self, id: str): self.get(id) - return self.official[id].read() + return self.official[id].widgets() diff --git a/pigway_frame_flow/integrations/kokonna_official/README.md b/pigway_frame_flow/integrations/kokonna_official/README.md index 23f31ba..04d22bb 100644 --- a/pigway_frame_flow/integrations/kokonna_official/README.md +++ b/pigway_frame_flow/integrations/kokonna_official/README.md @@ -13,11 +13,19 @@ FrameDeviceDriver protocol does not depend on account login or widgets. The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old `drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export. -No raw RPC proxy or public password-login endpoint is introduced. Future admin -account connection UI must use this module and keep credentials server-side. -No account or session secret is automatically persisted by this module. +No raw RPC proxy is exposed. The admin-only `/official/connection` endpoint +provides login (POST), state/discovery (GET), local frame association (PUT), and +local disconnect (DELETE), under `/api/v1/devices/{device_id}`. Browser requests +use the existing Frame Flow session and CSRF protection; API clients require admin. + +`connection.py` persists the official session and selected robot in +`/config/official-{device_id}.secret`, atomically with mode 0600. Passwords are +transient and never stored. These files must not be committed or exposed to users. +Disconnect clears the local secret, including on restart; it does not revoke +remote official account sessions. Expired sessions require logging in again. +No login refresh or stored-password automatic retry is performed. 设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录, 不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。 -账号登录已提供内部客户端能力;页面连接入口、写操作和会话持久化尚未实现。 +账号连接页面与会话持久化已实现;额外写操作尚未实现。 所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。 diff --git a/pigway_frame_flow/integrations/kokonna_official/connection.py b/pigway_frame_flow/integrations/kokonna_official/connection.py new file mode 100644 index 0000000..696bd51 --- /dev/null +++ b/pigway_frame_flow/integrations/kokonna_official/connection.py @@ -0,0 +1,162 @@ +"""Admin account connection; password transient, session persisted as a secret.""" + +import json +import os +import tempfile +import threading +from pathlib import Path + +from pydantic import Field, SecretStr + +from pigway_frame_flow.errors import ErrorCode as E +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC +from pigway_frame_flow.models import StrictModel + + +class LoginRequest(StrictModel): + username: str = Field(min_length=1, max_length=120) + password: SecretStr = Field(min_length=1, max_length=256) + + +class SelectRequest(StrictModel): + robot_id: int = Field(gt=0) + + +class OfficialDevice(StrictModel): + id: int + name: str + model: str | None = None + model_code: str | None = None + online: bool = False + + +class ConnectionState(StrictModel): + authorized: bool + username: str + robot_id: int | None + mock: bool + devices: list[OfficialDevice] = Field(default_factory=list) + + +class Connection: + def __init__(self, reader, path: Path, secrets: list): + 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"]) + + def _save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", dir=self.path.parent, delete=False + ) as f: + temp = Path(f.name) + try: + json.dump( + { + "token": self.reader.rpc._token, + "username": self.username, + "robot_id": self.reader._robot_id, + }, + f, + ) + f.flush() + os.fsync(f.fileno()) + temp.chmod(0o600) + os.replace(temp, self.path) + finally: + temp.unlink(missing_ok=True) + + def _devices(self, rpc): + if self.reader.mock: + return [ + OfficialDevice( + id=1, + name="Mock Frame", + model="13.3-inch", + model_code="B101", + online=True, + ) + ] + try: + data = rpc.devices()["data"] + return [ + OfficialDevice( + id=row["id"], + name=row.get("robotNickname") or "Frame", + model=(row.get("model") or {}).get("name"), + model_code=(row.get("model") or {}).get("code"), + online=bool(row.get("online")), + ) + for row in data + ] + except (KeyError, TypeError, ValueError, AttributeError): + raise FlowError( + E.CLOUD_UNAVAILABLE, "Official device list unavailable", 502 + ) from None + + def state(self, discovery=False): + with self.lock: + return ConnectionState( + authorized=self.reader.rpc.authorized, + username=self.username, + robot_id=self.reader._robot_id, + mock=self.reader.mock, + devices=self._devices(self.reader.rpc) + if discovery and self.reader.rpc.authorized + else [], + ) + + def login(self, username, password): + with self.lock: + rpc = OfficialRPC("mock-session" if self.reader.mock else "") + 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() + return ConnectionState( + authorized=True, + username=username, + robot_id=None, + mock=self.reader.mock, + devices=devices, + ) + + def select(self, robot_id): + with self.lock: + if not self.reader.rpc.authorized: + raise FlowError( + E.CONFIG_REQUIRED, "Official account authorization required", 503 + ) + if not any(d.id == robot_id for d in self._devices(self.reader.rpc)): + 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() + 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() + return self.state() + + def widgets(self): + with self.lock: + return self.reader.read() diff --git a/pigway_frame_flow/integrations/kokonna_official/widgets.py b/pigway_frame_flow/integrations/kokonna_official/widgets.py index 56f5d56..3ccf33d 100644 --- a/pigway_frame_flow/integrations/kokonna_official/widgets.py +++ b/pigway_frame_flow/integrations/kokonna_official/widgets.py @@ -46,7 +46,7 @@ class WidgetReader: configured=False, catalog=catalog, observed_at=snapshot["observed_at"], - status="AUTH_REQUIRED", + status="DEVICE_REQUIRED" if self.rpc.authorized else "AUTH_REQUIRED", ) try: data = self.rpc.widgets(self._robot_id) diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 9791a92..b159e21 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -81,9 +81,24 @@ async function officialContentInfo(){ try { const data=await api('/api/v1/devices/'+state.device+'/widgets'); const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at); - const currentLabel=!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified'); + const currentLabel=data.status==='DEVICE_REQUIRED'?(zh?'官方账号已连接,请先关联画框':'Official account connected; link a frame first'):!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified'); 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(); + info.querySelector('input').oninput=render;render();officialConnectionPanel(info); }catch(e){info.querySelector('p').textContent=e.message;} } + +async function officialConnectionPanel(info){ + const zh=state.lang==='zh',path='/api/v1/devices/'+state.device+'/official/connection'; + const panel=document.createElement('section');panel.className='official-account-panel';info.querySelector('.widget-auth-notice').after(panel); + const render=connection=>{ + panel.innerHTML=connection.authorized?`

${zh?'官方账号已连接':'Official account connected'}

${esc(connection.username)}

${connection.robot_id?(zh?'已关联画框':'Frame linked'):(zh?'请选择并关联你的画框':'Choose and link your frame')}

`:`

${zh?'连接 KoKonna 官方账号':'Connect KoKonna official account'}

${zh?'此处不是 Frame Flow 或 NAS 账号。密码不保存,登录会话会保存为 NAS 上受限的 secret 文件。连接仅查询,不切换小组件。':'Use your KoKonna account, not Frame Flow or NAS credentials. Passwords are not saved; the session is stored in a restricted NAS secret file. Connecting only queries data.'}

`; + if(connection.authorized){ + $('#official-disconnect').onclick=busy(async()=>{render(await api(path,{method:'DELETE'}));toast(zh?'已断开官方连接':'Official connection disconnected');}); + const picker=devices=>{const box=$('#official-device-picker');box.innerHTML=devices.length?`
`:`

${zh?'账号下没有可关联设备':'No devices found in this account'}

`;if(devices.length)$('#official-select-form').onsubmit=busy(async e=>{e.preventDefault();await api(path,{method:'PUT',body:{robot_id:Number($('#official-device').value)}});await navigate('templates');});}; + $('#official-discover').onclick=busy(async()=>{picker((await api(path+'?discover=true')).devices);}); + if(connection.devices.length)picker(connection.devices); + }else $('#official-login-form').onsubmit=busy(async e=>{e.preventDefault();const username=$('#official-username').value.trim(),password=$('#official-password').value;$('#official-password').value='';render(await api(path,{method:'POST',body:{username,password}}));}); + }; + try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';} +} diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 41e28c7..a65f008 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -44,3 +44,5 @@ details[open]>summary{margin-bottom:16px} /* Multiple controls in one field need their own spacing, beyond field margins. */ .field > :is(input,select,textarea) + :is(input,select,textarea){margin-top:12px} input[type="file"]::file-selector-button{margin-inline-end:12px} + +.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px} diff --git a/scripts/ui-official-connection.cjs b/scripts/ui-official-connection.cjs new file mode 100644 index 0000000..fd9ac36 --- /dev/null +++ b/scripts/ui-official-connection.cjs @@ -0,0 +1,3 @@ +/* Local Mock only. No real official account or frame writes. */ +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=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});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.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/tests/contract.json b/tests/contract.json index e064d5a..5b136eb 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -782,5 +782,63 @@ "502", "503" ] + }, + "/api/v1/devices/{device_id}/official/connection": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "delete": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] } } diff --git a/tests/test_official_connection.py b/tests/test_official_connection.py new file mode 100644 index 0000000..67f02ab --- /dev/null +++ b/tests/test_official_connection.py @@ -0,0 +1,71 @@ +import json +import stat +from unittest.mock import Mock, patch + +from pigway_frame_flow.api.app import create_app + +PATH = "/api/v1/devices/living-room/official/connection" + + +def test_connect_select_persist_and_disconnect(client, settings): + response = client.post( + PATH, json={"username": "official-user", "password": "not-saved"} + ) + assert response.status_code == 200 + assert response.json()["robot_id"] is None + assert response.json()["devices"][0]["id"] == 1 + assert "token" not in response.text and "not-saved" not in response.text + path = settings.config_dir / "official-living-room.secret" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert "not-saved" not in path.read_text() + assert client.put(PATH, json={"robot_id": 1}).status_code == 200 + assert client.put(PATH, json={"robot_id": 999}).status_code == 404 + restored = create_app(settings, start_worker=False).state.service.registry + assert restored.official["living-room"].state().robot_id == 1 + assert client.delete(PATH).status_code == 200 + assert json.loads(path.read_text())["token"] == "" + assert not client.get(PATH).json()["authorized"] + + +def test_auth_and_validation_hide_password(client): + response = client.post(PATH, json={"username": "", "password": "secret-password"}) + assert response.status_code == 422 and "secret-password" not in response.text + client.headers.clear() + assert ( + client.post(PATH, json={"username": "user", "password": "secret"}).status_code + == 401 + ) + + +def test_live_connection_only_login_and_read_methods(settings): + from pigway_frame_flow.integrations.kokonna_official.connection import Connection + from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader + + login = Mock(status_code=200) + login.json.return_value = {"result": {"token": "official-secret"}} + devices = Mock(status_code=200) + devices.json.return_value = { + "result": { + "data": [ + { + "id": 10, + "robotNickname": "Frame", + "model": {"code": "B101", "name": "13.3-inch"}, + } + ] + } + } + conn = Connection( + WidgetReader(), + settings.config_dir / "official-test.secret", + settings.additional_secrets, + ) + with patch("requests.post", side_effect=[login, devices, devices]) as post: + conn.login("user", "password") + conn.select(10) + assert [c.kwargs["json"]["method"] for c in post.call_args_list] == [ + "app.login", + "app.getRobots", + "app.getRobots", + ] + assert "official-secret" not in conn.state().model_dump_json()