From 141849d682c4c9b8e0cc750221b74d4dfd669cfc Mon Sep 17 00:00:00 2001 From: Pigway Date: Fri, 2 Oct 2026 08:43:07 +0800 Subject: [PATCH] feat: account sessions and usable template editors --- README.md | 29 +++- pigway_frame_flow/api/app.py | 15 ++ pigway_frame_flow/core/accounts.py | 178 ++++++++++++++++++++++ pigway_frame_flow/core/auth.py | 5 + pigway_frame_flow/renderers/templates.py | 33 ++++ pigway_frame_flow/templates/calendar.json | 19 ++- pigway_frame_flow/web/app.js | 35 +++-- pigway_frame_flow/web/i18n.js | 3 + scripts/create-account.py | 27 ++++ scripts/ui-smoke.cjs | 9 +- tests/contract.json | 78 ++++++++++ tests/test_accounts.py | 117 ++++++++++++++ 12 files changed, 515 insertions(+), 33 deletions(-) create mode 100644 pigway_frame_flow/core/accounts.py create mode 100644 scripts/create-account.py create mode 100644 tests/test_accounts.py diff --git a/README.md b/README.md index 19479ea..6a9f52b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ FRAME_FLOW_TOKEN. Read that token locally in an editor and enter it on the login screen. Do not paste a KoKonna API Key into Web UI. Demo always forces mock mode and disables real writes. It leaves data persistent in ./data and ./config. -打开页面后输入 `.env` 中的 FRAME_FLOW_TOKEN;Token 只留在当前页面内存,刷新后需重新输入。 +网页使用账号密码登录。通过 `python scripts/create-account.py --username YOUR_NAME` 创建账号,再启动服务;密码不会写入代码或明文配置。勾选记住登录可保持 30 天,设置页面可修改密码或退出。 ## Architecture / 架构 @@ -90,12 +90,16 @@ until the owner explicitly authorizes it.** Test design: [REAL_DEVICE_TEST.md](d All protected /api/v1 capabilities use Frame Flow Bearer auth and scope checks. SQLite stores token hashes. Device keys live only in env/secret and runtime memory. -The optional 30-day Remember login stores only the Frame Flow Token in this browser -localStorage. Disconnect, expiry or HTTP 401 clears it; shared browsers should opt out. -Cloud original-image endpoint embeds a key upstream; backend proxies it with no -redirects and no access/transport logging. Key never enters browser URLs or DB. -Request validation and exceptions use sanitized standard errors. Strong tokens, -trusted LAN and no public exposure are the intended deployment model. +Browser login uses SQLite accounts with salted scrypt password hashes and revocable, +HttpOnly/SameSite=Strict cookies (30 days when remembered, 12 hours otherwise). +Mutations require a session CSRF header. Logout and password changes revoke sessions. +Passwords and browser sessions are never stored in localStorage. Bootstrap hashes +in private `config/accounts.json` seed new accounts only; restarting does not undo +a password change. Five failed logins per source IP cause a five-minute lockout. +The browser needs no API Token. Bearer authentication remains compatible for +external clients; the KoKonna key is never used for account login. +The LAN deployment uses HTTP; use an explicitly configured HTTPS endpoint if +transport encryption is needed. No reverse proxy or firewall is changed automatically. ## API @@ -161,3 +165,14 @@ status is recorded honestly in docs/ACCEPTANCE.md. Additional drivers; scoped token administration; signed webhooks; cache retention; face-aware cropping; public weather provider; Google Calendar, Home Assistant, MQTT, NAS monitoring and AI integrations through the same API. External integration is optional. + +## Template editing + +Templates provide bilingual form fields, event lists, local photo selection, advanced +JSON, color/grayscale previews, persistent defaults and reset to built-in defaults. +Changing content invalidates the previous preview. Weather uses the labeled Mock +provider until a real provider is configured. Live display controls stay disabled +until real writes are separately approved. + +Browser smoke tests use a disposable Mock account via FRAME_FLOW_TEST_USERNAME +and FRAME_FLOW_TEST_PASSWORD; never run the mutation smoke suite against live devices. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 849ea3f..9a41165 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -14,6 +14,7 @@ from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import FileResponse, JSONResponse, Response from starlette.exceptions import HTTPException +from pigway_frame_flow.core.accounts import install_accounts, seed_accounts from pigway_frame_flow.core.auth import require, seed_client from pigway_frame_flow.core.registry import DeviceRegistry from pigway_frame_flow.display.service import DisplayService @@ -66,6 +67,7 @@ def create_app(settings=None, start_worker=True): service = DisplayService(db, settings, registry, assets, renderer) scheduler = Scheduler(service) seed_client(db, settings.api_token) + seed_accounts(db, settings.config_dir) for name in ["urllib3", "requests", "httpx", "httpcore"]: logging.getLogger(name).setLevel(logging.CRITICAL) @@ -88,6 +90,7 @@ def create_app(settings=None, start_worker=True): lifespan=lifespan, responses=error_responses, ) + install_accounts(app, db) app.state.service = service app.state.scheduler = scheduler app.add_middleware( @@ -99,6 +102,7 @@ def create_app(settings=None, start_worker=True): "Content-Type", "Idempotency-Key", "X-Request-ID", + "X-CSRF-Token", ], ) @@ -221,6 +225,7 @@ def create_app(settings=None, start_worker=True): "scheduler": scheduler.healthy() if start_worker else True, "authentication": bool( db.one("SELECT client_id FROM clients WHERE enabled=1") + or db.one("SELECT username FROM users WHERE enabled=1") ), } good = all(checks.values()) @@ -616,6 +621,16 @@ def create_app(settings=None, start_worker=True): ) return TemplateConfig(data=settings.sanitize(payload.data)) + @app.delete( + "/api/v1/templates/{template_id}/config", + status_code=204, + tags=["Templates"], + dependencies=[Depends(require("settings:write"))], + ) + def reset_template_config(template_id: str): + renderer.info(template_id) + db.execute("DELETE FROM template_config WHERE template_id=?", (template_id,)) + @app.get( "/api/v1/jobs", response_model=list[Job], diff --git a/pigway_frame_flow/core/accounts.py b/pigway_frame_flow/core/accounts.py new file mode 100644 index 0000000..8a5c847 --- /dev/null +++ b/pigway_frame_flow/core/accounts.py @@ -0,0 +1,178 @@ +"""Browser accounts: salted password hashes and revocable server-side sessions.""" + +import hashlib +import hmac +import json +import secrets +import time +from pathlib import Path + +from fastapi import Request, Response +from pydantic import BaseModel, Field + +from pigway_frame_flow.errors import ErrorCode as E +from pigway_frame_flow.errors import FlowError + +COOKIE = "frameflow_session" + + +def password_hash(password: str) -> str: + salt = secrets.token_hex(16) + digest = hashlib.scrypt( + password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 + ).hex() + return f"scrypt${salt}${digest}" + + +def verify_password(password: str, encoded: str) -> bool: + try: + _, salt, expected = encoded.split("$") + actual = hashlib.scrypt( + password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 + ).hex() + return hmac.compare_digest(actual, expected) + except (ValueError, TypeError): + return False + + +class Login(BaseModel): + username: str = Field(min_length=1, max_length=80) + password: str = Field(min_length=1, max_length=256, repr=False) + remember: bool = True + + +class AccountView(BaseModel): + username: str + csrf_token: str + + +class PasswordChange(BaseModel): + current_password: str = Field(min_length=1, max_length=256, repr=False) + new_password: str = Field(min_length=8, max_length=256, repr=False) + + +def seed_accounts(db, config_dir: Path): + db.execute( + "CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)" + ) + db.execute( + "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)" + ) + db.execute( + "CREATE TABLE IF NOT EXISTS login_attempts (identity TEXT PRIMARY KEY, failures INTEGER NOT NULL, blocked_until REAL NOT NULL)" + ) + path = config_dir / "accounts.json" + if path.exists(): + for user in json.loads(path.read_text()).get("users", []): + db.execute( + "INSERT OR IGNORE INTO users VALUES(?,?,1)", + (user["username"], user["password_hash"]), + ) + + +def session_user(request: Request): + raw = request.cookies.get(COOKIE, "") + if not raw: + return None + db = request.app.state.service.db + row = db.one( + "SELECT s.*,u.enabled FROM sessions s JOIN users u USING(username) WHERE token_hash=?", + (hashlib.sha256(raw.encode()).hexdigest(),), + ) + if not row or not row["enabled"] or row["expires_at"] <= time.time(): + return None + if request.method in {"POST", "PUT", "DELETE"}: + if not hmac.compare_digest( + request.headers.get("X-CSRF-Token", ""), row["csrf"] + ): + raise FlowError(E.FORBIDDEN, "Session CSRF validation failed", 403) + return row + + +def install_accounts(app, db): + @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) + def login(payload: Login, request: Request, response: Response): + identity = hashlib.sha256( + (request.client.host if request.client else "unknown").encode() + ).hexdigest() + attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) + if attempt and attempt["blocked_until"] > time.time(): + raise FlowError( + E.RATE_LIMITED, "Too many login attempts; try again later", 429 + ) + row = db.one( + "SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) + ) + # Perform a password calculation even for unknown usernames. + fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 + valid = verify_password( + payload.password, row["password_hash"] if row else fallback + ) + if not row or not valid: + failures = ( + attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 + ) + 1 + db.execute( + "INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", + (identity, failures, time.time() + 300 if failures >= 5 else 0), + ) + raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) + db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) + raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) + lifetime = 30 * 86400 if payload.remember else 12 * 3600 + db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),)) + old = request.cookies.get(COOKIE) + if old: + db.execute( + "DELETE FROM sessions WHERE token_hash=?", + (hashlib.sha256(old.encode()).hexdigest(),), + ) + db.execute( + "INSERT INTO sessions VALUES(?,?,?,?)", + ( + hashlib.sha256(raw.encode()).hexdigest(), + row["username"], + csrf, + time.time() + lifetime, + ), + ) + response.set_cookie( + COOKIE, + raw, + max_age=lifetime if payload.remember else None, + httponly=True, + samesite="strict", + secure=request.url.scheme == "https", + path="/", + ) + return AccountView(username=row["username"], csrf_token=csrf) + + def current(request): + row = session_user(request) + if not row: + raise FlowError(E.UNAUTHORIZED, "Account login required", 401) + return row + + @app.get("/api/v1/auth/me", response_model=AccountView, tags=["Accounts"]) + def me(request: Request): + row = current(request) + return AccountView(username=row["username"], csrf_token=row["csrf"]) + + @app.post("/api/v1/auth/logout", status_code=204, tags=["Accounts"]) + def logout(request: Request, response: Response): + row = current(request) + db.execute("DELETE FROM sessions WHERE token_hash=?", (row["token_hash"],)) + response.delete_cookie(COOKIE, path="/") + + @app.put("/api/v1/auth/password", status_code=204, tags=["Accounts"]) + def change_password(payload: PasswordChange, request: Request, response: Response): + session = current(request) + user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) + if not verify_password(payload.current_password, user["password_hash"]): + raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401) + db.execute( + "UPDATE users SET password_hash=? WHERE username=?", + (password_hash(payload.new_password), session["username"]), + ) + db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) + response.delete_cookie(COOKIE, path="/") diff --git a/pigway_frame_flow/core/auth.py b/pigway_frame_flow/core/auth.py index 6e39ad2..734be0e 100644 --- a/pigway_frame_flow/core/auth.py +++ b/pigway_frame_flow/core/auth.py @@ -5,6 +5,7 @@ import json from fastapi import Request, Security from fastapi.security import HTTPBearer +from pigway_frame_flow.core.accounts import session_user from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.storage.database import encode, now_iso @@ -40,6 +41,10 @@ def require(scope): def authorize(request: Request, credentials=Security(bearer)): db = request.app.state.service.db if not credentials: + account = session_user(request) + if account: + request.state.client_id = "user:" + account["username"] + return request.state.client_id raise FlowError(E.UNAUTHORIZED, "Frame Flow API token required", 401) digest = hashlib.sha256(credentials.credentials.encode()).hexdigest() for row in db.all("SELECT * FROM clients WHERE enabled=1"): diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py index db7115e..d24505e 100644 --- a/pigway_frame_flow/renderers/templates.py +++ b/pigway_frame_flow/renderers/templates.py @@ -1,3 +1,4 @@ +import calendar import json from datetime import datetime from io import BytesIO @@ -99,6 +100,38 @@ class TemplateRenderer: x, y, w, h = block["box"] if block["kind"] == "rule": draw.rectangle((x, y, x + w, y + h), fill=accent) + elif block["kind"] == "month": + try: + shown = datetime.strptime(str(values["date"]), "%Y-%m-%d") + except ValueError: + raise FlowError( + E.INVALID_REQUEST, "Calendar date must be YYYY-MM-DD" + ) from None + text(shown.strftime("%Y-%m"), x, y, w, 70, 48) + cells = calendar.Calendar(firstweekday=0).monthdayscalendar( + shown.year, shown.month + ) + cell_w, cell_h = w // 7, (h - 140) // len(cells) + for col, label in enumerate( + ["一 M", "二 T", "三 W", "四 T", "五 F", "六 S", "日 S"] + ): + text(label, x + col * cell_w + 10, y + 80, cell_w - 20, 60, 30) + for row, week in enumerate(cells): + for col, day in enumerate(week): + if not day: + continue + left, top = x + col * cell_w, y + 140 + row * cell_h + if (shown.year, shown.month, day) == ( + current.year, + current.month, + current.day, + ): + draw.rectangle( + (left, top, left + cell_w - 6, top + cell_h - 6), + outline=accent, + width=5, + ) + text(day, left + 25, top + 10, cell_w - 35, cell_h, 38) elif block["kind"] == "photo": asset_id = values.get("photo_id") if asset_id: diff --git a/pigway_frame_flow/templates/calendar.json b/pigway_frame_flow/templates/calendar.json index e4536d9..1fef6c2 100644 --- a/pigway_frame_flow/templates/calendar.json +++ b/pigway_frame_flow/templates/calendar.json @@ -63,26 +63,25 @@ "kind": "text" }, { - "field": "weekday", + "kind": "month", + "field": "date", "box": [ 72, - 430, + 440, 1056, - 100 - ], - "size": 42, - "kind": "text" + 600 + ] }, { + "kind": "text", "field": "events", "box": [ 72, - 600, + 1100, 1056, - 800 + 340 ], - "size": 54, - "kind": "text" + "size": 42 } ] } diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index d0849e1..50b3d2f 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -1,8 +1,7 @@ 'use strict'; const $=s=>document.querySelector(s); -const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:rememberedToken(),device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0}; -function rememberedToken(){try{const saved=JSON.parse(localStorage.getItem('ff-login')||'null');if(saved?.token&&saved.expires_at>Date.now())return saved.token;}catch{}localStorage.removeItem('ff-login');return '';} -function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.settings=null;state.devices=[];state.templates=[];} +const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0}; +function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];} async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;} const icons={dashboard:'◫',gallery:'▧',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'}; const t=k=>window.MESSAGES[state.lang][k]||k; @@ -12,7 +11,7 @@ const buttons=(id,text,primary=false)=>``;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();state.token=$('#api-token').value.trim();const remember=$('#remember-login').checked;try{await loadConnection();if(remember)localStorage.setItem('ff-login',JSON.stringify({token:state.token,expires_at:Date.now()+30*86400000}));else localStorage.removeItem('ff-login');await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});} +function login(){ $('#main').innerHTML=`
FRAME FLOW

${t('connect')}

${t('tokenHint')}

`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});} // LAN HTTP is not a secure context; getRandomValues remains available there. function clientUUID(){if(typeof crypto.randomUUID==='function')return crypto.randomUUID();const bytes=crypto.getRandomValues(new Uint8Array(16));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const hex=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;} async function accepted(path,payload){const key='web-'+clientUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;} @@ -41,19 +40,33 @@ async function waitOperation(id){for(let n=0;n<120;n++){const operation=await ap function showModal(html){$('#modal-content').innerHTML=html;$('#modal').showModal();bind('modal-cancel',()=>$('#modal').close());} async function confirmDialog(message,fn){showModal(`

${t('confirm')}

${esc(message)}

`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});} function detail(label,value){return `
${t(label)}${esc(value)}
`;} +function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;} +function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('[data-show],'+(state.tab==='cloud'?'.image-card .danger,':'')+'#show-name,#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});} +async function templateEditor(selected){ + const raw=$('#template-data');const assets=await api('/api/v1/assets');let values=JSON.parse(raw.value); + const panel=document.createElement('div');panel.id='template-fields';raw.before(panel); + const labels={title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp'),date:t('date'),weekday:state.lang==='zh'?'星期(留空自动)':'Weekday (blank for automatic)',weather:state.lang==='zh'?'天气数据(JSON,可留空使用 Provider)':'Weather JSON (blank uses provider)'}; + const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])]; + function invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');} + function build(){panel.innerHTML=`

${t('formEditor')}

`+fields.map(k=>k==='photo_id'?`
`:`
`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(k==='weather')v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});} + build();const advanced=document.createElement('details');advanced.innerHTML=`${t('advanced')}`;raw.before(advanced);advanced.append(raw);raw.oninput=()=>{invalidate();try{values=JSON.parse(raw.value);if(!values||typeof values!=='object'||Array.isArray(values))throw Error();raw.setCustomValidity('');build();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}}; + const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();})); + const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);}; +} +function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`

${t('changePassword')}

`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});} const views={ async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, - async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{await accepted('/api/v1/display',{device_id:state.device,content:{type:'image',image_name:$('#image-name').value}});$('#modal').close();});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return accepted('/api/v1/display',{device_id:state.device,content:state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id}});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+crypto.randomUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, + async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{await accepted('/api/v1/display',{device_id:state.device,content:{type:'image',image_name:$('#image-name').value}});$('#modal').close();});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return accepted('/api/v1/display',{device_id:state.device,content:state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id}});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=false;});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.preview.asset_id}}));}, - async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=false;});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.templatePreview}}));}, + async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.templatePreview}}));await templateEditor(selected);}, async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${fmt(o.created_at)}${esc(o.action)}
${esc(o.device_id)}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}${esc(o.client_id)}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, - async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',()=>{forgetLogin();shell();login();});} + async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} }; function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,...changes};} -function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});} +function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});} function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`

${t('playlists')}

`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});} $('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); -applyTheme();(async()=>{if(state.token){try{await loadConnection();}catch(error){forgetLogin();toast(error.message);}}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`Pigway Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); +applyTheme();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`Pigway Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000); diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 0a7472b..26bd8c0 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -2,3 +2,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'} }; + +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:'保存模板设置'}); +Object.assign(window.MESSAGES.en,{username:'Username',password:'Password',connect:'Sign in to Frame Flow',UNAUTHORIZED:'Invalid credentials or expired session',tokenHint:'Sign in with your account. Remember me keeps the session for 30 days; sign out on shared devices.',logout:'Sign out',noToken:'Sign in to manage frames',formEditor:'Form editor',advanced:'Advanced JSON',resetTemplate:'Reset template',resetConfirm:'Remove saved settings and restore built-in defaults?',eventsHelp:'One event per line',photo:'Family photo',noPhoto:'Use photo placeholder',changePassword:'Change password',currentPassword:'Current password',newPassword:'New password (at least 8 characters)',passwordChanged:'Password changed. Sign in again.',saveTemplate:'Save template settings'}); diff --git a/scripts/create-account.py b/scripts/create-account.py new file mode 100644 index 0000000..165e273 --- /dev/null +++ b/scripts/create-account.py @@ -0,0 +1,27 @@ +"""Create private bootstrap account hashes without storing a password.""" + +import argparse +import getpass +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from pigway_frame_flow.core.accounts import password_hash # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument("--username", required=True) +parser.add_argument("--config-dir", type=Path, default=Path("config")) +args = parser.parse_args() +password = getpass.getpass("Password (minimum 8 characters): ") +if len(password) < 8 or password != getpass.getpass("Confirm password: "): + sys.exit("Password too short or confirmation mismatch") +args.config_dir.mkdir(parents=True, exist_ok=True) +path = args.config_dir / "accounts.json" +users = json.loads(path.read_text()).get("users", []) if path.exists() else [] +if any(u["username"] == args.username for u in users): + sys.exit("Bootstrap user already exists. Change the password in Settings.") +users.append({"username": args.username, "password_hash": password_hash(password)}) +path.write_text(json.dumps({"users": users}) + "\n") +path.chmod(0o600) +print("Bootstrap hash saved. Restart the service to seed the account.") diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index f93b804..63369f7 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -5,7 +5,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode'); const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); const context=await browser.newContext({viewport:{width:1440,height:1000}});const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0; - await page.addInitScript(()=>Object.defineProperty(window.crypto,'randomUUID',{value:undefined,configurable:true}));await page.goto(base);await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++; + await page.addInitScript(()=>Object.defineProperty(window.crypto,'randomUUID',{value:undefined,configurable:true}));await page.goto(base);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.locator('h2').first().innerText()==='客厅相框');checks++; const shots=process.env.FRAME_FLOW_SCREENSHOT_DIR||path.join(root,'docs/screenshots');fs.mkdirSync(shots,{recursive:true});await page.screenshot({path:path.join(shots,'dashboard-light-zh.png'),fullPage:true}); await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++; await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++; @@ -18,7 +18,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++; await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===6);checks++; await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++; - await page.locator('#display-template').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++; + await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++; await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++; await page.locator('[data-job-toggle]').first().click();await page.getByText('Paused',{exact:true}).first().waitFor();checks++; await page.locator('[data-job-delete]').first().click();await page.locator('#modal-confirm').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor({state:'hidden'});checks++; @@ -31,9 +31,8 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await page.reload();await page.locator('.hero').waitFor();checks++; const reopened=await page.context().newPage();await reopened.goto(base);await reopened.locator('.hero').waitFor();await reopened.close();checks++; await page.locator('[data-page="settings"]').click();await page.locator('#logout').click();await page.locator('#login-form').waitFor();assert(await page.evaluate(()=>localStorage.getItem('ff-login')===null));checks++; - await page.locator('#remember-login').uncheck();await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('#logout').waitFor();await page.reload();await page.locator('#login-form').waitFor();checks++; - await page.evaluate(token=>localStorage.setItem('ff-login',JSON.stringify({token,expires_at:Date.now()-1})),token);await page.reload();await page.locator('#login-form').waitFor();assert(await page.evaluate(()=>localStorage.getItem('ff-login')===null));checks++; - await page.evaluate(()=>localStorage.setItem('ff-login',JSON.stringify({token:'test-invalid-frame-flow-token',expires_at:Date.now()+60000})));await page.reload();await page.locator('#login-form').waitFor();assert(await page.evaluate(()=>localStorage.getItem('ff-login')===null));checks++; + await page.locator('#remember-login').uncheck();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('#logout').waitFor();await page.reload();await page.locator('#logout').waitFor();assert((await context.cookies()).find(c=>c.name==='frameflow_session').expires===-1);checks++; + assert(await page.evaluate(()=>!document.cookie.includes('frameflow_session')));checks++; assert(errors.length===0,JSON.stringify(errors));checks++; await browser.close();console.log('Browser smoke:',checks,'passed; page errors:',errors.length); })().catch(error=>{console.error(error.message);process.exit(1)}); diff --git a/tests/contract.json b/tests/contract.json index 0c0a5f0..a0443f4 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -1,4 +1,68 @@ { + "/api/v1/auth/login": { + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/auth/me": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/auth/logout": { + "post": [ + "204", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/auth/password": { + "put": [ + "204", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, "/health": { "get": [ "200", @@ -379,6 +443,20 @@ "500", "502", "503" + ], + "delete": [ + "204", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" ] }, "/api/v1/jobs": { diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..d1c008c --- /dev/null +++ b/tests/test_accounts.py @@ -0,0 +1,117 @@ +import hashlib +import time + +from pigway_frame_flow.core.accounts import password_hash + + +def account(app): + app.state.service.db.execute( + "INSERT INTO users VALUES(?,?,1)", + ("test-user", password_hash("fixture-password")), + ) + + +def login(client, app, remember=True): + account(app) + client.headers.pop("Authorization", None) + return client.post( + "/api/v1/auth/login", + json={ + "username": "test-user", + "password": "fixture-password", + "remember": remember, + }, + ) + + +def test_session_login_and_csrf(client, app): + response = login(client, app) + assert response.status_code == 200 + assert "HttpOnly" in response.headers["set-cookie"] + assert "SameSite=strict" in response.headers["set-cookie"] + assert client.get("/api/v1/devices").status_code == 200 + assert client.post("/api/v1/jobs", json={}).status_code == 403 + assert client.get("/api/v1/auth/me").json()["username"] == "test-user" + client.headers["X-CSRF-Token"] = response.json()["csrf_token"] + assert client.post("/api/v1/auth/logout").status_code == 204 + assert client.get("/api/v1/devices").status_code == 401 + + +def test_password_change_revokes_sessions(client, app): + response = login(client, app) + client.headers["X-CSRF-Token"] = response.json()["csrf_token"] + assert ( + client.put( + "/api/v1/auth/password", + json={"current_password": "wrong", "new_password": "replacement-password"}, + ).status_code + == 401 + ) + assert ( + client.put( + "/api/v1/auth/password", + json={ + "current_password": "fixture-password", + "new_password": "replacement-password", + }, + ).status_code + == 204 + ) + assert client.get("/api/v1/auth/me").status_code == 401 + assert ( + client.post( + "/api/v1/auth/login", + json={"username": "test-user", "password": "replacement-password"}, + ).status_code + == 200 + ) + + +def test_login_rate_limit(client, app): + account(app) + for _ in range(5): + assert ( + client.post( + "/api/v1/auth/login", + json={"username": "test-user", "password": "wrong"}, + ).status_code + == 401 + ) + assert ( + client.post( + "/api/v1/auth/login", + json={"username": "test-user", "password": "fixture-password"}, + ).status_code + == 429 + ) + + +def test_session_expiry_and_no_plaintext(client, app): + response = login(client, app, False) + assert "Max-Age" not in response.headers["set-cookie"] + raw = client.cookies.get("frameflow_session") + row = app.state.service.db.one("SELECT * FROM sessions") + assert row["token_hash"] == hashlib.sha256(raw.encode()).hexdigest() + app.state.service.db.execute("UPDATE sessions SET expires_at=?", (time.time() - 1,)) + assert client.get("/api/v1/auth/me").status_code == 401 + assert "fixture-password" not in str( + app.state.service.db.one("SELECT * FROM users") + ) + + +def test_reset_template(client): + assert ( + client.put( + "/api/v1/templates/quote/config", json={"data": {"title": "Saved title"}} + ).status_code + == 200 + ) + assert ( + client.get("/api/v1/templates/quote").json()["defaults"]["title"] + == "Saved title" + ) + assert client.delete("/api/v1/templates/quote/config").status_code == 204 + assert ( + client.get("/api/v1/templates/quote").json()["defaults"]["title"] + != "Saved title" + )