Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,65 @@ pub async fn delete_theme(_: Admin, State(app): State<Shared>, Path(short): Path
}
}

/// Where a theme's saved settings live. Keyed by `short` in the database rather
/// than stored beside the theme, so updating, reinstalling or deleting the
/// theme leaves them in place, and a backup carries them.
fn theme_config_key(short: &str) -> String {
format!("theme_config:{short}")
}

/// The settings saved for one theme in the panel: only the fields changed from
/// the defaults its `theme.json` declares, which the theme fills in itself.
/// Any theme may be named, installed or not, so a theme under development
/// reads its own settings from whichever hub it proxies to.
///
/// Anonymous, under the same condition as `/api/nodes`. One request is one
/// primary-key read answering at most 64 KiB, the router's body limit having
/// bounded the write. That is the cost class of `/api/me`, so there is no gate:
/// on a three-core hub (debug build), 120 concurrent requests for a 60 KiB
/// value held the panel's `/api/nodes` at a 230 ms median, against 160 ms
/// under the same load on `/api/me`.
///
/// A failed read answers 500 rather than `{}`: the panel saves on top of what
/// it reads, so an empty answer would erase every saved override.
pub async fn theme_config(
State(app): State<Shared>,
headers: HeaderMap,
Path(short): Path<String>,
) -> Response {
if !authed(&app, &headers) && !app.public_page() {
return (StatusCode::UNAUTHORIZED, "sign-in required").into_response();
}
if !crate::frontend::valid_short(&short) {
return StatusCode::NOT_FOUND.into_response();
}
match app.db.lookup(&theme_config_key(&short)) {
Ok(saved) => {
let saved = saved.unwrap_or_else(|| "{}".into());
([(header::CONTENT_TYPE, "application/json")], saved).into_response()
}
Err(e) => fail(e),
}
}

/// Replaces a theme's saved settings. Values are not checked against the
/// theme's declared fields: the theme must validate what it reads regardless,
/// since a value saved under one version of the theme meets the next.
pub async fn save_theme_config(
_: Admin,
State(app): State<Shared>,
Path(short): Path<String>,
Json(values): Json<serde_json::Map<String, Value>>,
) -> Response {
if !crate::frontend::valid_short(&short) || !crate::frontend::selectable(&app, &short) {
return bad("theme is not installed");
}
match app.db.set(&theme_config_key(&short), &Value::Object(values).to_string()) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => fail(e),
}
}

pub async fn themes(_: Admin, State(app): State<Shared>) -> Response {
match crate::frontend::themes(&app) {
Ok(themes) => Json(json!({"themes": themes})).into_response(),
Expand Down Expand Up @@ -2242,6 +2301,34 @@ mod tests {
assert!(app.agents.read().unwrap().is_empty(), "the node must read as offline at once");
}

/// What the panel saves is what an anonymous visitor reads, under the same
/// condition as the node list, and only an installed theme takes a write.
#[tokio::test]
async fn saved_theme_settings_reach_visitors_while_the_status_page_is_open() {
let app = std::sync::Arc::new(app());
let state = || axum::extract::State(app.clone());
let read = |short: &str| theme_config(state(), HeaderMap::new(), Path(short.to_owned()));
let body = |r: Response| async { axum::body::to_bytes(r.into_body(), usize::MAX).await.unwrap() };

assert_eq!(&body(read("default").await).await[..], b"{}", "nothing saved reads as no overrides");
let values = json!({"notice": "维护中", "show_price": false}).as_object().unwrap().clone();
let saved = save_theme_config(Admin, state(), Path("default".into()), Json(values.clone())).await;
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
let read_back: Value = serde_json::from_slice(&body(read("default").await).await).unwrap();
assert_eq!(read_back, Value::Object(values.clone()));

let missing = save_theme_config(Admin, state(), Path("aurora".into()), Json(values.clone())).await;
assert_eq!(missing.status(), StatusCode::BAD_REQUEST, "a theme that is not installed takes no write");
assert_eq!(read("../etc").await.status(), StatusCode::NOT_FOUND);

app.db.set("public_page", "off").unwrap();
assert_eq!(
read("default").await.status(),
StatusCode::UNAUTHORIZED,
"a closed status page hides them too"
);
}

/// A write naming a node that no longer exists, such as one deleted from
/// another tab, is refused rather than reported as saved.
#[tokio::test]
Expand Down
13 changes: 9 additions & 4 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,16 @@ impl Db {
// ---- settings ----

pub fn get(&self, key: &str) -> Option<String> {
self.conn()
self.lookup(key).ok().flatten()
}

/// As [`Db::get`], with a failed read kept apart from an absent key, for a
/// caller that would otherwise act on "nothing saved".
pub fn lookup(&self, key: &str) -> Result<Option<String>> {
Ok(self
.conn()
.query_row("SELECT value FROM setting WHERE key = ?1", [key], |r| r.get(0))
.optional()
.ok()
.flatten()
.optional()?)
}

pub fn set(&self, key: &str, value: &str) -> Result<()> {
Expand Down
8 changes: 7 additions & 1 deletion src/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ pub struct Theme {
pub version: String,
pub author: String,
pub url: String,
/// The settings form the panel draws for this theme. Passed through
/// unparsed: a malformed field costs that one field in the panel rather than
/// removing the whole theme from the list, and a hub predating the field
/// ignores it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub selected: bool,
/// Set only for the copy embedded in the binary. Never read from a manifest:
Expand Down Expand Up @@ -162,7 +168,7 @@ fn read_inside(root: &Path, relative: &str) -> Option<Vec<u8>> {
/// name and anything carrying a path separator. `default` is admitted: an
/// installed copy of the built-in theme takes that name and is served in its
/// place, leaving the embedded one as the fallback beneath it.
fn valid_short(short: &str) -> bool {
pub fn valid_short(short: &str) -> bool {
!short.is_empty() && short.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}

Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ async fn main() -> Result<()> {
.route("/api/nodes", get(api::nodes))
.route("/api/nodes/{id}/metrics", get(api::metrics))
.route("/api/ws", get(api::live_ws))
.route("/api/themes/{short}/config", get(api::theme_config))
// Sign-in.
.route("/api/auth/login", post(auth::login))
.route("/api/auth/logout", post(auth::logout))
Expand All @@ -465,6 +466,7 @@ async fn main() -> Result<()> {
.route("/api/themes/{short}", delete(api::delete_theme))
.route("/api/themes/{short}/preview", get(api::theme_preview))
.route("/api/themes/{short}/update", post(api::update_theme))
.route("/api/themes/{short}/config", put(api::save_theme_config))
.route("/api/db", get(api::db_stats))
.route("/api/db/backup", get(api::db_backup))
.route("/api/db/vacuum", post(api::db_vacuum))
Expand Down
Loading
Loading