Hero becomes a content-owned module; site asset proxy; gesture growth fix
Test / test (push) Successful in 24s
Test / test (push) Successful in 24s
site.yaml's hero is now plain | module, where module names a JavaScript file the content repo ships (mount(container) -> handle with stop()). Portal serves content assets same-origin at /site/<path> (Gitea raw sends no CORS headers), starts the module at HTML parse time, adopts it on hydration, mounts fresh via the inline script's __mountHero on client-side navigation, and stops it on leave. The YES canvas (yes.js) and the gesture hero mode leave the engine - uhhm/questions ships YES as its hero.js, redoal/questions ships a sine-swings band. Gesture form canvas no longer balloons after a stroke: the wrap's aspect-ratio reservation is scoped to :empty (pre-mount) so it can't turn echo-strip height into width inside the flex field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
307fd7e753
commit
1afd34c22e
+118
-29
@@ -347,51 +347,76 @@ pub struct SiteConfig {
|
||||
pub hero: HeroConfig,
|
||||
}
|
||||
|
||||
/// What the landing page's hero is: the YES canvas piece (`yes`, the
|
||||
/// default), a redoal gesture-drawing canvas (`gesture`), or copy
|
||||
/// only (`plain`).
|
||||
/// What the landing page's hero is. `plain` is just the copy; `module`
|
||||
/// hands the hero to a JavaScript module the content repo itself
|
||||
/// ships (`module: hero.js`, a repo-relative path portal serves
|
||||
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
|
||||
/// CORS headers). The module exports `mount(container) -> handle` and
|
||||
/// the handle has `stop()`; portal starts it at HTML parse time and
|
||||
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
|
||||
/// entirely the module's: portal only reserves the box.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HeroConfig {
|
||||
#[serde(default = "default_hero_kind")]
|
||||
pub kind: String,
|
||||
/// `kind: gesture` only - ws(s):// URL of a redoal-relay for
|
||||
/// ambient echoes. Absent means the hero draws offline.
|
||||
#[serde(default)]
|
||||
pub relay: Option<String>,
|
||||
pub module: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HeroConfig {
|
||||
fn default() -> Self {
|
||||
Self { kind: default_hero_kind(), relay: None }
|
||||
Self { kind: default_hero_kind(), module: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_hero_kind() -> String {
|
||||
"yes".to_string()
|
||||
"plain".to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl SiteConfig {
|
||||
/// Shared by `load_site_from_gitea` and the `question-lint` binary
|
||||
/// so a typo'd hero kind is a caught rejection, not a silently
|
||||
/// plain hero.
|
||||
/// Same policy as `validate_questions`: content declares, the
|
||||
/// runtime refuses what it doesn't understand at load time, so a
|
||||
/// typo'd hero kind is a caught rejection, not a silently plain
|
||||
/// hero.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") {
|
||||
anyhow::bail!(
|
||||
"site.yaml: hero.kind {:?} is not one of yes | gesture | plain",
|
||||
self.hero.kind
|
||||
);
|
||||
}
|
||||
if let Some(relay) = &self.hero.relay {
|
||||
if self.hero.kind != "gesture" {
|
||||
anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture");
|
||||
match self.hero.kind.as_str() {
|
||||
"plain" => {
|
||||
if self.hero.module.is_some() {
|
||||
anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module");
|
||||
}
|
||||
}
|
||||
validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?;
|
||||
"module" => {
|
||||
let Some(module) = &self.hero.module else {
|
||||
anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)");
|
||||
};
|
||||
if !is_safe_site_path(module) {
|
||||
anyhow::bail!(
|
||||
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
|
||||
);
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A repo-relative asset path portal is willing to proxy from the
|
||||
/// content repo: plain segments only - no traversal, no leading slash,
|
||||
/// no scheme - so `/site/<path>` can only ever reach the content repo.
|
||||
pub fn is_safe_site_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& path.split('/').all(|seg| {
|
||||
!seg.is_empty()
|
||||
&& seg != ".."
|
||||
&& seg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
})
|
||||
}
|
||||
|
||||
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
|
||||
/// hero and `Requirement.relay` validation.
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -428,6 +453,15 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
|
||||
|
||||
/// Extracts `scheme://host` from a repo's normal browser URL - the
|
||||
/// Gitea API base every helper in this module builds requests against.
|
||||
#[cfg(feature = "ssr")]
|
||||
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
|
||||
/// `site_asset_handler` proxies content-shipped files from.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
|
||||
let parsed = url::Url::parse(repo_url)
|
||||
@@ -1091,6 +1125,54 @@ pub struct GiteaRepoQuery {
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
/// `GET /site/{*path}` - serves a file from the content repo's default
|
||||
/// branch same-origin, so content-shipped assets (the hero module,
|
||||
/// its stylesheet) are importable by the page: Gitea's raw endpoint
|
||||
/// sends no CORS headers, and an ES module import across origins is
|
||||
/// refused without them. Path is validated to plain segments
|
||||
/// (`is_safe_site_path`), the response revalidates like /pkg
|
||||
/// (Cache-Control: no-cache) so a content push shows up on reload.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn site_asset_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
if !is_safe_site_path(&path) {
|
||||
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
||||
}
|
||||
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
|
||||
let client = openidconnect::reqwest::Client::new();
|
||||
let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("site asset {path}: {e}");
|
||||
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
|
||||
}
|
||||
};
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||
};
|
||||
let mime = match path.rsplit('.').next() {
|
||||
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("json") => "application/json",
|
||||
Some("png") => "image/png",
|
||||
Some("webp") => "image/webp",
|
||||
Some("woff2") => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Looks up `owner/repo` on the same Gitea instance content is loaded
|
||||
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
|
||||
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
|
||||
@@ -1386,9 +1468,9 @@ alternatives:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_config_defaults_to_the_yes_hero() {
|
||||
fn site_config_defaults_to_the_plain_hero() {
|
||||
let site = SiteConfig::default();
|
||||
assert_eq!(site.hero.kind, "yes");
|
||||
assert_eq!(site.hero.kind, "plain");
|
||||
assert!(site.validate().is_ok());
|
||||
// And an empty file parses to the same thing.
|
||||
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
|
||||
@@ -1403,21 +1485,28 @@ alternatives:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_config_rejects_relay_without_gesture_hero() {
|
||||
let site: SiteConfig =
|
||||
serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap();
|
||||
assert!(site.validate().is_err());
|
||||
fn site_config_module_hero_needs_a_safe_path() {
|
||||
let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap();
|
||||
assert!(missing.validate().is_err());
|
||||
let traversal: SiteConfig =
|
||||
serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap();
|
||||
assert!(traversal.validate().is_err());
|
||||
let absolute: SiteConfig =
|
||||
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
|
||||
assert!(absolute.validate().is_err());
|
||||
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
|
||||
assert!(ok.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redoal_site_yaml_shape_parses() {
|
||||
let site: SiteConfig = serde_yaml::from_str(
|
||||
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n",
|
||||
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(site.validate().is_ok());
|
||||
assert_eq!(site.title.as_deref(), Some("redoal"));
|
||||
assert_eq!(site.hero.kind, "gesture");
|
||||
assert_eq!(site.hero.module.as_deref(), Some("hero.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user