Valider fase 13–14: fiks size-inkonsistens, sikkerhet og konsistens i traits + publisering

Feil funnet og fikset:

1. truncate() UTF-8-panikkfeil: Brukte byte-indeksering s[..max] som
   panikker på flerbyte-tegn (æ, ø, å). Byttet til char_indices().

2. VALID_TRAITS manglet 'mixer' og 'ai_tool': Dokumentert i
   docs/primitiver/traits.md men avvist av backend-validering.

3. Planlagte artikler synlige før publish_at: serve_article og alle
   listevisninger (forside, arkiv, kategori, søk) eksponerte artikler
   med fremtidig publish_at. Lagt til tidsfiltere i alle spørringer.

4. A/B klikk-attribusjon logget alle varianter: serve_article logget
   klikk for ALLE aktive varianter ved direkte artikkelbesøk, ikke
   bare den viste. Fjernet feilaktig attribusjon — klikk logges kun
   via track_click-endepunktet med spesifikk variant-parameter.

5. JSON-LD XSS via </script>: serde_json escaper ikke </script>-
   sekvenser, så brukertitler kunne bryte ut av <script>-blokken.
   Lagt til .replace("</", "<\\/") etter serialisering.

6. Hardkodet farge i search.html: Brukte rgba(233,69,96,0.1) i stedet
   for tema-variabel. Byttet til color-mix() med --color-accent.

Nye tester: truncate med UTF-8, truncate kort streng, JSON-LD XSS-escape.
This commit is contained in:
vegard 2026-03-18 15:27:38 +00:00
parent cebda9f3e8
commit e25b5a11ef
4 changed files with 88 additions and 40 deletions

View file

@ -36,7 +36,7 @@ const VALID_TRAITS: &[&str] = &[
// Publisering & distribusjon // Publisering & distribusjon
"publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api", "publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
// Lyd & video // Lyd & video
"podcast", "recording", "transcription", "tts", "clips", "playlist", "studio", "podcast", "recording", "transcription", "tts", "clips", "playlist", "mixer", "studio",
// Kommunikasjon // Kommunikasjon
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa", "chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
// Organisering // Organisering
@ -44,7 +44,7 @@ const VALID_TRAITS: &[&str] = &[
// Kunnskap // Kunnskap
"knowledge_graph", "wiki", "glossary", "faq", "bibliography", "knowledge_graph", "wiki", "glossary", "faq", "bibliography",
// Automatisering & AI // Automatisering & AI
"auto_tag", "auto_summarize", "digest", "bridge", "moderation", "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool",
// Tilgang & fellesskap // Tilgang & fellesskap
"membership", "roles", "invites", "paywall", "directory", "membership", "roles", "invites", "paywall", "directory",
// Ekstern integrasjon // Ekstern integrasjon

View file

@ -139,7 +139,6 @@ fn build_json_ld(
publisher_name: &str, publisher_name: &str,
canonical_url: &str, canonical_url: &str,
) -> String { ) -> String {
// Escape for safe JSON embedding i <script>-tag
let ld = serde_json::json!({ let ld = serde_json::json!({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
@ -152,7 +151,9 @@ fn build_json_ld(
}, },
"description": article.summary.as_deref().unwrap_or("") "description": article.summary.as_deref().unwrap_or("")
}); });
ld.to_string() // Escape </script> sekvenser for sikker embedding i <script>-tag.
// Uten dette kan brukerinnhold bryte ut av JSON-LD-blokken (XSS).
ld.to_string().replace("</", "<\\/")
} }
// ============================================================================= // =============================================================================
@ -330,8 +331,6 @@ pub struct CachedIndex {
/// Brukes for impression-logging ved serve. /// Brukes for impression-logging ved serve.
/// Tuple: (edge_id, article_id) /// Tuple: (edge_id, article_id)
active_ab_variants: Vec<(Uuid, Uuid)>, active_ab_variants: Vec<(Uuid, Uuid)>,
/// Map fra article_id til aktive variant edge_ids (for klikk-attribusjon).
ab_article_variants: HashMap<Uuid, Vec<Uuid>>,
} }
/// Thread-safe cache for forside-rendering (dynamisk modus). /// Thread-safe cache for forside-rendering (dynamisk modus).
@ -863,6 +862,11 @@ async fn fetch_article(
.and_then(|s| s.parse::<DateTime<Utc>>().ok()) .and_then(|s| s.parse::<DateTime<Utc>>().ok())
.unwrap_or(created_at); .unwrap_or(created_at);
// Artikler med fremtidig publish_at er ikke tilgjengelige ennå
if publish_at > Utc::now() {
return Ok(None);
}
// Sjekk om det finnes rendret HTML i CAS // Sjekk om det finnes rendret HTML i CAS
let html_hash = node_metadata let html_hash = node_metadata
.get("rendered") .get("rendered")
@ -964,6 +968,7 @@ async fn fetch_index_articles_optimized(
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>); type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
// 1. Hero: slot = "hero", maks 1 // 1. Hero: slot = "hero", maks 1
// Ekskluderer artikler med fremtidig publish_at (planlagt publisering)
let hero_row: Option<Row> = sqlx::query_as( let hero_row: Option<Row> = sqlx::query_as(
r#" r#"
SELECT n.id, n.title, n.content, n.created_at, e.metadata SELECT n.id, n.title, n.content, n.created_at, e.metadata
@ -972,6 +977,8 @@ async fn fetch_index_articles_optimized(
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND e.metadata->>'slot' = 'hero' AND e.metadata->>'slot' = 'hero'
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
LIMIT 1 LIMIT 1
"#, "#,
) )
@ -992,6 +999,8 @@ async fn fetch_index_articles_optimized(
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND e.metadata->>'slot' = 'featured' AND e.metadata->>'slot' = 'featured'
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
ORDER BY (e.metadata->>'slot_order')::int ASC NULLS LAST ORDER BY (e.metadata->>'slot_order')::int ASC NULLS LAST
LIMIT $2 LIMIT $2
"#, "#,
@ -1017,6 +1026,8 @@ async fn fetch_index_articles_optimized(
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND (e.metadata->>'slot' IS NULL OR e.metadata->>'slot' = '') AND (e.metadata->>'slot' IS NULL OR e.metadata->>'slot' = '')
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
ORDER BY COALESCE( ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz, (e.metadata->>'publish_at')::timestamptz,
n.created_at n.created_at
@ -1323,12 +1334,18 @@ async fn fetch_presentation_elements_batch(
} }
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { if s.chars().count() <= max {
return s.to_string(); return s.to_string();
} }
match s[..max].rfind(' ') { // Finn char-boundary for maks lengde
let byte_pos = s.char_indices()
.nth(max)
.map(|(i, _)| i)
.unwrap_or(s.len());
// Finn siste mellomrom før grensen
match s[..byte_pos].rfind(' ') {
Some(pos) => format!("{}", &s[..pos]), Some(pos) => format!("{}", &s[..pos]),
None => format!("{}", &s[..max]), None => format!("{}", &s[..byte_pos]),
} }
} }
@ -1476,13 +1493,7 @@ pub async fn serve_index(
}); });
} }
// Bygg article_id → variant edge_ids map for klikk-attribusjon // Legg i cache med aktive A/B-varianter (for impression-logging)
let mut ab_article_variants: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for &(edge_id, article_id) in &ab_variants {
ab_article_variants.entry(article_id).or_default().push(edge_id);
}
// Legg i cache med aktive A/B-varianter
let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64); let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64);
{ {
let mut cache = state.index_cache.write().await; let mut cache = state.index_cache.write().await;
@ -1490,7 +1501,6 @@ pub async fn serve_index(
html: html.clone(), html: html.clone(),
expires_at, expires_at,
active_ab_variants: ab_variants, active_ab_variants: ab_variants,
ab_article_variants,
}); });
} }
@ -1531,25 +1541,11 @@ pub async fn serve_article(
})? })?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
// A/B klikk-attribusjon: sjekk om denne artikkelen har aktive varianter // A/B klikk-attribusjon er håndtert via track_click-endepunktet
// i den cachede forsiden, og logg klikk for dem. // (/pub/{slug}/t/{short_id}?v={edge_id}) som logger klikk for
if let Ok(uid) = fetched.article.id.parse::<Uuid>() { // den spesifikke varianten brukeren så. Direkte artikkelvisninger
let cache = state.index_cache.read().await; // uten variant-parameter logges ikke — dette forhindrer at
if let Some(cached) = cache.get(&collection.id) { // klikk attribueres feil variant.
if let Some(variant_edges) = cached.ab_article_variants.get(&uid) {
let db = state.db.clone();
let cid = collection.id;
let edges = variant_edges.clone();
let aid = uid;
drop(cache); // Release read lock before spawning
tokio::spawn(async move {
for edge_id in edges {
log_ab_click(&db, edge_id, aid, cid).await;
}
});
}
}
}
// Sjekk om pre-rendret HTML finnes i CAS // Sjekk om pre-rendret HTML finnes i CAS
if let Some(ref hash) = fetched.html_hash { if let Some(ref hash) = fetched.html_hash {
@ -2010,6 +2006,7 @@ pub async fn serve_category(
let tag_name = tag_title.unwrap_or_else(|| tag_slug.clone()); let tag_name = tag_title.unwrap_or_else(|| tag_slug.clone());
// Tell totalt antall artikler med denne taggen i samlingen // Tell totalt antall artikler med denne taggen i samlingen
// Ekskluderer artikler med fremtidig publish_at
let (total_count,): (i64,) = sqlx::query_as( let (total_count,): (i64,) = sqlx::query_as(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*)
@ -2019,6 +2016,8 @@ pub async fn serve_category(
AND e_belongs.edge_type = 'belongs_to' AND e_belongs.edge_type = 'belongs_to'
AND e_tag.target_id = $2 AND e_tag.target_id = $2
AND e_tag.edge_type = 'tagged' AND e_tag.edge_type = 'tagged'
AND (e_belongs.metadata->>'publish_at' IS NULL
OR (e_belongs.metadata->>'publish_at')::timestamptz <= now())
"#, "#,
) )
.bind(collection.id) .bind(collection.id)
@ -2042,6 +2041,8 @@ pub async fn serve_category(
AND e_belongs.edge_type = 'belongs_to' AND e_belongs.edge_type = 'belongs_to'
AND e_tag.target_id = $2 AND e_tag.target_id = $2
AND e_tag.edge_type = 'tagged' AND e_tag.edge_type = 'tagged'
AND (e_belongs.metadata->>'publish_at' IS NULL
OR (e_belongs.metadata->>'publish_at')::timestamptz <= now())
ORDER BY COALESCE( ORDER BY COALESCE(
(e_belongs.metadata->>'publish_at')::timestamptz, (e_belongs.metadata->>'publish_at')::timestamptz,
n.created_at n.created_at
@ -2150,12 +2151,14 @@ pub async fn serve_archive(
.unwrap()); .unwrap());
} }
// Tell totalt // Tell totalt (ekskluder fremtidige artikler)
let (total_count,): (i64,) = sqlx::query_as( let (total_count,): (i64,) = sqlx::query_as(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*)
FROM edges e FROM edges e
WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' WHERE e.target_id = $1 AND e.edge_type = 'belongs_to'
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
"#, "#,
) )
.bind(collection.id) .bind(collection.id)
@ -2175,6 +2178,8 @@ pub async fn serve_archive(
JOIN nodes n ON n.id = e.source_id JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
ORDER BY COALESCE( ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz, (e.metadata->>'publish_at')::timestamptz,
n.created_at n.created_at
@ -2332,6 +2337,8 @@ pub async fn serve_search(
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND n.search_vector @@ plainto_tsquery('norwegian', $2) AND n.search_vector @@ plainto_tsquery('norwegian', $2)
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
"#, "#,
) )
.bind(collection.id) .bind(collection.id)
@ -2353,6 +2360,8 @@ pub async fn serve_search(
WHERE e.target_id = $1 WHERE e.target_id = $1
AND e.edge_type = 'belongs_to' AND e.edge_type = 'belongs_to'
AND n.search_vector @@ plainto_tsquery('norwegian', $2) AND n.search_vector @@ plainto_tsquery('norwegian', $2)
AND (e.metadata->>'publish_at' IS NULL
OR (e.metadata->>'publish_at')::timestamptz <= now())
ORDER BY ts_rank(n.search_vector, plainto_tsquery('norwegian', $2)) DESC ORDER BY ts_rank(n.search_vector, plainto_tsquery('norwegian', $2)) DESC
LIMIT $3 OFFSET $4 LIMIT $3 OFFSET $4
"#, "#,
@ -3350,6 +3359,46 @@ mod tests {
assert_eq!(css, css_blogg); assert_eq!(css, css_blogg);
} }
#[test]
fn truncate_handles_multibyte_utf8() {
// Norsk tekst med æ, ø, å skal ikke panikke
let text = "Blåbærsyltetøy er en norsk spesialitet som smaker godt";
let result = truncate(text, 15);
assert!(result.ends_with('…') || result.len() <= 15 * 4);
// Sjekk at resultatet er gyldig UTF-8 (implisitt ved å bruke String)
assert!(!result.is_empty());
// Emoji / 4-byte chars
let emoji_text = "🎵🎶🎸🎹🎺🎻🎷 Musikk er flott";
let result2 = truncate(emoji_text, 5);
assert!(!result2.is_empty());
}
#[test]
fn truncate_short_string_unchanged() {
let text = "Kort tekst";
assert_eq!(truncate(text, 20), "Kort tekst");
}
#[test]
fn json_ld_escapes_script_tags() {
let article = ArticleData {
id: "xss".to_string(),
short_id: "xss12345".to_string(),
title: "Test</script><script>alert(1)".to_string(),
subtitle: None,
content: "Safe".to_string(),
summary: Some("Safe summary".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(),
};
let ld = build_json_ld(&article, "Pub", "https://example.com/xss");
// Skal ikke inneholde rå </script>
assert!(!ld.contains("</script>"), "JSON-LD inneholder uescaped </script>: {ld}");
assert!(ld.contains("<\\/script>"), "JSON-LD mangler escaped </script>: {ld}");
}
#[test] #[test]
fn z_test_insufficient_data_returns_1() { fn z_test_insufficient_data_returns_1() {
// For lite data: returnerer p=1.0 (ingen signifikans) // For lite data: returnerer p=1.0 (ingen signifikans)

View file

@ -75,7 +75,7 @@
line-height: 1.5; line-height: 1.5;
} }
.article-list__highlight { .article-list__highlight {
background: rgba(233, 69, 96, 0.1); background: color-mix(in srgb, var(--color-accent) 10%, transparent);
padding: 0 0.15rem; padding: 0 0.15rem;
border-radius: 2px; border-radius: 2px;
} }

View file

@ -300,8 +300,7 @@ med spesifikasjon for det som trenger en dedikert sesjon.
- [x] 23.3 Valider fase 58 (kommunikasjon + CAS + lyd + aliaser): chat-loop, kontekst-arv, CAS-hashing/deduplisering, Whisper-pipeline, segmenttabell, SRT-eksport, alias-identitet. - [x] 23.3 Valider fase 58 (kommunikasjon + CAS + lyd + aliaser): chat-loop, kontekst-arv, CAS-hashing/deduplisering, Whisper-pipeline, segmenttabell, SRT-eksport, alias-identitet.
- [x] 23.4 Valider fase 910 (visninger + AI): kanban drag-and-drop, kalender, dagbok, kunnskapsgraf, LiteLLM-ruting, AI-foreslåtte edges, oppsummering, TTS. - [x] 23.4 Valider fase 910 (visninger + AI): kanban drag-and-drop, kalender, dagbok, kunnskapsgraf, LiteLLM-ruting, AI-foreslåtte edges, oppsummering, TTS.
- [x] 23.5 Valider fase 11 (produksjon): LiveKit-oppsett, sanntidslyd, pruning-logikk, podcast-RSS. - [x] 23.5 Valider fase 11 (produksjon): LiveKit-oppsett, sanntidslyd, pruning-logikk, podcast-RSS.
- [~] 23.6 Valider fase 1314 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing. - [x] 23.6 Valider fase 1314 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing.
> Påbegynt: 2026-03-18T15:16
- [ ] 23.7 Valider fase 1516 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter. - [ ] 23.7 Valider fase 1516 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter.
- [ ] 23.8 Valider fase 1718 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon. - [ ] 23.8 Valider fase 1718 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon.
- [ ] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio). - [ ] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).