Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

Ship CSP; redesign source/replicated as real heatmap cells

CSP: default-src 'none'; style-src 'unsafe-inline'; img-src 'self';
connect-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'. Validated with Playwright against the actual
dashboard/social HTML before shipping: a negative control (no style-src
allowance) reproduced the predicted breakage — transparent background,
no card borders, a genuine CSP console violation — proving the test
methodology catches what it's meant to; the shipped policy renders
identically to the no-CSP baseline, confirmed both locally and against
the live production response. style-src allows inline because both
pages' entire stylesheet is one static <style> block nothing is ever
injected into — no practical downside to allowing it specifically,
versus a per-page hash that would silently break on the next CSS edit.

Dashboard: the "source"/"replicated" states no longer render as a
separate pill that breaks the heatmap's visual language. Each side that
holds content without a graft-logged push of its own now gets real
cells — same shape as a commit/issue/patch cell, same hover tooltip,
just a different color (new --kind-source/--kind-replicated cell
colors, previously only used in the legend). Built from a dedup map of
every distinct event logged anywhere in the series (keyed by commit SHA
where content-addressed, kind+summary otherwise), so a side sees exactly
the same commits/issues its siblings logged — with a same-host link
reconstructed from the commit SHA where possible, and a tooltip note
naming which side actually logged the push and when.

+150 -64
+13 -8
cmd/sync/main.go
··· 252 252 // this wraps every route graft serves (dashboard, /social, ActivityPub, 253 253 // /inbox) and a bug here would affect all of them at once. 254 254 // 255 - // CSP is intentionally left out: the dashboard's entire stylesheet is one 256 - // inline <style> block, which a naive CSP would break outright (style-src 257 - // blocks inline styles by default) without a nonce wired through every 258 - // template render — worth doing properly later, not as a blanket header. 259 - // Cross-Origin-Embedder-Policy is left out too: it exists to protect 260 - // cross-origin-isolated contexts (SharedArrayBuffer, WASM threads) that 261 - // graft has no use for, and would only be a trap for a future contributor 262 - // who adds an external resource without thinking to CORS-enable it. 255 + // Cross-Origin-Embedder-Policy is deliberately left out: it exists to 256 + // protect cross-origin-isolated contexts (SharedArrayBuffer, WASM threads) 257 + // that graft has no use for, and would only be a trap for a future 258 + // contributor who adds an external resource without thinking to 259 + // CORS-enable it. 263 260 func securityHeaders(next http.Handler) http.Handler { 264 261 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 265 262 h := w.Header() ··· 271 268 // 180 days, no includeSubDomains (f1/f2/radicle aren't verified 272 269 // all-HTTPS as a unit) and no preload (effectively irreversible). 273 270 h.Set("Strict-Transport-Security", "max-age=15552000") 271 + // Tested locally (Playwright, real dashboard/social HTML, with a 272 + // negative control proving the test actually catches breakage) 273 + // before shipping: default-src 'none' blocks all script execution 274 + // (the real XSS mitigation graft gets from this) without touching 275 + // rendering, since style-src 'unsafe-inline' covers the one inline 276 + // <style> block each page has — nothing else is ever injected into 277 + // it, so there's no practical downside to allowing it specifically. 278 + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") 274 279 next.ServeHTTP(w, r) 275 280 }) 276 281 }
+137 -56
internal/status/status.go
··· 307 307 // with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never 308 308 // one link picked arbitrarily to represent all of them. 309 309 type subRow struct { 310 - Label string 311 - URL string 312 - // EmptyState explains why this side shows no events even though it may 313 - // already hold the mirrored content — empty when Events is non-empty, 314 - // or when the whole series has genuinely never been touched. "source" 315 - // marks the Forgejo side changes originate on (graft only logs the 316 - // pushes it makes, never the pushes it reads from); "replicated" marks 317 - // a side that received the content some other way graft doesn't log 318 - // itself — Radicle's own peer-to-peer gossip between nodes, or a 319 - // Forgejo-to-Forgejo Authorized Integration. 320 - EmptyState string 321 - Events []event 310 + Label string 311 + URL string 312 + Events []event 322 313 } 323 314 324 - // event is one activity_log row: one heatmap cell, one tooltip, one link. 315 + // event is one heatmap cell, one tooltip, one link — either a real 316 + // activity_log row, or (when State is set) a synthetic cell standing in for 317 + // content this side plainly holds without graft ever having logged a push 318 + // to it directly: the Forgejo side changes originate on ("source"), or a 319 + // side that received the content some other way graft doesn't log itself — 320 + // Radicle's own peer-to-peer gossip, or a Forgejo-to-Forgejo Authorized 321 + // Integration ("replicated"). Synthetic cells reuse the real event they 322 + // stand in for (same kind, date, summary) so they read exactly like a 323 + // normal commit/issue/patch cell, just in a different color, with the 324 + // tooltip naming where it actually happened. 325 325 type event struct { 326 - Kind string // "git", "issue", "patch" — drives the cell's color 327 - Label string // "Commit", "Issue", "Patch" 328 - Text string 329 - URL string 330 - Server string // which mirrored side this happened on, e.g. "f1", "alice" 331 - When string // human-readable timestamp, for the tooltip 326 + Kind string // "git", "issue", "patch" — drives the cell's color when State is unset 327 + Label string // "Commit", "Issue", "Patch" 328 + Text string 329 + URL string 330 + Server string // which mirrored side this happened on, e.g. "f1", "alice" 331 + When string // human-readable timestamp, for the tooltip 332 + State string // "" for a real event; "source" or "replicated" for a synthetic one — drives the cell's color instead of Kind 333 + OriginLabel string // for a synthetic cell, the sub-row label the real event was actually logged on 332 334 } 333 335 334 336 type commitLine struct { ··· 457 459 subOrder := append([]string(nil), sa.subOrder...) 458 460 sort.Strings(subOrder) 459 461 462 + // distinctEvents dedupes every event logged anywhere in the series 463 + // down to one representative per real underlying commit/issue/patch 464 + // — keyed by commit SHA for git (content-addressed, stable across 465 + // every mirror), or by (kind, summary) otherwise — keeping 466 + // whichever occurrence was logged earliest, since that's the 467 + // closest graft gets to "when this actually entered the series". 460 468 // A series with at least one logged event anywhere means every 461 - // currently-silent side already holds the mirrored content too — 462 - // either as the origin, or received via a path graft doesn't log 463 - // itself (Radicle gossip, an Authorized Integration). Only a 464 - // series with zero events anywhere is genuinely untouched. 465 - anyEvents := false 469 + // currently-silent side already holds this content too — either 470 + // as the origin, or received via a path graft doesn't log itself 471 + // (Radicle gossip, an Authorized Integration) — so those sides 472 + // get synthetic cells built from this map instead of the usual 473 + // per-event ones. Only a series with zero events anywhere stays 474 + // genuinely empty. 475 + type rep struct { 476 + entry state.ActivityEntry 477 + label string 478 + } 479 + reps := map[string]rep{} 466 480 for _, label := range subOrder { 467 - if len(sa.subs[label].events) > 0 { 468 - anyEvents = true 469 - break 481 + for _, e := range sa.subs[label].events { 482 + key := dedupKey(e.Kind, e.URL, e.Summary) 483 + cur, ok := reps[key] 484 + if !ok || e.OccurredAt.Before(cur.entry.OccurredAt) { 485 + reps[key] = rep{entry: e, label: label} 486 + } 470 487 } 471 488 } 489 + repKeys := make([]string, 0, len(reps)) 490 + for k := range reps { 491 + repKeys = append(repKeys, k) 492 + } 493 + sort.Slice(repKeys, func(i, j int) bool { 494 + return reps[repKeys[i]].entry.OccurredAt.Before(reps[repKeys[j]].entry.OccurredAt) 495 + }) 472 496 473 497 subRows := make([]subRow, 0, len(subOrder)) 474 498 for _, label := range subOrder { 475 499 sub := sa.subs[label] 500 + if len(sub.events) == 0 && len(reps) > 0 { 501 + cellState := "source" 502 + if sub.ai || sub.radicle { 503 + cellState = "replicated" 504 + } 505 + synth := make([]event, 0, len(repKeys)) 506 + for _, k := range repKeys { 507 + r := reps[k] 508 + synth = append(synth, event{ 509 + Kind: r.entry.Kind, 510 + Label: kindLabel(r.entry.Kind), 511 + Text: r.entry.Summary, 512 + URL: reconstructLink(sub.url, r.entry.Kind, r.entry.URL), 513 + Server: label, 514 + When: r.entry.OccurredAt.Format("Mon, Jan 2, 15:04"), 515 + State: cellState, 516 + OriginLabel: r.label, 517 + }) 518 + } 519 + subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: synth}) 520 + continue 521 + } 522 + 476 523 // entries arrive newest-first (see ActivitySince); the 477 524 // heatmap reads left-to-right as oldest-to-newest. 478 525 es := make([]state.ActivityEntry, len(sub.events)) 479 526 copy(es, sub.events) 480 527 sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) 481 - 482 - emptyState := "" 483 - if len(es) == 0 && anyEvents { 484 - switch { 485 - case sub.ai, sub.radicle: 486 - emptyState = "replicated" 487 - default: 488 - emptyState = "source" 489 - } 490 - } 491 - subRows = append(subRows, subRow{Label: label, URL: sub.url, EmptyState: emptyState, Events: toEvents(es)}) 528 + subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)}) 492 529 } 493 530 494 531 ok, known := health[name] ··· 583 620 return "" 584 621 } 585 622 623 + // commitSHA pulls the trailing commit hash out of a git-item URL, e.g. 624 + // ".../owner/repo/commit/<sha>" or ".../repo/commits/<sha>" -> "<sha>". 625 + // Empty if rawURL isn't a recognized commit link. 626 + func commitSHA(rawURL string) string { 627 + for _, marker := range []string{"/commit/", "/commits/"} { 628 + idx := strings.Index(rawURL, marker) 629 + if idx < 0 { 630 + continue 631 + } 632 + sha := rawURL[idx+len(marker):] 633 + if i := strings.IndexAny(sha, "/?#"); i >= 0 { 634 + sha = sha[:i] 635 + } 636 + return sha 637 + } 638 + return "" 639 + } 640 + 641 + // dedupKey identifies "the same underlying event" across every mirror it 642 + // got logged on. A commit is content-addressed — its SHA is identical on 643 + // every side it's mirrored to — so that's the stable key for git events. 644 + // Issues and patches get local, per-instance IDs that aren't portable 645 + // across hosts, so their summary text is the best available stand-in. 646 + func dedupKey(kind, url, summary string) string { 647 + if sha := commitSHA(url); sha != "" { 648 + return kind + ":" + sha 649 + } 650 + return kind + ":" + summary 651 + } 652 + 653 + // reconstructLink builds a same-host link for a synthetic cell: a commit's 654 + // SHA is identical everywhere it's mirrored, so subURL (this side's own 655 + // repo root) plus the right per-host item path reaches the same commit on 656 + // this side specifically, rather than sending the reader to the side it 657 + // was actually logged on. Falls back to the original event's own URL for 658 + // anything that isn't a reconstructible git link — an issue or patch ID 659 + // isn't portable across hosts, so linking to where it's actually visible 660 + // beats guessing. 661 + func reconstructLink(subURL, kind, originURL string) string { 662 + sha := commitSHA(originURL) 663 + if kind != "git" || sha == "" || subURL == "" { 664 + return originURL 665 + } 666 + if strings.Contains(subURL, "/nodes/") { 667 + return subURL + "/commits/" + sha 668 + } 669 + return subURL + "/commit/" + sha 670 + } 671 + 586 672 var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 587 673 <html lang="en"> 588 674 <head> ··· 678 764 .cell[data-kind="git"] { background: var(--kind-git); } 679 765 .cell[data-kind="issue"] { background: var(--kind-issue); } 680 766 .cell[data-kind="patch"] { background: var(--kind-patch); } 767 + .cell[data-kind="source"] { background: var(--kind-source); } 768 + .cell[data-kind="replicated"] { background: var(--kind-replicated); } 681 769 682 770 .tip { 683 771 display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%); ··· 697 785 .tip a.tip-row:hover { background: rgba(255,255,255,.12); } 698 786 .tip .tip-kind { color: #b3bac5; flex: none; } 699 787 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; } 700 - 701 - .state-pill { 702 - display: inline-flex; align-items: center; gap: .35rem; 703 - font-size: .74rem; padding: .2rem .6rem; border-radius: 999px; 788 + .tip .tip-note { 789 + padding: .35rem .75rem 0; margin-top: .3rem; border-top: 1px solid rgba(255,255,255,.15); 790 + color: #b3bac5; font-size: .7rem; line-height: 1.4; 704 791 } 705 - .state-pill .state-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } 706 - .state-pill.state-source { background: rgba(255,153,31,.14); color: #974f0c; } 707 - .state-pill.state-source .state-dot { background: var(--kind-source); } 708 - .state-pill.state-replicated { background: rgba(0,184,217,.14); color: #0868a0; } 709 - .state-pill.state-replicated .state-dot { background: var(--kind-replicated); } 710 792 711 793 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; } 712 794 .commits { border-top: 1px solid var(--border); } ··· 776 858 {{end}} 777 859 <div class="days"> 778 860 {{range .Events}} 779 - <div class="cell" data-kind="{{.Kind}}"> 861 + <div class="cell" data-kind="{{if .State}}{{.State}}{{else}}{{.Kind}}{{end}}"> 780 862 <div class="tip"> 781 863 <div class="tip-date">{{.When}}</div> 782 864 {{if .URL}} 783 - <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 865 + <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}{{if eq .State "source"}} · source{{else if eq .State "replicated"}} · replicated{{end}}</span>{{.Text}}</a> 784 866 {{else}} 785 - <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 867 + <span class="tip-row"><span class="tip-kind">{{.Label}}{{if eq .State "source"}} · source{{else if eq .State "replicated"}} · replicated{{end}}</span>{{.Text}}</span> 868 + {{end}} 869 + {{if eq .State "source"}} 870 + <div class="tip-note">origin of this content — graft mirrored it out from here, via {{.OriginLabel}}</div> 871 + {{else if eq .State "replicated"}} 872 + <div class="tip-note">replicated here without a graft-logged push — originally pushed via {{.OriginLabel}}, {{.When}}</div> 786 873 {{end}} 787 874 </div> 788 875 </div> 789 876 {{else}} 790 - {{if eq .EmptyState "source"}} 791 - <span class="state-pill state-source" title="content originates here — graft only logs the pushes it makes, not the pushes it reads from"><span class="state-dot"></span>source</span> 792 - {{else if eq .EmptyState "replicated"}} 793 - <span class="state-pill state-replicated" title="content arrived here without a graft-logged push — Radicle peer-to-peer gossip, or a Forgejo Authorized Integration"><span class="state-dot"></span>replicated</span> 794 - {{else}} 795 877 <span class="tip-empty">nothing synced</span> 796 - {{end}} 797 878 {{end}} 798 879 </div> 799 880 </div>