Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb982ec6e6 | ||
|
|
c6dffc384c | ||
|
|
db0972974f | ||
|
|
72faf44559 | ||
|
|
db89ea6400 | ||
|
|
95bffc221b | ||
|
|
f5266cb347 | ||
|
|
248561e805 |
@@ -6,6 +6,14 @@ content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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.
|
||||
|
||||
## 0.6.6 (2026-09-30)
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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  after"), "<p>before after</p>");
|
||||
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user