Compare commits

...
6 Commits
Author SHA1 Message Date
Bendik LynghaugandClaude Opus 5 c9faaeb4a6 Serve a content repo's .well-known/ at the site root
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m3s
Publish release / publish (push) Successful in 1m51s
Everything a site has to publish for another program to find it lives
under /.well-known/, and portal had no way to put a file there: the
catch-all answered those paths 200 with the app shell, so a client
probing for security.txt or matrix/server got HTML where it expected a
document, and could not tell a missing file from a present one.

The prefix is the one place this is safe to overlay. RFC 8615 reserves
it, and a question's id comes from a file name under questions/, which
cannot begin with a dot - so no file a repo ships can shadow a page. The
path still goes through is_safe_site_path, so it cannot climb out of the
repo either, and a miss is a real 404.

Types: an extensionless file under the prefix is JSON, which is what
matrix/server and nodeinfo are; .mobileconfig is the Apple profile type,
without which iOS downloads a profile instead of offering to install it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:30:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1a2c064d3a Release 0.6.20
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m11s
Publish release / publish (push) Successful in 1m55s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fe7a868b18 A platform's file may name its architecture
A Mac is offered the macos-arm64 file rather than sent past it to
TestFlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:19 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 aee5048b01 Release 0.6.19
docs / deploy (push) Successful in 3s
Publish release / publish (push) Successful in 2m50s
Test / test (push) Successful in 2m10s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:48:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 268d545843 A releases manifest renders as a download card, the device's file first
A kind: url resource whose JSON is a releases manifest - latest,
versions newest first with per-platform assets, channels such as a
package repo or TestFlight - is one card: the newest version, the file
for the visitor's own platform (read from the browser on hydration;
the server renders every file), the Linux package repo lines beside a
Linux file, TestFlight for iPhones and Macs, and the rest folded under
"All downloads". Decided by shape, as every resource is; written for
redoal's public releases at dl.redoal.com.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:48:31 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 22d086f9e1 A resource can show only its newest few
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m0s
`limit: n` on a resource spec, applied after `jq`.

The releases card is what asked for it: a page that grows by one card
per release gets longer forever, and the cut a visitor should run is the
newest one — the rest are history, not an invitation. redoal's card had
every release on it.

After `jq` rather than before, for two reasons: it caps what the page
will actually render whatever the filter did, and content that reorders
with the filter can then take the top of its own ordering rather than
the top of the source's. A source yielding a single record has no length
and passes through untouched — turning one record into an empty list
would be worse than ignoring a limit that cannot apply. `limit: 0` on a
list does mean nothing, and the `empty` line speaks instead, which is a
coherent thing for content to ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByshXuDEzH8hXh5qp4VgGk
2026-10-04 22:51:12 +02:00
11 changed files with 458 additions and 39 deletions
+12
View File
@@ -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
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.18"
version = "0.6.21"
dependencies = [
"anyhow",
"arc-swap",
+2 -1
View File
@@ -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",
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
}
+12
View File
@@ -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
+9
View File
@@ -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",
+49 -19
View File
@@ -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,18 +217,17 @@ 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,
)
.await
leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
.await
}
// The app's own Leptos route list includes a root-level wildcard
@@ -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
View File
@@ -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" })), &params));
}
/// `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 ""
+6
View File
@@ -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);