Fix audit findings in queue fairness and official account lifecycle
This commit is contained in:
@@ -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"]:
|
||||
|
||||
@@ -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"]
|
||||
):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -85,7 +85,7 @@ async function officialContentInfo(){
|
||||
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
|
||||
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=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
|
||||
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);
|
||||
}catch(e){info.querySelector('p').textContent=e.message;}
|
||||
}catch(e){info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p class="notice widget-auth-notice">${esc(e.message)}</p>`;officialConnectionPanel(info);}
|
||||
}
|
||||
|
||||
async function officialConnectionPanel(info){
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user