Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9faaeb4a6 | ||
|
|
1a2c064d3a | ||
|
|
fe7a868b18 | ||
|
|
aee5048b01 | ||
|
|
268d545843 | ||
|
|
22d086f9e1 |
@@ -4,6 +4,18 @@ One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.6.21 (2026-10-07)
|
||||
|
||||
- A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download.
|
||||
|
||||
## 0.6.20 (2026-10-05)
|
||||
|
||||
- A releases card lets a platform's file carry an architecture: a Mac visitor is offered the `macos-arm64` file, not sent to TestFlight past it.
|
||||
|
||||
## 0.6.19 (2026-10-05)
|
||||
|
||||
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
|
||||
|
||||
## 0.6.18 (2026-10-03)
|
||||
|
||||
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.18"
|
||||
version = "0.6.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.18"
|
||||
version = "0.6.21"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
|
||||
"FileList",
|
||||
"FormData",
|
||||
"HtmlInputElement",
|
||||
"Navigator",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
|
||||
@@ -255,6 +255,7 @@ reads is never a client-supplied parameter.
|
||||
states: [approved, refused] # only these; empty means all
|
||||
where: { field: case, equals: "{key}" }
|
||||
jq: ".[] | {name, url: .html_url}"
|
||||
limit: 1 # at most this many, after jq
|
||||
empty: Nothing decided yet.
|
||||
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
||||
transitions: # the desk's buttons
|
||||
@@ -264,6 +265,14 @@ reads is never a client-supplied parameter.
|
||||
`source.kind` is one of `kv`, `gitea_starred`,
|
||||
`gitea_releases`, `url`.
|
||||
|
||||
`limit: 1` shows only the first item — for a feed where only the newest
|
||||
matters. A releases card wants the cut someone should run, not every
|
||||
cut there has ever been, and a page that grows by one card per release
|
||||
gets longer forever. It applies *after* `jq`, so a filter can reorder
|
||||
and the limit takes the top of that ordering. A source that yields a
|
||||
single record has no length and ignores it; `limit: 0` on a list shows
|
||||
nothing, and the `empty` line speaks instead.
|
||||
|
||||
A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
@@ -293,6 +302,18 @@ stays about the work in hand however long the site has run; nothing
|
||||
is hidden from a read-only list, and `leaves_after` still takes a
|
||||
record out of every list altogether.
|
||||
|
||||
A **releases manifest** - a `kind: url` resource whose JSON is
|
||||
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
||||
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
||||
ios: { testflight, note } } }`, newest version first, as a release
|
||||
pipeline writes it - renders as one card: the newest version, the file
|
||||
for the visitor's own platform first (read from the browser: `ios`,
|
||||
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
||||
other files and the channels folded under "All downloads". Linux gets
|
||||
the package repo lines beside its file; iPhones and Macs get
|
||||
TestFlight. Platforms a manifest lacks fall through to the folded
|
||||
list.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
|
||||
+163
@@ -2124,9 +2124,148 @@ fn ResourceValue(
|
||||
}
|
||||
}
|
||||
|
||||
// A releases manifest (content-reference, "A releases manifest"):
|
||||
// the newest version, its files per platform, and the standing
|
||||
// channels, with the visitor's own platform picked in the browser.
|
||||
if is_releases_manifest(&value) {
|
||||
return view! { <ReleasesCard manifest=value/> }.into_any();
|
||||
}
|
||||
|
||||
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
||||
}
|
||||
|
||||
/// `{ name, latest, versions: [{ version, date, notes, assets: [{
|
||||
/// platform, url, sha256, size }] }], channels: {...} }` - the shape a
|
||||
/// release pipeline writes (redoal's does) for a site to offer downloads.
|
||||
pub fn is_releases_manifest(value: &serde_json::Value) -> bool {
|
||||
value.get("latest").and_then(|v| v.as_str()).is_some()
|
||||
&& value.get("versions").and_then(|v| v.as_array()).is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// The platform a browser says it is, in the manifest's words:
|
||||
/// `ios`, `macos`, `android`, `windows`, `linux-x86_64`,
|
||||
/// `linux-aarch64`, or `other`. Read, never trusted for anything but
|
||||
/// which download to put first.
|
||||
pub fn platform_of(user_agent: &str) -> &'static str {
|
||||
let ua = user_agent.to_ascii_lowercase();
|
||||
if ua.contains("iphone") || ua.contains("ipad") {
|
||||
"ios"
|
||||
} else if ua.contains("mac os x") || ua.contains("macintosh") {
|
||||
"macos"
|
||||
} else if ua.contains("android") {
|
||||
"android"
|
||||
} else if ua.contains("windows") {
|
||||
"windows"
|
||||
} else if ua.contains("linux") || ua.contains("x11") {
|
||||
if ua.contains("aarch64") || ua.contains("arm64") {
|
||||
"linux-aarch64"
|
||||
} else {
|
||||
"linux-x86_64"
|
||||
}
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest release as a card: the file for this device first (the
|
||||
/// platform is read in the browser, so the server renders every file
|
||||
/// and the pick lands on hydration), the rest after, and the channels
|
||||
/// that are not files - a package repo, TestFlight.
|
||||
#[component]
|
||||
fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView {
|
||||
let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let latest = manifest.get("latest").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let newest = manifest["versions"][0].clone();
|
||||
let date = newest.get("date").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let notes = newest.get("notes").and_then(|v| v.as_str()).filter(|u| u.starts_with("https://")).map(str::to_string);
|
||||
let assets: Vec<(String, String, String)> = newest
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| {
|
||||
let platform = x.get("platform")?.as_str()?.to_string();
|
||||
let url = x.get("url")?.as_str()?.to_string();
|
||||
let sha = x.get("sha256").and_then(|s| s.as_str()).unwrap_or("").to_string();
|
||||
Some((platform, url, sha))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let channels = manifest.get("channels").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let arch = channels.get("arch").cloned();
|
||||
let ios = channels.get("ios").cloned();
|
||||
|
||||
let platform = RwSignal::new(None::<&'static str>);
|
||||
Effect::new(move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
if let Some(ua) = web_sys::window().and_then(|w| w.navigator().user_agent().ok()) {
|
||||
platform.set(Some(platform_of(&ua)));
|
||||
}
|
||||
});
|
||||
let label = |p: &str| match p {
|
||||
"linux-x86_64" => "Linux (x86_64)",
|
||||
"linux-aarch64" => "Linux (aarch64)",
|
||||
"macos" | "macos-arm64" => "macOS",
|
||||
"windows" | "windows-x86_64" => "Windows",
|
||||
other => other,
|
||||
}.to_string();
|
||||
let assets_for_list = assets.clone();
|
||||
let arch_for_pick = arch.clone();
|
||||
let ios_for_pick = ios.clone();
|
||||
let assets_for_pick = assets.clone();
|
||||
view! {
|
||||
<article class="item-card releases">
|
||||
<div class="item-card-title">
|
||||
<strong>{name.clone()} " " {latest.clone()}</strong>
|
||||
{(!date.is_empty()).then(|| view! { <span class="dim">" · " {date.clone()}</span> })}
|
||||
{notes.clone().map(|u| view! { " · " <a href=u target="_blank" rel="noopener noreferrer">{move || t(&lang().get(), "release_notes")}</a> })}
|
||||
</div>
|
||||
// What this device should take, decided in the browser.
|
||||
{move || {
|
||||
let p = platform.get()?;
|
||||
let arch = arch_for_pick.clone();
|
||||
let ios = ios_for_pick.clone();
|
||||
// "macos" is satisfied by a "macos-arm64" file, and so on.
|
||||
let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p || pl.starts_with(&format!("{p}-"))).cloned();
|
||||
let pick: Option<AnyView> = match (p, own) {
|
||||
(_, Some((pl, url, _))) => Some(view! {
|
||||
<p><a class="pill" href=url download>{t(&lang().get(), "for_this_device")} ": " {label(&pl)}</a></p>
|
||||
{(p.starts_with("linux") && arch.is_some()).then(|| {
|
||||
let a = arch.clone().unwrap();
|
||||
let repo = a.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let pkg = a.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <pre class="monospace dim">{repo} "\n\npacman -Syu " {pkg}</pre> }
|
||||
})}
|
||||
}.into_any()),
|
||||
("ios" | "macos", None) => ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(str::to_string)).map(|tf| {
|
||||
let note = ios.as_ref().and_then(|i| i.get("note").and_then(|v| v.as_str())).unwrap_or("").to_string();
|
||||
view! { <p><a class="pill" href=tf target="_blank" rel="noopener noreferrer">{t(&lang().get(), "for_this_device")} ": TestFlight"</a> <span class="dim">" " {note}</span></p> }.into_any()
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
pick
|
||||
}}
|
||||
<details class="releases-all">
|
||||
<summary>{move || t(&lang().get(), "all_downloads")}</summary>
|
||||
<ul>
|
||||
{assets_for_list.iter().map(|(pl, url, sha)| view! {
|
||||
<li><a href=url.clone() download>{label(pl)}</a> {(!sha.is_empty()).then(|| view! { <span class="dim monospace">" sha256 " {sha[..sha.len().min(12)].to_string()} "…"</span> })}</li>
|
||||
}).collect::<Vec<_>>()}
|
||||
{arch.as_ref().map(|a| {
|
||||
let note = a.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <li>{note}</li> }
|
||||
})}
|
||||
{ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(|tf| {
|
||||
let note = i.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <li><a href=tf.to_string() target="_blank" rel="noopener noreferrer">"TestFlight"</a> " " {note}</li> }
|
||||
}))}
|
||||
</ul>
|
||||
</details>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the first present, non-null string field out of a JSON object
|
||||
/// matching one of `keys`, tried in order - shared between `ItemCard`
|
||||
/// and `SelectField`'s option rendering, since both need "guess a
|
||||
@@ -3365,3 +3504,27 @@ mod picture_tests {
|
||||
assert_eq!(picture_of(&answer, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod releases_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_browser_names_its_platform_in_the_manifests_words() {
|
||||
assert_eq!(platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), "ios");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), "macos");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), "linux-x86_64");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), "linux-aarch64");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), "windows");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Linux; Android 15)"), "android");
|
||||
assert_eq!(platform_of("curl/8"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_manifest_shape_is_a_manifest() {
|
||||
let m = serde_json::json!({"name":"redoal","latest":"0.1.17","versions":[{"version":"0.1.17","assets":[]}]});
|
||||
assert!(is_releases_manifest(&m));
|
||||
assert!(!is_releases_manifest(&serde_json::json!({"latest":"1","versions":[]})));
|
||||
assert!(!is_releases_manifest(&serde_json::json!([{"name":"x"}])));
|
||||
}
|
||||
}
|
||||
|
||||
+115
-15
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
|
||||
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 {
|
||||
serve_repo_file(&state, &path, false).await
|
||||
}
|
||||
|
||||
/// `GET /.well-known/{*path}` - the content repo overlaid on the one part
|
||||
/// of the URL space that can never collide with a question: RFC 8615
|
||||
/// reserves `/.well-known/`, and a question's id is derived from a file
|
||||
/// name under `questions/`, which cannot begin with a dot. A repo ships
|
||||
/// `.well-known/security.txt`, `.well-known/assetlinks.json`,
|
||||
/// `.well-known/matrix/server` and so on at the path they are served at,
|
||||
/// and a miss is a real 404 rather than the app shell answering 200 with
|
||||
/// an HTML page - which is what a client probing for one of these would
|
||||
/// otherwise have to parse.
|
||||
///
|
||||
/// Paths Caddy claims in front of portal (mail autoconfiguration, the
|
||||
/// CalDAV and CardDAV redirects) never reach here.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn well_known_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
serve_repo_file(&state, &format!(".well-known/{path}"), true).await
|
||||
}
|
||||
|
||||
/// Proxies one repo-relative file from the content repo's default branch.
|
||||
/// `well_known` only changes what an extensionless file is typed as: under
|
||||
/// `/.well-known/` those are JSON by convention (`matrix/server`,
|
||||
/// `nodeinfo`), while elsewhere a file with no extension is an unknown.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve_repo_file(
|
||||
state: &crate::server::AppState,
|
||||
path: &str,
|
||||
well_known: bool,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
if !is_safe_site_path(&path) {
|
||||
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 resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
|
||||
let url = format!(
|
||||
"{}/{path}?ref={}",
|
||||
state.content_raw_base, state.content_branch
|
||||
);
|
||||
let resp = match crate::http::client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("site asset {path}: {e}");
|
||||
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
|
||||
Some("avif") => "image/avif",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
Some("txt") => "text/plain; charset=utf-8",
|
||||
Some("xml") => "application/xml; charset=utf-8",
|
||||
Some("webmanifest") => "application/manifest+json",
|
||||
// An Apple configuration profile only offers to install when it
|
||||
// arrives under this type; anything else is just a download.
|
||||
Some("mobileconfig") => "application/x-apple-aspen-config",
|
||||
// A file with no extension at all: rsplit yields the whole name.
|
||||
_ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => {
|
||||
"application/json; charset=utf-8"
|
||||
}
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// Photographs are large and do not change under a name, so a
|
||||
// browser may keep them for an hour; everything else revalidates,
|
||||
// so a content push shows on reload.
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") {
|
||||
"public, max-age=3600"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||
bytes,
|
||||
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
};
|
||||
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
||||
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
let GiteaRepoQuery { owner, repo } = query;
|
||||
|
||||
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
||||
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
|
||||
.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("fetching {api_url}: {e}"),
|
||||
)
|
||||
})?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if expected.is_empty() || presented != expected {
|
||||
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"unauthorized".to_string(),
|
||||
));
|
||||
}
|
||||
// The token authenticates the automation service; what it may read
|
||||
// is the policy's to say, like every other principal.
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Service { name: "automation".to_string() },
|
||||
&crate::access::Principal::Service {
|
||||
name: "automation".to_string(),
|
||||
},
|
||||
crate::access::Action::AutomationRead,
|
||||
&crate::access::Resource::Bucket { name: bucket.clone() },
|
||||
&crate::access::Resource::Bucket {
|
||||
name: bucket.clone(),
|
||||
},
|
||||
serde_json::json!({}),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"not authorized".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&bucket)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("bucket unavailable: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let keys: Vec<String> = store
|
||||
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
|
||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The overlay's whole safety argument: `/.well-known/` is reserved by
|
||||
/// RFC 8615 and a question's id comes from a file under `questions/`, so
|
||||
/// a repo file served at the site root cannot shadow a page - and the
|
||||
/// path it is fetched by still cannot climb out of the repo.
|
||||
#[test]
|
||||
fn well_known_paths_stay_inside_the_repo() {
|
||||
for path in [
|
||||
".well-known/security.txt",
|
||||
".well-known/matrix/server",
|
||||
".well-known/assetlinks.json",
|
||||
] {
|
||||
assert!(is_safe_site_path(path), "{path} should be servable");
|
||||
}
|
||||
for path in [
|
||||
".well-known/../questions/index.yaml",
|
||||
".well-known//escape",
|
||||
"/.well-known/security.txt",
|
||||
] {
|
||||
assert!(!is_safe_site_path(path), "{path} should be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +519,18 @@ pub struct ResourceSpec {
|
||||
/// it where the silence needs a voice ("Nothing released yet").
|
||||
#[serde(default)]
|
||||
pub empty: Option<String>,
|
||||
/// Show at most this many items, applied after `jq`. For a feed
|
||||
/// where only the newest matters: a releases card wants the cut you
|
||||
/// should run, not every cut there has ever been, and a page that
|
||||
/// grows by one card per release gets longer forever.
|
||||
///
|
||||
/// After `jq` rather than before, so it caps what the page will
|
||||
/// actually render whatever the filter did — and so content can
|
||||
/// reorder with jq and still take the top of its own ordering. A
|
||||
/// source that yields a single record has no length and passes
|
||||
/// through untouched. `None` shows everything.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
|
||||
|
||||
@@ -25,6 +25,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("en", "sign_out") => "Sign out",
|
||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||
("en", "show_older") => "Show older",
|
||||
("en", "for_this_device") => "For this device",
|
||||
("en", "all_downloads") => "All downloads",
|
||||
("en", "release_notes") => "release notes",
|
||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||
@@ -62,6 +65,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("no", "sign_out") => "Logg ut",
|
||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||
("no", "show_older") => "Vis eldre",
|
||||
("no", "for_this_device") => "Til denne enheten",
|
||||
("no", "all_downloads") => "Alle nedlastinger",
|
||||
("no", "release_notes") => "utgivelsesnotater",
|
||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||
@@ -117,6 +123,9 @@ mod tests {
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"show_older",
|
||||
"for_this_device",
|
||||
"all_downloads",
|
||||
"release_notes",
|
||||
"upload_failed",
|
||||
"upload_too_large",
|
||||
"upload_unavailable",
|
||||
|
||||
+48
-18
@@ -27,7 +27,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve() -> anyhow::Result<()> {
|
||||
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::Request,
|
||||
response::IntoResponse,
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use portal::app::{shell, App};
|
||||
@@ -57,12 +64,15 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
|
||||
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
let questions =
|
||||
content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
content::validate_questions(&questions, &aggregates)?;
|
||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
||||
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
|
||||
tracing::info!(count = questions.len(), storylines, repo = %content_repo, branch = %content_branch, "loaded content");
|
||||
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
|
||||
let access = Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
portal::access::Policy::from_content(&questions, &aggregates),
|
||||
));
|
||||
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
|
||||
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
|
||||
@@ -128,7 +138,12 @@ async fn serve() -> anyhow::Result<()> {
|
||||
|
||||
// The same decisions, answerable by other apps over NATS, for a
|
||||
// person named by phone number or address as well as by groups.
|
||||
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
|
||||
tokio::spawn(portal::access::serve(
|
||||
state.nats.clone(),
|
||||
site_name_for_subjects(),
|
||||
state.access.clone(),
|
||||
Some(state.clone()),
|
||||
));
|
||||
// Announced pages: keep their desk records current, close ended
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::run(state.clone()));
|
||||
@@ -202,17 +217,16 @@ async fn serve() -> anyhow::Result<()> {
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
||||
None => tracing::warn!(
|
||||
"rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"
|
||||
),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
leptos_axum::handle_server_fns_with_context(
|
||||
move || provide_context(state.clone()),
|
||||
request,
|
||||
)
|
||||
leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -264,10 +278,7 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
||||
.route_service(
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
.route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path))
|
||||
// Same name every release, so revalidated like /pkg.
|
||||
.route_service(
|
||||
"/page-motion.js",
|
||||
@@ -291,7 +302,9 @@ async fn serve() -> anyhow::Result<()> {
|
||||
// with room for the multipart framing.
|
||||
.route(
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(
|
||||
upload::MAX_UPLOAD_BYTES + 64 * 1024,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
@@ -302,7 +315,11 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.route("/report", get(portal::report::handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
.route("/.well-known/{*path}", get(content::well_known_handler))
|
||||
.route(
|
||||
"/automation/kv/{bucket}",
|
||||
get(content::automation_kv_handler),
|
||||
)
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
@@ -324,7 +341,10 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||
.layer(session_layer)
|
||||
// Which storyline this visitor reads, before anything renders.
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
portal::server::storyline::assign,
|
||||
));
|
||||
// Outermost, so a caller who is over the limit is turned away
|
||||
// before a session is loaded or a handler is entered.
|
||||
let app = match governor {
|
||||
@@ -362,7 +382,17 @@ fn site_name_for_subjects() -> String {
|
||||
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
|
||||
let folded: String = raw
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if folded.is_empty() { "portal".to_string() } else { folded }
|
||||
if folded.is_empty() {
|
||||
"portal".to_string()
|
||||
} else {
|
||||
folded
|
||||
}
|
||||
}
|
||||
|
||||
+68
-3
@@ -347,9 +347,29 @@ async fn fetch_resource_value(
|
||||
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
||||
};
|
||||
|
||||
match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value),
|
||||
None => Ok(value),
|
||||
let shaped = match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value)?,
|
||||
None => value,
|
||||
};
|
||||
Ok(apply_limit(resource.limit, shaped))
|
||||
}
|
||||
|
||||
/// Keep at most `n` items, after `jq` has had its say.
|
||||
///
|
||||
/// Anything that is not a list passes through: a single record has no
|
||||
/// length to cap, and silently turning one into an empty list would be
|
||||
/// worse than ignoring a limit that cannot apply. A limit of zero does
|
||||
/// mean nothing — the resource's `empty` line speaks instead, which is
|
||||
/// a coherent thing for content to ask for.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
|
||||
let Some(n) = limit else { return value };
|
||||
match value {
|
||||
serde_json::Value::Array(mut items) => {
|
||||
items.truncate(n);
|
||||
serde_json::Value::Array(items)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +684,51 @@ mod tests {
|
||||
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
||||
}
|
||||
|
||||
/// `limit` caps what the page renders, and does it after `jq` so
|
||||
/// content can order with the filter and take the top of its own
|
||||
/// ordering. The releases card is the case: the newest cut is the
|
||||
/// one you should run, and a page that grows a card per release
|
||||
/// gets longer forever.
|
||||
#[test]
|
||||
fn limit_caps_a_list_after_the_filter() {
|
||||
let releases = serde_json::json!([
|
||||
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
|
||||
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
|
||||
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
|
||||
]);
|
||||
// The filter redoal/questions actually declares: bare objects,
|
||||
// never a wrapped array — the frontend re-wraps.
|
||||
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
|
||||
let shaped = apply_jq(filter, &releases).expect("filter runs");
|
||||
|
||||
let all = apply_limit(None, shaped.clone());
|
||||
assert_eq!(all.as_array().map(Vec::len), Some(3), "no limit shows everything");
|
||||
|
||||
let latest = apply_limit(Some(1), shaped.clone());
|
||||
let rows = latest.as_array().expect("still a list");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["tag"], "v0.1.17", "and it is the newest, which Gitea returns first");
|
||||
|
||||
let two = apply_limit(Some(2), shaped.clone());
|
||||
assert_eq!(two.as_array().map(Vec::len), Some(2));
|
||||
|
||||
// Asking for more than there is is not an error.
|
||||
let plenty = apply_limit(Some(99), shaped);
|
||||
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
|
||||
}
|
||||
|
||||
/// A limit that cannot apply is ignored rather than guessed at.
|
||||
#[test]
|
||||
fn limit_leaves_a_single_record_alone() {
|
||||
let one = serde_json::json!({"name": "a record", "state": "open"});
|
||||
assert_eq!(apply_limit(Some(1), one.clone()), one);
|
||||
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
|
||||
|
||||
// Zero on a list does mean nothing — the `empty` line speaks.
|
||||
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
|
||||
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
||||
/// `/users/{username}/starred` endpoint returns (Gitea 1.27:
|
||||
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
|
||||
|
||||
@@ -1294,6 +1294,12 @@ textarea:focus {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A releases manifest: the pick for this device, the rest folded. */
|
||||
.releases .pill { display: inline-block; }
|
||||
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
|
||||
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
|
||||
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
|
||||
|
||||
.show-older:hover {
|
||||
color: inherit;
|
||||
border-color: var(--ink-dim);
|
||||
|
||||
Reference in New Issue
Block a user