diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 8b30700..ee34795 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -891,7 +891,7 @@ def create_app(settings=None, start_worker=True): @app.get("/docs", include_in_schema=False) def api_docs(): - return get_swagger_ui_html( + response = get_swagger_ui_html( openapi_url="/openapi.json", title="Pigway Frame Flow · API Docs", swagger_js_url="/ui/swagger-ui-bundle.js", @@ -902,6 +902,24 @@ def create_app(settings=None, start_worker=True): "displayRequestDuration": True, }, ) + html = ( + bytes(response.body) + .decode() + .replace( + "SwaggerUIBundle({", + """SwaggerUIBundle({ + requestInterceptor: async (req) => { + if (!['GET','HEAD'].includes(req.method.toUpperCase())) { + const session = await fetch('/api/v1/auth/me'); + if (session.ok) req.headers['X-CSRF-Token'] = (await session.json()).csrf_token; + } + return req; + },""", + ) + ) + from fastapi.responses import HTMLResponse + + return HTMLResponse(html) root = Path(__file__).resolve().parent.parent / "web" diff --git a/pigway_frame_flow/core/accounts.py b/pigway_frame_flow/core/accounts.py index 8a5c847..52ec692 100644 --- a/pigway_frame_flow/core/accounts.py +++ b/pigway_frame_flow/core/accounts.py @@ -7,13 +7,15 @@ import secrets import time from pathlib import Path -from fastapi import Request, Response +from fastapi import Request, Response, Security +from fastapi.security import APIKeyCookie from pydantic import BaseModel, Field from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError COOKIE = "frameflow_session" +cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession") def password_hash(password: str) -> str: @@ -153,18 +155,33 @@ def install_accounts(app, db): raise FlowError(E.UNAUTHORIZED, "Account login required", 401) return row - @app.get("/api/v1/auth/me", response_model=AccountView, tags=["Accounts"]) + @app.get( + "/api/v1/auth/me", + response_model=AccountView, + tags=["Accounts"], + dependencies=[Security(cookie)], + ) 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"]) + @app.post( + "/api/v1/auth/logout", + status_code=204, + tags=["Accounts"], + dependencies=[Security(cookie)], + ) 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"]) + @app.put( + "/api/v1/auth/password", + status_code=204, + tags=["Accounts"], + dependencies=[Security(cookie)], + ) def change_password(payload: PasswordChange, request: Request, response: Response): session = current(request) user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) diff --git a/pigway_frame_flow/core/auth.py b/pigway_frame_flow/core/auth.py index 734be0e..e750e42 100644 --- a/pigway_frame_flow/core/auth.py +++ b/pigway_frame_flow/core/auth.py @@ -5,7 +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.core.accounts import cookie, 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 @@ -38,7 +38,9 @@ def seed_client(db, token): def require(scope): - def authorize(request: Request, credentials=Security(bearer)): + def authorize( + request: Request, credentials=Security(bearer), session_cookie=Security(cookie) + ): db = request.app.state.service.db if not credentials: account = session_user(request) diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py index ca62ee9..2a1cd9a 100644 --- a/pigway_frame_flow/renderers/templates.py +++ b/pigway_frame_flow/renderers/templates.py @@ -71,6 +71,13 @@ class TemplateRenderer: ) if "weather" not in values: values["weather"] = self.weather.get_current() + if ( + any(b.get("field") == "forecast" for b in spec["blocks"]) + and "forecast" not in values + ): + values["forecast"] = self.weather.get_forecast() or [ + "尚无天气预报 / No forecast available" + ] values["updated"] = current.strftime("%Y-%m-%d %H:%M") values["weather_attribution"] = ( values.get("weather", {}).get("attribution", "") @@ -164,7 +171,15 @@ class TemplateRenderer: if isinstance(value, list): value = "\n".join( "• " - + (str(v.get("title", "")) if isinstance(v, dict) else str(v)) + + ( + ( + f"{v.get('date', '')} · {v.get('summary', '')} · {v.get('minimum_c', '--')}–{v.get('maximum_c', '--')}°C · {v.get('rain_probability', '--')}%" + if block.get("field") == "forecast" + else str(v.get("title", "")) + ) + if isinstance(v, dict) + else str(v) + ) for v in value[:10] ) if isinstance(value, dict): diff --git a/pigway_frame_flow/templates/weather.json b/pigway_frame_flow/templates/weather.json index 7136f5c..ad94e08 100644 --- a/pigway_frame_flow/templates/weather.json +++ b/pigway_frame_flow/templates/weather.json @@ -9,7 +9,8 @@ "photo_id", "title", "weather", - "weekday" + "weekday", + "forecast" ], "defaults": { "title": "天气 / Weather" diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 7a65e12..5a47090 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -48,7 +48,7 @@ async function templateEditor(selected){ 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();}});} + 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(['weather','forecast'].includes(k))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);}; diff --git a/tests/test_accounts.py b/tests/test_accounts.py index d1c008c..f876918 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -115,3 +115,13 @@ def test_reset_template(client): client.get("/api/v1/templates/quote").json()["defaults"]["title"] != "Saved title" ) + + +def test_openapi_describes_cookie_auth_and_docs_csrf(client): + schema = client.get("/openapi.json").json() + cookie = schema["components"]["securitySchemes"]["AccountSession"] + assert cookie["in"] == "cookie" and cookie["name"] == "frameflow_session" + assert {"AccountSession": []} in schema["paths"]["/api/v1/devices"]["get"][ + "security" + ] + assert "X-CSRF-Token" in client.get("/docs").text diff --git a/tests/test_weather.py b/tests/test_weather.py index d9b7b22..89e215f 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -125,3 +125,13 @@ def test_image_url_allowlist_is_persistent_and_restrictive(client, service): ).json()["allowed_hosts"] == ["example.com"] assert service.settings.image_url_hosts == ["example.com"] assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'") + + +def test_weather_template_resolves_forecast(service): + provider = ConfigurableWeatherProvider(service.db, transport()) + provider.configure(WeatherConfig(provider="open-meteo")) + service.renderer.weather = provider + snapshot = service.renderer.resolve("weather", {}) + assert snapshot["forecast"][0]["maximum_c"] == 26 + assert snapshot["weather_attribution"] == "Open-Meteo / CC BY 4.0" + assert service.renderer.render("weather", snapshot)