Compare commits

..
8 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 19b7750292 Release 0.6.9
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m54s
2026-09-30 21:38:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1a1723f8bb Changelog: 0.6.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:56 +02:00
bl 0d67675456 Merge pull request 'A bio is markdown, on stricter rules' (#18) from bio-markdown into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 19:38:55 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 eb982ec6e6 A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:54 +02:00
Bendik Aagaard Lynghaug c6dffc384c Release 0.6.8
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 2m0s
2026-09-30 21:08:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db0972974f Changelog: 0.6.8
docs / deploy (push) Successful in 4s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:30 +02:00
bl 72faf44559 Merge pull request 'An upload that did not arrive says why' (#17) from upload-messages into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 19:08:30 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db89ea6400 An upload that did not arrive says why
Test / test (pull_request) Successful in 1m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:28 +02:00
10 changed files with 164 additions and 13 deletions
+8
View File
@@ -4,6 +4,14 @@ 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.9 (2026-09-30)
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
## 0.6.8 (2026-09-30)
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
## 0.6.7 (2026-09-30)
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.7"
version = "0.6.9"
edition = "2021"
[lib]
+4 -1
View File
@@ -275,7 +275,10 @@ time. The cards appear in the order the records were put up. That is
how a site shows the people who work there, or a gallery of what it
has built. On a desk, the same picture is a thumbnail that opens.
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
send a JPEG whatever it keeps its photos as.
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML.
`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
+1 -1
View File
@@ -507,7 +507,7 @@
"hints": {
"name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
"erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av",
"erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
},
"question": "Ditt kort på forsiden",
+47 -2
View File
@@ -1310,6 +1310,12 @@ fn AlternativeCard(
};
let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue };
// Too big is known before a byte is sent.
if file.size() > MAX_UPLOAD_BYTES as f64 {
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
sending.set(false);
return;
}
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
@@ -1319,7 +1325,8 @@ fn AlternativeCard(
// say so.
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
send_error.set(Some(t(&lang().get_untracked(), "upload_failed").to_string()));
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
sending.set(false);
return;
}
@@ -2283,6 +2290,24 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// The largest file one upload may carry. Here, not in `upload.rs`,
/// so the browser checks the same number before it sends anything.
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
/// Why an upload did not arrive, in words a person can act on, from
/// the status `POST /upload` answered with (see `upload.rs`): too big,
/// a site that takes no files, a page they may not send from, or
/// anything else.
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
fn upload_refusal(status: Option<u16>) -> &'static str {
match status {
Some(413) => "upload_too_large",
Some(503) => "upload_unavailable",
Some(403) => "upload_forbidden",
_ => "upload_failed",
}
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
@@ -2528,7 +2553,12 @@ fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl In
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()}
// What the person wrote, as they wrote it: markdown, on
// the stricter rules for text that is not the site's.
{rest
.into_iter()
.map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
.collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
@@ -3241,6 +3271,21 @@ mod responsible_tests {
mod picture_tests {
use super::*;
#[test]
fn an_upload_refusal_says_which_it_was() {
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
assert_eq!(upload_refusal(Some(502)), "upload_failed");
assert_eq!(upload_refusal(None), "upload_failed");
for lang in ["en", "no"] {
for status in [Some(413), Some(503), Some(403), None] {
let key = upload_refusal(status);
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
}
}
}
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
+64
View File
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
/// Markdown that a person wrote into a record - a bio on a card - as
/// opposed to the site's own content. Paragraphs, line breaks as they
/// were typed, lists, emphasis and links; no images (a picture has its
/// own field, and an image in text is a way to load something from
/// elsewhere into every visitor's page), no raw HTML, headings as plain
/// paragraphs. Links go to https, mailto or tel only, and say they are
/// the writer's, not the site's (`rel="nofollow ugc"`).
pub fn render_written_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
let mut in_dropped_link = 0usize;
let mut in_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Image { .. }) => {
in_image += 1;
None
}
Event::End(TagEnd::Image) => {
in_image = in_image.saturating_sub(1);
None
}
_ if in_image > 0 => None,
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
// What someone typed on a new line is on a new line.
Event::SoftBreak => Some(Event::HardBreak),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
{
in_dropped_link += 1;
None
}
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
in_dropped_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
}
#[cfg(test)]
mod written_tests {
use super::render_written_markdown as md;
#[test]
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
// Everything that would put something else in the page is gone.
assert_eq!(md("<script>alert(1)</script>"), "");
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+11 -2
View File
@@ -24,7 +24,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "upload_failed") => "The file could not be sent, so nothing was. Try again, or with a smaller file.",
("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.",
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
("en", "current_file") => "Now:",
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
("en", "nothing_here") => "Nothing here",
@@ -57,7 +60,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen, eller med en mindre fil.",
("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.",
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
("no", "current_file") => "Nå:",
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
("no", "nothing_here") => "Ingenting her",
@@ -109,6 +115,9 @@ mod tests {
"sign_out",
"nothing_here_yet",
"upload_failed",
"upload_too_large",
"upload_unavailable",
"upload_forbidden",
"current_file",
"keep_file",
"nothing_here",
+1 -1
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState;
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
pub const MAX_UPLOAD_BYTES: usize = crate::app::MAX_UPLOAD_BYTES;
#[derive(Clone)]
pub struct Garage {
+26 -4
View File
@@ -1300,14 +1300,36 @@ textarea:focus {
font-size: 1.05rem;
}
.answer-card-text p {
margin: 0;
.card-field {
font-size: 0.92rem;
color: var(--ink-dim);
white-space: pre-line;
}
.answer-card-text p:first-of-type {
.card-field > :first-child {
margin-top: 0;
}
.card-field > :last-child {
margin-bottom: 0;
}
.card-field p,
.card-field ul,
.card-field ol {
margin: 0 0 0.5em;
}
.card-field ul,
.card-field ol {
padding-left: 1.2em;
}
.card-field a {
color: var(--accent);
}
/* The field right under the name - a role - reads as part of the name. */
.answer-card-text .card-field:first-of-type {
color: var(--ink);
font-weight: 600;
}