fierj
PublicTiny personal git forge
aa2e312716d1d1a55b611dad7e73e6ea250ca41a
diff --git a/ap.go b/ap.go
index 1e905b6..bfcb661 100644
--- a/ap.go
+++ b/ap.go
@@ -1,24 +1,1000 @@
package main
-import "log/slog"
+import (
+ "crypto"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
const apContext = "https://www.w3.org/ns/activitystreams"
+const apPublic = "https://www.w3.org/ns/activitystreams#Public"
+const apContentType = "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\""
+const jrdContentType = "application/jrd+json"
-type APObject struct {
- Context string `json:"@context"`
- ID string `json:"id"`
+// ----- data types -----
+
+// APActor represents an ActivityPub actor (Service type) for a repo.
+type APActor struct {
+ Context string `json:"@context"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ PreferredUsername string `json:"preferredUsername"`
+ Name string `json:"name,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ URL string `json:"url,omitempty"`
+ Inbox string `json:"inbox"`
+ Outbox string `json:"outbox"`
+ Followers string `json:"followers"`
+ PublicKey APKey `json:"publicKey"`
+}
+
+// APKey is the public key block embedded in the actor.
+type APKey struct {
+ ID string `json:"id"`
+ Owner string `json:"owner"`
+ PublicKeyPem string `json:"publicKeyPem"`
+}
+
+// APActivity is a generic ActivityPub activity.
+type APActivity struct {
+ Context string `json:"@context"`
+ ID string `json:"id,omitempty"`
+ Type string `json:"type"`
+ Actor string `json:"actor,omitempty"`
+ Object any `json:"object,omitempty"`
+}
+
+// APCreate wraps a Create activity.
+type APCreate struct {
+ Context string `json:"@context"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Actor string `json:"actor"`
+ Published string `json:"published"`
+ To []string `json:"to"`
+ CC []string `json:"cc,omitempty"`
+ Object *APNote `json:"object"`
+}
+
+// APNote is a Note object (used for threads/replies).
+type APNote struct {
+ ID string `json:"id,omitempty"`
Type string `json:"type"`
Published string `json:"published,omitempty"`
AttributedTo string `json:"attributedTo,omitempty"`
Content string `json:"content,omitempty"`
URL string `json:"url,omitempty"`
+ InReplyTo string `json:"inReplyTo,omitempty"`
To []string `json:"to,omitempty"`
CC []string `json:"cc,omitempty"`
}
+// APOrderedCollection is used for outbox and followers.
+type APOrderedCollection struct {
+ Context string `json:"@context"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ TotalItems int `json:"totalItems"`
+ First string `json:"first,omitempty"`
+ OrderedItems []any `json:"orderedItems,omitempty"`
+}
+
+// APMeta holds per-repo ActivityPub state, stored in .forge/ap.json.
+type APMeta struct {
+ PublicKey string `json:"public_key"`
+ PrivateKey string `json:"private_key"`
+ Followers []string `json:"followers,omitempty"`
+}
+
+// ----- key management -----
+
+func apMetaPath(repoPath string) string {
+ return filepath.Join(repoPath, ".forge", "ap.json")
+}
+
+var apMetaLocks sync.Map
+
+func loadOrCreateAPMeta(repoPath string) (*APMeta, error) {
+ mu := &sync.Mutex{}
+ actual, _ := apMetaLocks.LoadOrStore(repoPath, mu)
+ mu = actual.(*sync.Mutex)
+ mu.Lock()
+ defer mu.Unlock()
+
+ dir := filepath.Dir(apMetaPath(repoPath))
+ os.MkdirAll(dir, 0755)
+
+ data, err := os.ReadFile(apMetaPath(repoPath))
+ if err == nil {
+ var meta APMeta
+ if json.Unmarshal(data, &meta) == nil && meta.PrivateKey != "" {
+ return &meta, nil
+ }
+ }
+
+ // Generate an RSA 2048-bit keypair (the de facto AP standard).
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return nil, fmt.Errorf("generate key: %w", err)
+ }
+
+ pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+ if err != nil {
+ return nil, fmt.Errorf("marshal pub: %w", err)
+ }
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ privBytes, err := x509.MarshalPKCS8PrivateKey(rsaKey)
+ if err != nil {
+ return nil, fmt.Errorf("marshal priv: %w", err)
+ }
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ meta := &APMeta{
+ PublicKey: string(pubPEM),
+ PrivateKey: string(privPEM),
+ Followers: nil,
+ }
+
+ b, _ := json.MarshalIndent(meta, "", " ")
+ tmp := apMetaPath(repoPath) + ".tmp"
+ os.WriteFile(tmp, b, 0600)
+ os.Rename(tmp, apMetaPath(repoPath))
+ return meta, nil
+}
+
+func parseEd25519Private(pemData string) (ed25519.PrivateKey, error) {
+ priv, err := parsePrivateKey(pemData)
+ if err != nil {
+ return nil, err
+ }
+ ed, ok := priv.(ed25519.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("not ed25519")
+ }
+ return ed, nil
+}
+
+// parsePrivateKey parses a PEM-encoded private key, returning either
+// *rsa.PrivateKey or ed25519.PrivateKey.
+func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
+ block, _ := pem.Decode([]byte(pemData))
+ if block == nil {
+ return nil, fmt.Errorf("bad PEM")
+ }
+ priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ switch priv.(type) {
+ case *rsa.PrivateKey, ed25519.PrivateKey:
+ return priv, nil
+ default:
+ return nil, fmt.Errorf("unsupported key type: %T", priv)
+ }
+}
+
+// parsePublicKey parses a PEM-encoded public key, returning either an
+// *rsa.PublicKey or ed25519.PublicKey.
+func parsePublicKey(pemData string) (crypto.PublicKey, error) {
+ block, _ := pem.Decode([]byte(pemData))
+ if block == nil {
+ return nil, fmt.Errorf("bad PEM")
+ }
+ pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ switch pub.(type) {
+ case *rsa.PublicKey, ed25519.PublicKey:
+ return pub, nil
+ default:
+ return nil, fmt.Errorf("unsupported key type: %T", pub)
+ }
+}
+
+func parseEd25519Public(pemData string) (ed25519.PublicKey, error) {
+ pub, err := parsePublicKey(pemData)
+ if err != nil {
+ return nil, err
+ }
+ ed, ok := pub.(ed25519.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("not ed25519")
+ }
+ return ed, nil
+}
+
+// ----- URL helpers -----
+
func actorURL(host, repoName string) string {
return "https://" + host + "/" + repoName + "/ap/actor"
}
+func inboxURL(host, repoName string) string {
+ return "https://" + host + "/" + repoName + "/ap/inbox"
+}
+func outboxURL(host, repoName string) string {
+ return "https://" + host + "/" + repoName + "/ap/outbox"
+}
+func followersURL(host, repoName string) string {
+ return "https://" + host + "/" + repoName + "/ap/followers"
+}
+
+// ----- WebFinger -----
+
+type jrd struct {
+ Subject string `json:"subject"`
+ Links []jrdLink `json:"links"`
+}
+type jrdLink struct {
+ Rel string `json:"rel"`
+ Type string `json:"type,omitempty"`
+ Href string `json:"href,omitempty"`
+}
+
+func WebFinger(cfg Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cfg.Host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ resource := r.URL.Query().Get("resource")
+ if resource == "" || !strings.HasPrefix(resource, "acct:") {
+ http.NotFound(w, r)
+ return
+ }
+ acct := strings.TrimPrefix(resource, "acct:")
+ // Format: repo@host
+ parts := strings.SplitN(acct, "@", 2)
+ // Accept if the acct host matches our configured host, or if our
+ // host isn't set (development), accept the request's Host header.
+ if len(parts) != 2 {
+ http.NotFound(w, r)
+ return
+ }
+ acctHost := parts[1]
+ if acctHost != cfg.Host {
+ // Also accept if the acct uses the request's Host header
+ // (common behind reverse proxies).
+ if acctHost != r.Host && !strings.HasPrefix(r.Host, acctHost) {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ repoName := parts[0]
+
+ // Verify repo exists.
+ repoPath := filepath.Join(cfg.Dir, repoName+".git")
+ if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+
+ result := jrd{
+ Subject: resource,
+ Links: []jrdLink{{
+ Rel: "self",
+ Type: "application/activity+json",
+ Href: actorURL(cfg.Host, repoName),
+ }},
+ }
+
+ w.Header().Set("Content-Type", jrdContentType)
+ json.NewEncoder(w).Encode(result)
+ }
+}
+
+// ----- Actor -----
+
+func APActorHandler(cfg Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cfg.Host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ repoName := r.PathValue("repo")
+ repoPath := repoPath(cfg, repoName)
+
+ meta, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ slog.Error("ap actor: load meta", "repo", repoName, "error", err)
+ http.Error(w, "internal error", 500)
+ return
+ }
+
+ actorID := actorURL(cfg.Host, repoName)
+ git := &Git{Dir: cfg.Dir, Name: repoName}
+
+ actor := APActor{
+ Context: apContext,
+ ID: actorID,
+ Type: "Service",
+ PreferredUsername: repoName,
+ Name: repoName,
+ Summary: git.Description(),
+ URL: "https://" + cfg.Host + "/" + repoName,
+ Inbox: inboxURL(cfg.Host, repoName),
+ Outbox: outboxURL(cfg.Host, repoName),
+ Followers: followersURL(cfg.Host, repoName),
+ PublicKey: APKey{
+ ID: actorID + "#main-key",
+ Owner: actorID,
+ PublicKeyPem: meta.PublicKey,
+ },
+ }
+
+ w.Header().Set("Content-Type", apContentType)
+ json.NewEncoder(w).Encode(actor)
+ }
+}
+
+// ----- Inbox -----
+
+func APInbox(cfg Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cfg.Host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ repoName := r.PathValue("repo")
+ repoPath := repoPath(cfg, repoName)
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "bad request", 400)
+ return
+ }
+ defer r.Body.Close()
+
+ // Parse the activity first so we can use its actor field as a
+ // fallback for public key discovery.
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ http.Error(w, "bad json", 400)
+ return
+ }
+ activityActor, _ := raw["actor"].(string)
+
+ // Verify HTTP signature. If verification fails, reject the activity.
+ sigHeader := r.Header.Get("Signature")
+ if sigHeader == "" {
+ http.Error(w, "missing signature", 401)
+ return
+ }
+
+ // Try RFC 9421 format first (sig1= in Signature + Signature-Input).
+ if sigInput := r.Header.Get("Signature-Input"); sigInput != "" && strings.Contains(sigHeader, "sig1=") {
+ if err := verifyRFC9421(sigHeader, sigInput, activityActor, r, body); err != nil {
+ slog.Warn("ap inbox: rfc9421 verify", "error", err)
+ http.Error(w, "invalid signature", 401)
+ return
+ }
+ } else {
+ // Legacy draft-cavage format.
+ sig, err := parseSignature(sigHeader)
+ if err != nil {
+ slog.Warn("ap inbox: bad signature header", "error", err)
+ http.Error(w, "invalid signature", 401)
+ return
+ }
+ pubKey, err := fetchPublicKeyFallback(sig.KeyID, activityActor)
+ if err != nil {
+ slog.Warn("ap inbox: fetch key", "keyId", sig.KeyID, "error", err)
+ http.Error(w, "cannot verify signature", 401)
+ return
+ }
+ if err := verifySignature(sig, pubKey, r, body); err != nil {
+ slog.Warn("ap inbox: signature verify", "error", err)
+ http.Error(w, "invalid signature", 401)
+ return
+ }
+ }
+
+ activityType, _ := raw["type"].(string)
+ actor, _ := raw["actor"].(string)
+
+ switch activityType {
+ case "Create":
+ obj, ok := raw["object"].(map[string]any)
+ if !ok {
+ http.Error(w, "bad object", 400)
+ return
+ }
+ objType, _ := obj["type"].(string)
+ if objType != "Note" {
+ http.Error(w, "unsupported object type", 400)
+ return
+ }
+ handleCreateNote(cfg, repoName, actor, obj)
+
+ case "Follow":
+ if actor != "" {
+ addFollower(repoPath, actor)
+ slog.Info("ap: new follower", "repo", repoName, "actor", actor)
+ // Deliver Accept to the follower's inbox (required by AP spec).
+ go sendAccept(cfg, repoName, actor, raw)
+ // Return 200 immediately; Mastodon considers the follow
+ // accepted when it receives the Accept in its inbox.
+ w.WriteHeader(200)
+ return
+ }
+
+ case "Undo":
+ obj, ok := raw["object"].(map[string]any)
+ if ok {
+ undoType, _ := obj["type"].(string)
+ if undoType == "Follow" {
+ undoActor, _ := obj["actor"].(string)
+ if undoActor != "" {
+ removeFollower(repoPath, undoActor)
+ slog.Info("ap: removed follower", "repo", repoName, "actor", undoActor)
+ }
+ }
+ }
+
+ default:
+ slog.Info("ap inbox: unhandled activity", "type", activityType)
+ }
+
+ w.WriteHeader(200)
+ }
+}
+
+// handleCreateNote processes an incoming Create/Note activity, creating a
+// thread or reply in the target repo.
+func handleCreateNote(cfg Config, repoName, actor string, obj map[string]any) {
+ content, _ := obj["content"].(string)
+ contentPreview := content
+ if len(contentPreview) > 200 {
+ contentPreview = contentPreview[:200]
+ }
+ slog.Info("ap inbox: create note", "actor", actor, "contentLen", len(content), "inReplyTo", obj["inReplyTo"], "content", contentPreview)
+ if content == "" {
+ return
+ }
+
+ // Content from Mastodon is HTML. Keep it for the body (rendered
+ // client-side), but strip tags for the title.
+ summary, _ := obj["summary"].(string)
+
+ rp := repoPath(cfg, repoName)
+
+ // Check if this is a reply to a fierj thread or reply. inReplyTo must be
+ // a fierj thread URL like https://host/repo/threads/<id>[#reply-<rid>].
+ inReplyTo, _ := obj["inReplyTo"].(string)
+ if inReplyTo != "" {
+ if idx := strings.Index(inReplyTo, "/threads/"); idx >= 0 {
+ rest := inReplyTo[idx+len("/threads/"):]
+ // Extract thread ID and optional parent reply ID from fragment.
+ threadID := rest
+ parentID := ""
+ if hash := strings.Index(rest, "#"); hash >= 0 {
+ threadID = rest[:hash]
+ frag := rest[hash+1:]
+ if strings.HasPrefix(frag, "reply-") {
+ parentID = strings.TrimPrefix(frag, "reply-")
+ }
+ }
+ if slash := strings.Index(threadID, "/"); slash >= 0 {
+ threadID = threadID[:slash]
+ }
+ if err := addNestedReply(rp, threadID, parentID, content, actor, actor); err != nil {
+ slog.Warn("ap inbox: reply failed", "thread", threadID, "error", err)
+ return
+ }
+ slog.Info("ap inbox: reply added", "repo", repoName, "thread", threadID, "parent", parentID)
+ return
+ }
+ // Not a fierj thread URL — treat as a new thread.
+ }
+
+ // Create a new thread. Use summary (Content Warning) as title when
+ // present, otherwise strip HTML from the first line of content.
+ title := summary
+ if title == "" {
+ title = stripHTML(content)
+ if idx := strings.Index(title, "\n"); idx >= 0 {
+ title = strings.TrimSpace(title[:idx])
+ }
+ if len(title) > 120 {
+ title = title[:120] + "…"
+ }
+ }
+
+ t, err := createThread(rp, title, content, actor, actor)
+ if err != nil {
+ slog.Warn("ap inbox: create thread failed", "error", err)
+ return
+ }
+ slog.Info("ap inbox: thread created", "repo", repoName, "thread", t.ID)
+}
+
+// stripHTML removes HTML tags from a string (simple approach for titles).
+func stripHTML(s string) string {
+ var out strings.Builder
+ inTag := false
+ for _, r := range s {
+ if r == '<' {
+ inTag = true
+ } else if r == '>' {
+ inTag = false
+ } else if !inTag {
+ out.WriteRune(r)
+ }
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// ----- Outbox -----
+
+func APOutbox(cfg Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cfg.Host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ repoName := r.PathValue("repo")
+ rp := repoPath(cfg, repoName)
+ actor := actorURL(cfg.Host, repoName)
+
+ // Gather activities: thread creates from threads dir.
+ var items []any
+ threads, _, _ := listThreadsFiltered(rp, "")
+ for _, t := range threads {
+ threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+ note := APNote{
+ ID: threadURL,
+ Type: "Note",
+ Published: t.Created,
+ AttributedTo: t.Author,
+ Content: t.Title + "\n\n" + t.Body,
+ URL: threadURL,
+ To: []string{apPublic},
+ CC: []string{followersURL(cfg.Host, repoName)},
+ }
+ items = append(items, map[string]any{
+ "id": threadURL + "/activity",
+ "type": "Create",
+ "actor": actor,
+ "published": t.Created,
+ "to": []string{apPublic},
+ "cc": []string{followersURL(cfg.Host, repoName)},
+ "object": note,
+ })
+ }
+
+ coll := APOrderedCollection{
+ Context: apContext,
+ ID: outboxURL(cfg.Host, repoName),
+ Type: "OrderedCollection",
+ TotalItems: len(items),
+ OrderedItems: items,
+ }
+
+ w.Header().Set("Content-Type", apContentType)
+ json.NewEncoder(w).Encode(coll)
+ }
+}
+
+// ----- Followers -----
+
+func APFollowers(cfg Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cfg.Host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ repoName := r.PathValue("repo")
+ repoPath := repoPath(cfg, repoName)
+
+ meta, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ slog.Error("ap followers: load meta", "repo", repoName, "error", err)
+ http.Error(w, "internal error", 500)
+ return
+ }
+
+ items := make([]any, len(meta.Followers))
+ for i, f := range meta.Followers {
+ items[i] = f
+ }
+
+ coll := APOrderedCollection{
+ Context: apContext,
+ ID: followersURL(cfg.Host, repoName),
+ Type: "OrderedCollection",
+ TotalItems: len(items),
+ OrderedItems: items,
+ }
+
+ w.Header().Set("Content-Type", apContentType)
+ json.NewEncoder(w).Encode(coll)
+ }
+}
+
+var followerLocks sync.Map
+
+func addFollower(repoPath, followerURL string) error {
+ mu := &sync.Mutex{}
+ actual, _ := followerLocks.LoadOrStore(repoPath, mu)
+ mu = actual.(*sync.Mutex)
+ mu.Lock()
+ defer mu.Unlock()
+
+ meta, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ return err
+ }
+ for _, f := range meta.Followers {
+ if f == followerURL {
+ return nil // already exists
+ }
+ }
+ meta.Followers = append(meta.Followers, followerURL)
+ return saveAPMeta(repoPath, meta)
+}
+
+func removeFollower(repoPath, followerURL string) error {
+ mu := &sync.Mutex{}
+ actual, _ := followerLocks.LoadOrStore(repoPath, mu)
+ mu = actual.(*sync.Mutex)
+ mu.Lock()
+ defer mu.Unlock()
+
+ meta, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ return err
+ }
+ filtered := make([]string, 0, len(meta.Followers))
+ for _, f := range meta.Followers {
+ if f != followerURL {
+ filtered = append(filtered, f)
+ }
+ }
+ meta.Followers = filtered
+ return saveAPMeta(repoPath, meta)
+}
+
+func saveAPMeta(repoPath string, meta *APMeta) error {
+ b, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := apMetaPath(repoPath) + ".tmp"
+ if err := os.WriteFile(tmp, b, 0600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, apMetaPath(repoPath))
+}
+
+// ----- HTTP Signatures -----
+
+// httpSignature represents a parsed HTTP Signature header.
+type httpSignature struct {
+ KeyID string
+ Algorithm string
+ Headers []string
+ Signature []byte
+}
+
+// parseSignature parses an HTTP Signature header value.
+// Format: keyId="...",algorithm="...",headers="...",signature="..."
+func parseSignature(header string) (*httpSignature, error) {
+ s := &httpSignature{}
+ pairs := strings.Split(header, ",")
+ for _, pair := range pairs {
+ kv := strings.SplitN(strings.TrimSpace(pair), "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ key := kv[0]
+ val := strings.Trim(kv[1], `"`)
+ switch key {
+ case "keyId":
+ s.KeyID = val
+ case "algorithm":
+ s.Algorithm = val
+ case "headers":
+ s.Headers = strings.FieldsFunc(val, func(r rune) bool { return r == ' ' })
+ case "signature":
+ sig, err := base64.StdEncoding.DecodeString(val)
+ if err != nil {
+ return nil, fmt.Errorf("bad signature base64: %w", err)
+ }
+ s.Signature = sig
+ }
+ }
+ if s.KeyID == "" || len(s.Signature) == 0 {
+ return nil, fmt.Errorf("incomplete signature")
+ }
+ if s.Algorithm == "" {
+ s.Algorithm = "rsa-sha256" // default
+ }
+ return s, nil
+}
+
+// fetchPublicKey fetches an actor's public key from the remote server.
+// Returns either *rsa.PublicKey or ed25519.PublicKey.
+func fetchPublicKey(keyID string) (crypto.PublicKey, error) {
+ return fetchPublicKeyFallback(keyID, "")
+}
+
+// fetchPublicKeyFallback tries the actor URL first (more stable than keyID
+// which can use Mastodon's newer /ap/users/<id> format that returns 403/410),
+// then falls back to the keyID URL.
+func fetchPublicKeyFallback(keyID, actorURL string) (crypto.PublicKey, error) {
+ if actorURL != "" {
+ key, err := fetchPublicKeyFromURL(actorURL)
+ if err == nil {
+ return key, nil
+ }
+ slog.Info("ap: actor fetch failed, trying keyID", "actor", actorURL, "keyId", keyID, "error", err)
+ }
+ if keyID != "" {
+ return fetchPublicKeyFromURL(keyID)
+ }
+ return nil, fmt.Errorf("no key source")
+}
+
+func fetchPublicKeyFromURL(url string) (crypto.PublicKey, error) {
+ // Strip fragment for HTTP request; the fragment identifies the key within
+ // the actor document, not a separate URL.
+ reqURL := url
+ if idx := strings.Index(reqURL, "#"); idx >= 0 {
+ reqURL = reqURL[:idx]
+ }
+
+ req, err := http.NewRequest("GET", reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("fetch key: %w", err)
+ }
+ req.Header.Set("Accept", apContentType)
+ req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch key: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("fetch key: status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var actor struct {
+ PublicKey struct {
+ PublicKeyPem string `json:"publicKeyPem"`
+ } `json:"publicKey"`
+ }
+ if err := json.Unmarshal(body, &actor); err != nil {
+ return nil, fmt.Errorf("parse actor: %w", err)
+ }
+
+ return parsePublicKey(actor.PublicKey.PublicKeyPem)
+}
+
+// verifyRFC9421 verifies an RFC 9421 HTTP Message Signature.
+// sigHeader is the Signature header (sig1=:BASE64URL:).
+// sigInput is the Signature-Input header listing covered components.
+func verifyRFC9421(sigHeader, sigInput, actorURL string, r *http.Request, body []byte) error {
+ // Parse Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=...;keyid="..."
+ // Find the parenthesized list of covered components.
+ open := strings.Index(sigInput, "(")
+ close := strings.Index(sigInput, ")")
+ if open < 0 || close < 0 || close < open {
+ return fmt.Errorf("bad Signature-Input format")
+ }
+ componentsStr := sigInput[open+1 : close]
+ components := strings.Fields(strings.ReplaceAll(componentsStr, "\"", ""))
+
+ // Extract created and keyid params.
+ created := ""
+ keyID := ""
+ params := sigInput[close+1:]
+ for _, p := range strings.Split(params, ";") {
+ p = strings.TrimSpace(p)
+ if strings.HasPrefix(p, "created=") {
+ created = strings.Trim(p[len("created="):], `"`)
+ }
+ if strings.HasPrefix(p, "keyid=") {
+ keyID = strings.Trim(p[len("keyid="):], `"`)
+ }
+ }
+
+ // Build the signing string components.
+ var lines []string
+ for _, comp := range components {
+ switch comp {
+ case "@method":
+ lines = append(lines, `"@method": `+r.Method)
+ case "@target-uri":
+ targetURI := "https://" + r.Host + r.URL.Path
+ lines = append(lines, `"@target-uri": `+targetURI)
+ case "content-digest":
+ cd := r.Header.Get("Content-Digest")
+ if cd == "" {
+ return fmt.Errorf("missing Content-Digest header")
+ }
+ lines = append(lines, `"content-digest": `+cd)
+ case "host":
+ lines = append(lines, `"host": `+r.Host)
+ case "date":
+ lines = append(lines, `"date": `+r.Header.Get("Date"))
+ default:
+ // Generic header field.
+ v := r.Header.Get(comp)
+ if v == "" {
+ return fmt.Errorf("missing header %q", comp)
+ }
+ lines = append(lines, `"`+comp+`": `+v)
+ }
+ }
+
+ // Build @signature-params line.
+ paramsLine := "("
+ for i, comp := range components {
+ if i > 0 {
+ paramsLine += " "
+ }
+ paramsLine += `"` + comp + `"`
+ }
+ paramsLine += ")"
+ if created != "" {
+ paramsLine += ";created=" + created
+ }
+ if keyID != "" {
+ paramsLine += ";keyid=\"" + keyID + "\""
+ }
+ lines = append(lines, `"@signature-params": `+paramsLine)
+
+ signingString := strings.Join(lines, "\n")
+
+ // Extract the signature from sigHeader: sig1=:BASE64URL:
+ sigStart := strings.Index(sigHeader, "sig1=:")
+ if sigStart < 0 {
+ return fmt.Errorf("no sig1 in Signature header")
+ }
+ sigStart += len("sig1=:")
+ sigEnd := strings.Index(sigHeader[sigStart:], ":")
+ if sigEnd < 0 {
+ return fmt.Errorf("bad sig1 format")
+ }
+ sigB64 := sigHeader[sigStart : sigStart+sigEnd]
+ sig, err := base64.StdEncoding.DecodeString(sigB64)
+ if err != nil {
+ // Fallback: URL-safe encoding.
+ sig, err = base64.RawURLEncoding.DecodeString(sigB64)
+ if err != nil {
+ return fmt.Errorf("decode sig1: %w", err)
+ }
+ }
+
+ // Fetch the public key.
+ pubKey, err := fetchPublicKeyFallback(keyID, actorURL)
+ if err != nil {
+ return fmt.Errorf("fetch key: %w", err)
+ }
+
+ // Verify.
+ hash := sha256.Sum256([]byte(signingString))
+ switch k := pubKey.(type) {
+ case ed25519.PublicKey:
+ if !ed25519.Verify(k, hash[:], sig) {
+ return fmt.Errorf("ed25519 signature mismatch")
+ }
+ case *rsa.PublicKey:
+ if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig); err != nil {
+ return fmt.Errorf("rsa signature mismatch: %w", err)
+ }
+ default:
+ return fmt.Errorf("unsupported key type: %T", pubKey)
+ }
+ return nil
+}
+
+// verifySignature checks the HTTP signature against the request.
+// pub may be *rsa.PublicKey or ed25519.PublicKey.
+func verifySignature(sig *httpSignature, pub crypto.PublicKey, r *http.Request, body []byte) error {
+ // Build the signing string from the listed headers.
+ var parts []string
+ for _, h := range sig.Headers {
+ if h == "(request-target)" {
+ target := strings.ToLower(r.Method) + " " + r.URL.Path
+ parts = append(parts, "(request-target): "+target)
+ } else if h == "host" {
+ parts = append(parts, "host: "+r.Host)
+ } else if h == "digest" {
+ parts = append(parts, "digest: "+r.Header.Get("Digest"))
+ } else {
+ parts = append(parts, h+": "+r.Header.Get(h))
+ }
+ }
+ signingString := strings.Join(parts, "\n")
+
+ // Hash the signing string.
+ hash := sha256.Sum256([]byte(signingString))
+
+ switch k := pub.(type) {
+ case ed25519.PublicKey:
+ if !ed25519.Verify(k, hash[:], sig.Signature) {
+ return fmt.Errorf("ed25519 signature mismatch")
+ }
+ case *rsa.PublicKey:
+ if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig.Signature); err != nil {
+ return fmt.Errorf("rsa signature mismatch: %w", err)
+ }
+ default:
+ return fmt.Errorf("unsupported key type: %T", pub)
+ }
+ return nil
+}
+
+// signRequest adds an HTTP Signature header to an outgoing request.
+func signRequest(req *http.Request, body []byte, keyID string, priv crypto.PrivateKey) error {
+ // Compute digest of body.
+ digestHash := sha256.Sum256(body)
+ digest := "SHA-256=" + base64.StdEncoding.EncodeToString(digestHash[:])
+ req.Header.Set("Digest", digest)
+
+ date := time.Now().UTC().Format(http.TimeFormat)
+ req.Header.Set("Date", date)
+
+ // Build signing string: (request-target), host, date, digest
+ target := strings.ToLower(req.Method) + " " + req.URL.Path
+ signingString := fmt.Sprintf("(request-target): %s\nhost: %s\ndate: %s\ndigest: %s",
+ target, req.URL.Host, date, digest)
+
+ hash := sha256.Sum256([]byte(signingString))
+
+ var sig []byte
+ var algo string
+ switch k := priv.(type) {
+ case ed25519.PrivateKey:
+ sig = ed25519.Sign(k, hash[:])
+ algo = "ed25519-sha256"
+ case *rsa.PrivateKey:
+ var err error
+ sig, err = rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, hash[:])
+ if err != nil {
+ return fmt.Errorf("rsa sign: %w", err)
+ }
+ algo = "rsa-sha256"
+ default:
+ return fmt.Errorf("unsupported private key type: %T", priv)
+ }
+
+ sigHeader := fmt.Sprintf(
+ `keyId="%s",algorithm="%s",headers="(request-target) host date digest",signature="%s"`,
+ keyID, algo, base64.StdEncoding.EncodeToString(sig),
+ )
+ req.Header.Set("Signature", sigHeader)
+ return nil
+}
+
+// ----- Delivery to followers -----
+
+var httpClient = &http.Client{Timeout: 10 * time.Second}
func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
if cfg.Host == "" {
@@ -26,28 +1002,245 @@ func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
}
actor := actorURL(cfg.Host, repoName)
threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
- note := APObject{
- Context: apContext,
+
+ note := APNote{
ID: threadURL,
Type: "Note",
Published: t.Created,
AttributedTo: actor,
Content: t.Title + "\n\n" + t.Body,
URL: threadURL,
- To: []string{"https://www.w3.org/ns/activitystreams#Public"},
- CC: []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+ To: []string{apPublic},
+ CC: []string{followersURL(cfg.Host, repoName)},
}
- activity := map[string]any{
+ create := map[string]any{
"@context": apContext,
"id": threadURL + "/activity",
"type": "Create",
"actor": actor,
"published": t.Created,
- "to": []string{"https://www.w3.org/ns/activitystreams#Public"},
- "cc": []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+ "to": []string{apPublic},
+ "cc": []string{followersURL(cfg.Host, repoName)},
"object": note,
}
- // TODO: implement actual delivery to inboxes of followers.
- slog.Info("ap: would deliver Create activity", "actor", actor, "thread", threadURL)
- _ = activity
+
+ deliverToFollowers(cfg, repoName, create)
}
+
+func deliverPatchCreate(cfg Config, repoName string, p *Patch) {
+ if cfg.Host == "" {
+ return
+ }
+ actor := actorURL(cfg.Host, repoName)
+ patchURL := "https://" + cfg.Host + "/" + repoName + "/patches/" + p.ID
+
+ note := APNote{
+ ID: patchURL,
+ Type: "Note",
+ Published: p.Created,
+ AttributedTo: actor,
+ Content: p.Title + "\n\n" + p.Body,
+ URL: patchURL,
+ To: []string{apPublic},
+ CC: []string{followersURL(cfg.Host, repoName)},
+ }
+ create := map[string]any{
+ "@context": apContext,
+ "id": patchURL + "/activity",
+ "type": "Create",
+ "actor": actor,
+ "published": p.Created,
+ "to": []string{apPublic},
+ "cc": []string{followersURL(cfg.Host, repoName)},
+ "object": note,
+ }
+
+ deliverToFollowers(cfg, repoName, create)
+}
+
+func deliverReplyCreate(cfg Config, repoName string, t *Thread) {
+ if cfg.Host == "" || len(t.Replies) == 0 {
+ return
+ }
+ actor := actorURL(cfg.Host, repoName)
+ last := t.Replies[len(t.Replies)-1]
+ threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+
+ // Determine the inReplyTo target: parent reply if nested, else thread root.
+ inReplyTo := threadURL
+ if last.ParentID != "" {
+ inReplyTo = threadURL + "#reply-" + last.ParentID
+ }
+
+ note := APNote{
+ ID: threadURL + "#reply-" + last.ID,
+ Type: "Note",
+ Published: last.Created,
+ AttributedTo: actor,
+ Content: last.Body,
+ URL: threadURL + "#reply-" + last.ID,
+ InReplyTo: inReplyTo,
+ To: []string{apPublic},
+ CC: []string{followersURL(cfg.Host, repoName)},
+ }
+ create := map[string]any{
+ "@context": apContext,
+ "id": threadURL + "#reply-" + last.ID + "/activity",
+ "type": "Create",
+ "actor": actor,
+ "published": last.Created,
+ "to": []string{apPublic},
+ "cc": []string{followersURL(cfg.Host, repoName)},
+ "object": note,
+ }
+
+ deliverToFollowers(cfg, repoName, create)
+}
+
+func deliverToFollowers(cfg Config, repoName string, activity map[string]any) {
+ rp := repoPath(cfg, repoName)
+ meta, err := loadOrCreateAPMeta(rp)
+ if err != nil {
+ slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
+ return
+ }
+ if len(meta.Followers) == 0 {
+ slog.Info("ap deliver: no followers", "repo", repoName)
+ return
+ }
+
+ slog.Info("ap deliver: starting", "repo", repoName, "followers", len(meta.Followers))
+
+ priv, err := parsePrivateKey(meta.PrivateKey)
+ if err != nil {
+ slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
+ return
+ }
+
+ body, err := json.Marshal(activity)
+ if err != nil {
+ slog.Warn("ap deliver: marshal", "error", err)
+ return
+ }
+
+ for _, follower := range meta.Followers {
+ go func(followerURL string) {
+ // Resolve inbox from follower's actor.
+ inbox := followerURL
+ if !strings.Contains(followerURL, "/inbox") {
+ resolved, err := resolveInbox(followerURL)
+ if err != nil {
+ slog.Warn("ap deliver: resolve inbox", "actor", followerURL, "error", err)
+ return
+ }
+ inbox = resolved
+ }
+
+ req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Content-Type", "application/activity+json")
+ keyID := actorURL(cfg.Host, repoName) + "#main-key"
+ if err := signRequest(req, body, keyID, priv); err != nil {
+ slog.Warn("ap deliver: sign", "error", err)
+ return
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
+ return
+ }
+ resp.Body.Close()
+ slog.Info("ap deliver: delivered", "to", followerURL, "status", resp.StatusCode)
+ }(follower)
+ }
+}
+
+// sendAccept delivers an Accept activity to a follower's inbox.
+// Called asynchronously after a Follow is received.
+func sendAccept(cfg Config, repoName, actor string, followActivity map[string]any) {
+ accept := map[string]any{
+ "@context": apContext,
+ "type": "Accept",
+ "actor": actorURL(cfg.Host, repoName),
+ "object": followActivity,
+ }
+ // Use the same delivery mechanism as thread/patch delivery.
+ deliverActivityToActor(cfg, repoName, actor, accept)
+}
+
+// deliverActivityToActor resolves an actor's inbox and delivers a signed activity.
+func deliverActivityToActor(cfg Config, repoName, actor string, activity map[string]any) {
+ rp := repoPath(cfg, repoName)
+ meta, err := loadOrCreateAPMeta(rp)
+ if err != nil {
+ slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
+ return
+ }
+ priv, err := parsePrivateKey(meta.PrivateKey)
+ if err != nil {
+ slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
+ return
+ }
+
+ inbox, err := resolveInbox(actor)
+ if err != nil {
+ slog.Warn("ap deliver: resolve inbox", "actor", actor, "error", err)
+ return
+ }
+
+ body, err := json.Marshal(activity)
+ if err != nil {
+ slog.Warn("ap deliver: marshal", "error", err)
+ return
+ }
+
+ req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Content-Type", "application/activity+json")
+ keyID := actorURL(cfg.Host, repoName) + "#main-key"
+ if err := signRequest(req, body, keyID, priv); err != nil {
+ slog.Warn("ap deliver: sign", "error", err)
+ return
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
+ return
+ }
+ resp.Body.Close()
+ slog.Info("ap deliver: delivered", "to", actor, "status", resp.StatusCode)
+}
+
+// resolveInbox fetches an actor and returns its inbox URL.
+func resolveInbox(actorURL string) (string, error) {
+ req, err := http.NewRequest("GET", actorURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", apContentType)
+ req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ var actor struct {
+ Inbox string `json:"inbox"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
+ return "", err
+ }
+ if actor.Inbox == "" {
+ return "", fmt.Errorf("no inbox in actor")
+ }
+ return actor.Inbox, nil
+}
+
diff --git a/ap_test.go b/ap_test.go
new file mode 100644
index 0000000..778104f
--- /dev/null
+++ b/ap_test.go
@@ -0,0 +1,860 @@
+package main
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// newTestServerWithHost creates a test server with Host configured from the
+// actual listener address (needed for ActivityPub URL construction).
+func newTestServerWithHost(t *testing.T) *testServer {
+ t.Helper()
+
+ dir := t.TempDir()
+ usersPath := filepath.Join(dir, "users.json")
+ tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
+
+ // Build with placeholder host first, then rebuild after we know the port.
+ cfg := Config{
+ Addr: "127.0.0.1:0",
+ Host: "placeholder",
+ Dir: dir,
+ UsersPath: usersPath,
+ CookieSecret: "test-secret",
+ }
+
+ users, err := LoadUsers(usersPath)
+ if err != nil {
+ t.Fatalf("LoadUsers: %v", err)
+ }
+
+ secret := []byte(cfg.CookieSecret)
+ placeholderHandler := newHandler(cfg, users, secret, tmpl)
+
+ ts := httptest.NewUnstartedServer(placeholderHandler)
+ cfg.Host = ts.Listener.Addr().String()
+ handler := newHandler(cfg, users, secret, tmpl)
+ ts.Config.Handler = handler
+ ts.Start()
+
+ return &testServer{
+ Server: ts,
+ cfg: cfg,
+ dir: dir,
+ users: users,
+ secret: secret,
+ tmpl: tmpl,
+ }
+}
+
+// ---------- WebFinger ----------
+
+func TestWebFingerRepoDiscovery(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "ap-repo", "An AP-enabled repo")
+
+ resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:ap-repo@"+ts.cfg.Host)
+ assertStatus(t, resp, http.StatusOK)
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "jrd+json") {
+ t.Errorf("content-type should be jrd+json, got %q", ct)
+ }
+
+ var result jrd
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ t.Fatalf("parse WebFinger response: %v", err)
+ }
+
+ if result.Subject != "acct:ap-repo@"+ts.cfg.Host {
+ t.Errorf("subject = %q, want %q", result.Subject, "acct:ap-repo@"+ts.cfg.Host)
+ }
+ if len(result.Links) != 1 {
+ t.Fatalf("expected 1 link, got %d", len(result.Links))
+ }
+ link := result.Links[0]
+ if link.Rel != "self" {
+ t.Errorf("link rel = %q, want self", link.Rel)
+ }
+ if link.Type != "application/activity+json" {
+ t.Errorf("link type = %q", link.Type)
+ }
+ if link.Href != actorURL(ts.cfg.Host, "ap-repo") {
+ t.Errorf("link href = %q, want %q", link.Href, actorURL(ts.cfg.Host, "ap-repo"))
+ }
+}
+
+func TestWebFingerUnknownRepo(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "admin", "admin123")
+
+ resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:nonexistent@"+ts.cfg.Host)
+ assertStatus(t, resp, http.StatusNotFound)
+}
+
+func TestWebFingerBadResource(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "admin", "admin123")
+
+ resp := ts.get(t, c, "/.well-known/webfinger?resource=bad-format")
+ assertStatus(t, resp, http.StatusNotFound)
+}
+
+// ---------- Actor ----------
+
+func TestAPActor(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "actor-test", "Actor test repo")
+
+ resp := ts.get(t, c, "/actor-test/ap/actor")
+ assertStatus(t, resp, http.StatusOK)
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
+ t.Errorf("content-type should be activity+json, got %q", ct)
+ }
+
+ var actor APActor
+ if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
+ t.Fatalf("parse actor: %v", err)
+ }
+
+ if actor.Context != apContext {
+ t.Errorf("@context = %q", actor.Context)
+ }
+ if actor.Type != "Service" {
+ t.Errorf("type = %q, want Service", actor.Type)
+ }
+ if actor.ID != actorURL(ts.cfg.Host, "actor-test") {
+ t.Errorf("id = %q", actor.ID)
+ }
+ if actor.Name != "actor-test" {
+ t.Errorf("name = %q", actor.Name)
+ }
+ if actor.PreferredUsername != "actor-test" {
+ t.Errorf("preferredUsername = %q", actor.PreferredUsername)
+ }
+ if actor.URL != "https://"+ts.cfg.Host+"/actor-test" {
+ t.Errorf("url = %q", actor.URL)
+ }
+ if actor.Summary != "Actor test repo" {
+ t.Errorf("summary = %q", actor.Summary)
+ }
+ if actor.Inbox != inboxURL(ts.cfg.Host, "actor-test") {
+ t.Errorf("inbox = %q", actor.Inbox)
+ }
+ if actor.Outbox != outboxURL(ts.cfg.Host, "actor-test") {
+ t.Errorf("outbox = %q", actor.Outbox)
+ }
+ if actor.Followers != followersURL(ts.cfg.Host, "actor-test") {
+ t.Errorf("followers = %q", actor.Followers)
+ }
+ if actor.PublicKey.ID != actorURL(ts.cfg.Host, "actor-test")+"#main-key" {
+ t.Errorf("publicKey.id = %q", actor.PublicKey.ID)
+ }
+ if actor.PublicKey.Owner != actorURL(ts.cfg.Host, "actor-test") {
+ t.Errorf("publicKey.owner = %q", actor.PublicKey.Owner)
+ }
+ if !strings.Contains(actor.PublicKey.PublicKeyPem, "PUBLIC KEY") {
+ t.Error("publicKey.publicKeyPem should contain PEM block")
+ }
+}
+
+// ---------- Inbox ----------
+
+func TestAPInboxCreateNoteCreatesThread(t *testing.T) {
+ // Set up two servers: server A follows server B's repo.
+ // Server A = the receiver (has the inbox)
+ tsA := newTestServerWithHost(t)
+ cA := tsA.client()
+ tsA.setupUser(t, cA, "admin", "admin123")
+ tsA.createRepo(t, cA, "target", "Target repo")
+
+ // Get A's actor info for the key.
+ respActor := tsA.get(t, cA, "/target/ap/actor")
+ assertStatus(t, respActor, http.StatusOK)
+ var actorA APActor
+ json.NewDecoder(respActor.Body).Decode(&actorA)
+
+ // Build a Follow activity from "server B".
+ followActivity := map[string]any{
+ "@context": apContext,
+ "type": "Follow",
+ "actor": "https://remote.example/users/alice",
+ "object": actorA.ID,
+ }
+
+ jsonBody, _ := json.Marshal(followActivity)
+ req, _ := http.NewRequest("POST", tsA.URL+"/target/ap/inbox", bytes.NewReader(jsonBody))
+ req.Header.Set("Content-Type", "application/activity+json")
+
+ // Create a fake signature (inbox currently requires a signature header).
+ // Since we can't sign externally, we'll test that a missing signature returns 401.
+ req.Header.Set("Signature", `keyId="https://remote.example/users/alice#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="deadbeef"`)
+
+ resp, err := tsA.client().Do(req)
+ if err != nil {
+ t.Fatalf("inbox request: %v", err)
+ }
+ // Strict verification: fake signature must be rejected.
+ assertStatus(t, resp, http.StatusUnauthorized)
+
+ // Now test that the inbox endpoint responds at all with a valid setup.
+ // (Full signature verification test would require signing with own key.)
+}
+
+// TestAPInboxNoSignature verifies that inbox rejects requests without a signature.
+func TestAPInboxNoSignature(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "admin", "admin123")
+ ts.createRepo(t, c, "sigtest", "Signature test")
+
+ body, _ := json.Marshal(map[string]string{"type": "Create"})
+ req, _ := http.NewRequest("POST", ts.URL+"/sigtest/ap/inbox", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/activity+json")
+
+ resp, err := ts.client().Do(req)
+ if err != nil {
+ t.Fatalf("inbox request: %v", err)
+ }
+ assertStatus(t, resp, http.StatusUnauthorized)
+}
+
+// ---------- Outbox ----------
+
+func TestAPOutbox(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "outbox-test", "Outbox test")
+
+ // Create a thread so there's something in the outbox.
+ ts.postForm(t, c, "/outbox-test/threads/new", url.Values{
+ "title": {"Hello federation"},
+ "body": {"First federated thread."},
+ })
+
+ resp := ts.get(t, c, "/outbox-test/ap/outbox")
+ assertStatus(t, resp, http.StatusOK)
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
+ t.Errorf("content-type should be activity+json, got %q", ct)
+ }
+
+ var coll APOrderedCollection
+ if err := json.NewDecoder(resp.Body).Decode(&coll); err != nil {
+ t.Fatalf("parse outbox: %v", err)
+ }
+
+ if coll.Type != "OrderedCollection" {
+ t.Errorf("type = %q, want OrderedCollection", coll.Type)
+ }
+ if coll.TotalItems < 1 {
+ t.Error("outbox should have at least 1 item")
+ }
+ // Check that an item contains our thread title.
+ found := false
+ for _, item := range coll.OrderedItems {
+ itemMap, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ obj, ok := itemMap["object"].(map[string]any)
+ if !ok {
+ continue
+ }
+ content, _ := obj["content"].(string)
+ if strings.Contains(content, "Hello federation") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("outbox should contain the thread about 'Hello federation'")
+ }
+}
+
+func TestAPOutboxEmpty(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "empty-out", "Empty outbox")
+
+ resp := ts.get(t, c, "/empty-out/ap/outbox")
+ assertStatus(t, resp, http.StatusOK)
+
+ var coll APOrderedCollection
+ json.NewDecoder(resp.Body).Decode(&coll)
+ if coll.TotalItems != 0 {
+ t.Errorf("empty outbox should have 0 items, got %d", coll.TotalItems)
+ }
+}
+
+// ---------- Followers ----------
+
+func TestAPFollowers(t *testing.T) {
+ ts := newTestServerWithHost(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "followed", "Followed repo")
+
+ // Followers should start empty.
+ resp := ts.get(t, c, "/followed/ap/followers")
+ assertStatus(t, resp, http.StatusOK)
+
+ var coll APOrderedCollection
+ json.NewDecoder(resp.Body).Decode(&coll)
+ if coll.TotalItems != 0 {
+ t.Errorf("initial followers should be 0, got %d", coll.TotalItems)
+ }
+}
+
+// ---------- Key management ----------
+
+func TestAPKeyGeneration(t *testing.T) {
+ dir := t.TempDir()
+ repoPath := filepath.Join(dir, "keytest.git")
+ os.MkdirAll(repoPath, 0755)
+
+ meta, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ t.Fatalf("loadOrCreateAPMeta: %v", err)
+ }
+
+ if meta.PublicKey == "" || meta.PrivateKey == "" {
+ t.Fatal("keys should be generated")
+ }
+
+ // Parse the keys to ensure they're valid.
+ priv, err := parsePrivateKey(meta.PrivateKey)
+ if err != nil {
+ t.Fatalf("parse private key: %v", err)
+ }
+ pub, err := parsePublicKey(meta.PublicKey)
+ if err != nil {
+ t.Fatalf("parse public key: %v", err)
+ }
+
+ // Verify the keys match by signing with private and verifying with public.
+ msg := []byte("test message")
+ hash := sha256.Sum256(msg)
+ sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hash[:])
+ if err != nil {
+ t.Fatalf("sign: %v", err)
+ }
+ if err := rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, hash[:], sig); err != nil {
+ t.Error("keypair verification failed")
+ }
+
+ // Load again — should get the same keys.
+ meta2, err := loadOrCreateAPMeta(repoPath)
+ if err != nil {
+ t.Fatalf("second load: %v", err)
+ }
+ if meta2.PublicKey != meta.PublicKey || meta2.PrivateKey != meta.PrivateKey {
+ t.Error("keys should persist across loads")
+ }
+}
+
+// ---------- Follow/unfollow persistence ----------
+
+func TestAPFollowPersistence(t *testing.T) {
+ dir := t.TempDir()
+ repoPath := filepath.Join(dir, "followtest.git")
+ os.MkdirAll(repoPath, 0755)
+
+ // Add a follower.
+ if err := addFollower(repoPath, "https://example.com/users/alice"); err != nil {
+ t.Fatalf("addFollower: %v", err)
+ }
+
+ meta, _ := loadOrCreateAPMeta(repoPath)
+ if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/alice" {
+ t.Fatalf("followers = %v, want [alice]", meta.Followers)
+ }
+
+ // Add another.
+ addFollower(repoPath, "https://example.com/users/bob")
+ meta, _ = loadOrCreateAPMeta(repoPath)
+ if len(meta.Followers) != 2 {
+ t.Fatalf("followers count = %d, want 2", len(meta.Followers))
+ }
+
+ // Remove alice.
+ removeFollower(repoPath, "https://example.com/users/alice")
+ meta, _ = loadOrCreateAPMeta(repoPath)
+ if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/bob" {
+ t.Fatalf("followers after remove = %v, want [bob]", meta.Followers)
+ }
+
+ // Dedup.
+ addFollower(repoPath, "https://example.com/users/bob")
+ meta, _ = loadOrCreateAPMeta(repoPath)
+ if len(meta.Followers) != 1 {
+ t.Fatalf("followers should be deduplicated, got %d", len(meta.Followers))
+ }
+}
+
+// ---------- HTTP Signature parsing ----------
+
+func TestParseSignature(t *testing.T) {
+ header := `keyId="https://example.com/actor#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="c29tZXNpZ25hdHVyZQ=="`
+
+ sig, err := parseSignature(header)
+ if err != nil {
+ t.Fatalf("parseSignature: %v", err)
+ }
+ if sig.KeyID != "https://example.com/actor#main-key" {
+ t.Errorf("keyId = %q", sig.KeyID)
+ }
+ if sig.Algorithm != "ed25519-sha256" {
+ t.Errorf("algorithm = %q", sig.Algorithm)
+ }
+ if len(sig.Headers) != 4 {
+ t.Errorf("headers = %v", sig.Headers)
+ }
+ if len(sig.Signature) == 0 {
+ t.Error("signature is empty")
+ }
+}
+
+func TestParseSignatureIncomplete(t *testing.T) {
+ _, err := parseSignature(`keyId="https://example.com"`)
+ if err == nil {
+ t.Error("should fail for incomplete signature")
+ }
+}
+
+// TestRSASignatureRoundTrip verifies RSA key parsing and signature verification.
+func TestRSASignatureRoundTrip(t *testing.T) {
+ // Generate an RSA key (similar to what Mastodon uses).
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("generate RSA key: %v", err)
+ }
+
+ // Marshal public key to PEM.
+ pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
+
+ // Parse it back using parsePublicKey.
+ pub, err := parsePublicKey(pubPEM)
+ if err != nil {
+ t.Fatalf("parsePublicKey (RSA): %v", err)
+ }
+
+ // Build a fake request and sign it.
+ req, _ := http.NewRequest("POST", "https://example.com/inbox", bytes.NewReader([]byte(`{"type":"Follow"}`)))
+ req.Header.Set("Host", "example.com")
+ req.Header.Set("Date", "Sun, 06 Nov 1994 08:49:37 GMT")
+
+ body := []byte(`{"type":"Follow"}`)
+ digestHash := sha256.Sum256(body)
+ req.Header.Set("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(digestHash[:]))
+
+ // Build signing string.
+ target := "post /inbox"
+ signingString := "(request-target): " + target + "\nhost: example.com\ndate: Sun, 06 Nov 1994 08:49:37 GMT\ndigest: " + req.Header.Get("Digest")
+ hash := sha256.Sum256([]byte(signingString))
+
+ // Sign with RSA.
+ sig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, hash[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ sigB64 := base64.StdEncoding.EncodeToString(sig)
+
+ // Build signature header.
+ sigHeader := `keyId="https://example.com/actor#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="` + sigB64 + `"`
+
+ parsed, err := parseSignature(sigHeader)
+ if err != nil {
+ t.Fatalf("parseSignature: %v", err)
+ }
+
+ // Verify.
+ if err := verifySignature(parsed, pub, req, body); err != nil {
+ t.Fatalf("RSA verify failed: %v", err)
+ }
+
+ // Bad signature should fail.
+ parsed.Signature[0] ^= 0xff
+ if err := verifySignature(parsed, pub, req, body); err == nil {
+ t.Error("tampered signature should fail verification")
+ }
+}
+
+// TestBuildReplyViews verifies that a reply's "replying to" pointer only
+// shows up when it isn't simply continuing the line right above it.
+func TestBuildReplyViews(t *testing.T) {
+ replies := []Reply{
+ {ID: "r1", AuthorName: "Priya", Body: "top-level, replies to the thread root", ParentID: ""},
+ {ID: "r2", Body: "continues r1, the message right above it", ParentID: "r1"},
+ {ID: "r3", Body: "another top-level comment", ParentID: ""},
+ {ID: "r4", Body: "reaches back to r1, not the previous message", ParentID: "r1"},
+ }
+
+ views := buildReplyViews(replies)
+ if len(views) != 4 {
+ t.Fatalf("expected 4 views, got %d", len(views))
+ }
+ if views[0].ShowParent {
+ t.Error("reply to thread root should not show a pointer")
+ }
+ if views[1].ShowParent {
+ t.Error("reply to the immediately preceding message should not show a pointer")
+ }
+ if views[2].ShowParent {
+ t.Error("another top-level reply should not show a pointer")
+ }
+ if !views[3].ShowParent {
+ t.Error("reply that reaches back past the previous message should show a pointer")
+ }
+ if views[3].ParentAuthor != "Priya" {
+ t.Errorf("ParentAuthor = %q, want %q", views[3].ParentAuthor, "Priya")
+ }
+}
+
+// TestNestedReplyFromInbox verifies a nested reply (inReplyTo with #reply-<id>)
+// is stored with the correct ParentID.
+func TestNestedReplyFromInbox(t *testing.T) {
+ ts := newTestServer(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "nested-reply", "Nested reply test")
+
+ // Create a thread and a reply directly.
+ rp := repoPath(ts.cfg, "nested-reply")
+ thread, err := createThread(rp, "Thread", "Body", "dev", "dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ addReply(rp, thread.ID, "First reply", "dev", "dev")
+
+ // Load the thread to get the reply ID.
+ thread, _ = loadThread(rp, thread.ID)
+ if len(thread.Replies) != 1 {
+ t.Fatal("expected 1 reply")
+ }
+ replyID := thread.Replies[0].ID
+
+ // Simulate an AP reply with inReplyTo pointing to the reply.
+ inReplyTo := "https://" + ts.cfg.Host + "/nested-reply/threads/" + thread.ID + "#reply-" + replyID
+ obj := map[string]any{
+ "type": "Note",
+ "content": "Nested reply content",
+ "inReplyTo": inReplyTo,
+ }
+ handleCreateNote(ts.cfg, "nested-reply", "https://remote.example/users/bob", obj)
+
+ // Verify the nested reply has the correct ParentID.
+ thread, _ = loadThread(rp, thread.ID)
+ if len(thread.Replies) != 2 {
+ t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
+ }
+ last := thread.Replies[1]
+ if last.ParentID != replyID {
+ t.Errorf("nested reply ParentID = %q, want %q", last.ParentID, replyID)
+ }
+}
+
+// TestThreadViewLinear verifies the thread page renders replies as a single
+// chronological conversation, with a "replying to" pointer only on the one
+// that reaches back past the message immediately before it.
+func TestThreadViewLinear(t *testing.T) {
+ ts := newTestServer(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "views", "View test")
+
+ rp := repoPath(ts.cfg, "views")
+ thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
+ addReply(rp, thread.ID, "First reply", "dev", "dev")
+ thread, _ = loadThread(rp, thread.ID)
+ firstID := thread.Replies[0].ID
+ addReply(rp, thread.ID, "Second reply, top-level", "dev", "dev")
+ addNestedReply(rp, thread.ID, firstID, "Reaches back to the first reply", "dev", "dev")
+
+ resp := ts.get(t, c, "/views/threads/"+thread.ID)
+ assertStatus(t, resp, http.StatusOK)
+ b := body(t, resp)
+ if !strings.Contains(b, "First reply") || !strings.Contains(b, "Second reply, top-level") || !strings.Contains(b, "Reaches back to the first reply") {
+ t.Error("thread view should show all replies in one linear list")
+ }
+ if got := strings.Count(b, `class="reply-pointer"`); got != 1 {
+ t.Errorf("expected exactly one reply pointer (for the reply that skips back), got %d", got)
+ }
+}
+
+// TestReplyToSpecificComment verifies parent_id creates a nested reply via web UI.
+func TestReplyToSpecificComment(t *testing.T) {
+ ts := newTestServer(t)
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "replies", "Reply test")
+
+ rp := repoPath(ts.cfg, "replies")
+ thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
+ addReply(rp, thread.ID, "First", "dev", "dev")
+ thread, _ = loadThread(rp, thread.ID)
+ firstReplyID := thread.Replies[0].ID
+
+ // Reply to the first reply via the web form.
+ resp := ts.postForm(t, c, "/replies/threads/"+thread.ID+"/reply", url.Values{
+ "body": {"Nested from UI"},
+ "parent_id": {firstReplyID},
+ })
+ assertStatus(t, resp, http.StatusOK)
+
+ thread, _ = loadThread(rp, thread.ID)
+ if len(thread.Replies) != 2 {
+ t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
+ }
+ if thread.Replies[1].ParentID != firstReplyID {
+ t.Errorf("nested reply ParentID = %q, want %q", thread.Replies[1].ParentID, firstReplyID)
+ }
+}
+
+// ---------- End-to-end federation flow ----------
+
+func TestFederationEndToEnd(t *testing.T) {
+ // Server A hosts a repo that someone wants to follow and interact with.
+ tsA := newTestServerWithHost(t)
+ cA := tsA.client()
+ tsA.setupUser(t, cA, "admin", "admin123")
+ tsA.createRepo(t, cA, "fed-repo", "Federated repository")
+
+ // Get A's actor and public key.
+ respActorA := tsA.get(t, cA, "/fed-repo/ap/actor")
+ assertStatus(t, respActorA, http.StatusOK)
+ var actorA APActor
+ json.NewDecoder(respActorA.Body).Decode(&actorA)
+
+ // Load A's private key for signing.
+ rpA := repoPath(tsA.cfg, "fed-repo")
+ metaA, err := loadOrCreateAPMeta(rpA)
+ if err != nil {
+ t.Fatalf("load AP meta: %v", err)
+ }
+ privA, err := parsePrivateKey(metaA.PrivateKey)
+ if err != nil {
+ t.Fatalf("parse private key: %v", err)
+ }
+
+ // Construct URLs using http:// (test server is HTTP).
+ actorID := "http://" + tsA.cfg.Host + "/fed-repo/ap/actor"
+ inboxA := tsA.URL + "/fed-repo/ap/inbox"
+ keyID := actorID + "#main-key"
+
+ // Simulate "server B" — a remote user posting a Follow to A's inbox.
+ // We'll sign it with A's own key (since we are A and we have the private key).
+ // This lets us test the full pipeline: signature verification + activity dispatch.
+ followBody, _ := json.Marshal(map[string]any{
+ "@context": apContext,
+ "type": "Follow",
+ "actor": "https://remote.example/users/alice",
+ "object": actorA.ID,
+ })
+
+ reqFollow, _ := http.NewRequest("POST", inboxA, bytes.NewReader(followBody))
+ reqFollow.Header.Set("Content-Type", "application/activity+json")
+ reqFollow.Header.Set("Host", tsA.cfg.Host)
+ if err := signRequest(reqFollow, followBody, keyID, privA); err != nil {
+ t.Fatalf("sign follow request: %v", err)
+ }
+
+ respFollow, err := tsA.client().Do(reqFollow)
+ if err != nil {
+ t.Fatalf("send follow: %v", err)
+ }
+ bodyStr := body(t, respFollow)
+ t.Logf("follow response status: %d, body: %.200s", respFollow.StatusCode, bodyStr)
+ // Should succeed (200 or 202).
+ if respFollow.StatusCode != 200 && respFollow.StatusCode != 202 {
+ t.Errorf("follow should return 200/202, got %d", respFollow.StatusCode)
+ }
+
+ // Verify alice is now a follower.
+ metaA, _ = loadOrCreateAPMeta(rpA)
+ found := false
+ for _, f := range metaA.Followers {
+ if f == "https://remote.example/users/alice" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("alice should be in followers after Follow activity")
+ }
+
+ // Now simulate Alice creating a Note (thread) via A's inbox.
+ noteBody, _ := json.Marshal(map[string]any{
+ "@context": apContext,
+ "type": "Create",
+ "actor": "https://remote.example/users/alice",
+ "object": map[string]any{
+ "type": "Note",
+ "attributedTo": "https://remote.example/users/alice",
+ "content": "Hello from Alice\n\nThis is a federated thread from a remote user.",
+ },
+ })
+
+ reqNote, _ := http.NewRequest("POST", inboxA, bytes.NewReader(noteBody))
+ reqNote.Header.Set("Content-Type", "application/activity+json")
+ reqNote.Header.Set("Host", tsA.cfg.Host)
+ if err := signRequest(reqNote, noteBody, keyID, privA); err != nil {
+ t.Fatalf("sign note request: %v", err)
+ }
+
+ respNote, err := tsA.client().Do(reqNote)
+ if err != nil {
+ t.Fatalf("send note: %v", err)
+ }
+ if respNote.StatusCode != 200 {
+ noteBodyStr := body(t, respNote)
+ t.Fatalf("note creation should return 200, got %d: %s", respNote.StatusCode, noteBodyStr)
+ }
+
+ // Verify the thread was created on server A.
+ rp := repoPath(tsA.cfg, "fed-repo")
+ threads, _, _ := listThreadsFiltered(rp, "open")
+ if len(threads) != 1 {
+ t.Fatalf("expected 1 thread from federation, got %d", len(threads))
+ }
+ if threads[0].Title != "Hello from Alice" {
+ t.Errorf("thread title = %q, want 'Hello from Alice'", threads[0].Title)
+ }
+ if threads[0].Author != "https://remote.example/users/alice" {
+ t.Errorf("thread author = %q, want remote user", threads[0].Author)
+ }
+
+ // Verify the thread is visible via the web UI.
+ respWeb := tsA.get(t, cA, "/fed-repo/threads/"+threads[0].ID)
+ assertStatus(t, respWeb, http.StatusOK)
+ b := body(t, respWeb)
+ if !strings.Contains(b, "Hello from Alice") {
+ t.Error("thread page should show federated thread title")
+ }
+ if !strings.Contains(b, "federated thread") {
+ t.Error("thread page should show federated thread body")
+ }
+
+ // Now simulate a reply via inbox.
+ replyBody, _ := json.Marshal(map[string]any{
+ "@context": apContext,
+ "type": "Create",
+ "actor": "https://remote.example/users/alice",
+ "object": map[string]any{
+ "type": "Note",
+ "attributedTo": "https://remote.example/users/alice",
+ "content": "This is a reply\n\nReplying to the federated thread.",
+ "inReplyTo": "https://" + tsA.cfg.Host + "/fed-repo/threads/" + threads[0].ID,
+ },
+ })
+
+ reqReply, _ := http.NewRequest("POST", inboxA, bytes.NewReader(replyBody))
+ reqReply.Header.Set("Content-Type", "application/activity+json")
+ reqReply.Header.Set("Host", tsA.cfg.Host)
+ signRequest(reqReply, replyBody, keyID, privA)
+
+ respReply, err := tsA.client().Do(reqReply)
+ if err != nil {
+ t.Fatalf("send reply: %v", err)
+ }
+ if respReply.StatusCode != 200 {
+ bodyStr = body(t, respReply)
+ t.Fatalf("reply should return 200, got %d: %s", respReply.StatusCode, bodyStr)
+ }
+
+ // Reload the thread and verify the reply.
+ thread, err := loadThread(rp, threads[0].ID)
+ if err != nil {
+ t.Fatalf("load thread: %v", err)
+ }
+ if len(thread.Replies) != 1 {
+ t.Fatalf("expected 1 reply, got %d", len(thread.Replies))
+ }
+ if thread.Replies[0].Author != "https://remote.example/users/alice" {
+ t.Errorf("reply author = %q", thread.Replies[0].Author)
+ }
+ if !strings.Contains(thread.Replies[0].Body, "Replying to the federated thread") {
+ t.Error("reply body should contain the reply text")
+ }
+
+ // Undo the Follow.
+ undoBody, _ := json.Marshal(map[string]any{
+ "@context": apContext,
+ "type": "Undo",
+ "actor": "https://remote.example/users/alice",
+ "object": map[string]any{
+ "type": "Follow",
+ "actor": "https://remote.example/users/alice",
+ "object": actorA.ID,
+ },
+ })
+
+ reqUndo, _ := http.NewRequest("POST", inboxA, bytes.NewReader(undoBody))
+ reqUndo.Header.Set("Content-Type", "application/activity+json")
+ reqUndo.Header.Set("Host", tsA.cfg.Host)
+ signRequest(reqUndo, undoBody, keyID, privA)
+
+ respUndo, err := tsA.client().Do(reqUndo)
+ if err != nil {
+ t.Fatalf("send undo: %v", err)
+ }
+ if respUndo.StatusCode != 200 {
+ bodyStr = body(t, respUndo)
+ t.Fatalf("undo should return 200, got %d: %s", respUndo.StatusCode, bodyStr)
+ }
+
+ // Alice should be removed from followers.
+ metaA, _ = loadOrCreateAPMeta(rpA)
+ for _, f := range metaA.Followers {
+ if f == "https://remote.example/users/alice" {
+ t.Error("alice should be removed after Undo Follow")
+ break
+ }
+ }
+}
+
+// ---------- No host config disables AP ----------
+
+func TestAPDisabledWithoutHost(t *testing.T) {
+ ts := newTestServer(t) // no Host configured
+ c := ts.client()
+ ts.setupUser(t, c, "dev", "secret123")
+ ts.createRepo(t, c, "no-ap", "No AP")
+
+ // All AP endpoints should return 404.
+ for _, path := range []string{
+ "/.well-known/webfinger?resource=acct:no-ap@localhost",
+ "/no-ap/ap/actor",
+ "/no-ap/ap/outbox",
+ "/no-ap/ap/followers",
+ } {
+ resp := ts.get(t, c, path)
+ if resp.StatusCode != 404 {
+ t.Errorf("%s should return 404 when Host is not configured, got %d", path, resp.StatusCode)
+ }
+ }
+}
diff --git a/main.go b/main.go
index 8808917..0a6eeb3 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,13 @@ func registerRoutes(mux *http.ServeMux, cfg Config, users UserStore, cookieSecre
mux.HandleFunc("GET /{repo}/patches/{patchId}", PatchView(cfg, tmpl))
mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
+
+ // ActivityPub
+ mux.HandleFunc("GET /.well-known/webfinger", WebFinger(cfg))
+ mux.HandleFunc("GET /{repo}/ap/actor", APActorHandler(cfg))
+ mux.HandleFunc("POST /{repo}/ap/inbox", APInbox(cfg))
+ mux.HandleFunc("GET /{repo}/ap/outbox", APOutbox(cfg))
+ mux.HandleFunc("GET /{repo}/ap/followers", APFollowers(cfg))
}
// newHandler creates a fully-wired HTTP handler from the given configuration.
@@ -65,9 +72,41 @@ func newHandler(cfg Config, users UserStore, cookieSecret []byte, tmpl *template
h = SetupRedirect(users)(h)
h = authMiddleware(cookieSecret)(h)
}
+ // Request logging.
+ h = requestLogMiddleware(h)
return h
}
+// requestLogMiddleware logs every incoming request with method, path, status,
+// and which handler served it (or "unmatched" if nothing matched).
+func requestLogMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ lw := &loggingResponseWriter{ResponseWriter: w, status: 200}
+ next.ServeHTTP(lw, r)
+ dur := time.Since(start)
+ slog.Info("request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "query", r.URL.RawQuery,
+ "status", lw.status,
+ "duration", dur.String(),
+ "host", r.Host,
+ "remote", r.RemoteAddr,
+ )
+ })
+}
+
+type loggingResponseWriter struct {
+ http.ResponseWriter
+ status int
+}
+
+func (lw *loggingResponseWriter) WriteHeader(code int) {
+ lw.status = code
+ lw.ResponseWriter.WriteHeader(code)
+}
+
func main() {
if len(os.Args) >= 2 && os.Args[1] == "hook" {
os.Exit(hookCmd(os.Args[2:]))
diff --git a/patch_handlers.go b/patch_handlers.go
index 0883e59..45f1c0e 100644
--- a/patch_handlers.go
+++ b/patch_handlers.go
@@ -101,6 +101,7 @@ func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
return
}
+ go deliverPatchCreate(cfg, git.Name, p)
http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
}
}
diff --git a/templates/base.html b/templates/base.html
index 3175c00..1a4b6c4 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1034,40 +1034,104 @@
margin-bottom: var(--space-lg);
}
+ /* Boxed card: used for the thread/patch root post only — the one
+ "document" on the page. Conversation replies use .thread-reply
+ below, which is deliberately flat. */
.thread-comment {
+ background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius);
- margin-bottom: var(--space-md);
+ margin-bottom: var(--space-lg);
}
.thread-comment-header {
display: flex;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
- background: var(--bg-subtle);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
- border-radius: var(--radius) var(--radius) 0 0;
+ color: var(--text-secondary);
}
.thread-comment-body {
padding: var(--space-md);
}
- .thread-reply-form {
+ /* Conversation: a plain, indented list — no per-comment card. */
+ .thread-replies {
margin-top: var(--space-lg);
}
- .thread-reply-form textarea {
- width: 100%;
- font-family: inherit;
- font-size: 0.9rem;
- padding: var(--space-sm);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--bg);
+ .thread-reply {
+ padding: var(--space-md) 0;
+ border-bottom: 1px solid var(--border-subtle);
+ }
+
+ .thread-reply-header {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-xs);
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: var(--space-xs);
+ }
+
+ .thread-reply-header strong {
color: var(--text);
- resize: vertical;
+ }
+
+ .thread-reply-header time {
+ margin-left: auto;
+ }
+
+ .reply-pointer {
+ display: block;
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ font-size: 0.78rem;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ margin-bottom: var(--space-xs);
+ }
+
+ .reply-pointer:hover {
+ color: var(--accent);
+ text-decoration: underline;
+ }
+
+ .thread-reply.flash,
+ .thread-comment.flash {
+ animation: fj-flash 0.9s ease;
+ }
+
+ @keyframes fj-flash {
+ from { background: var(--accent-subtle); }
+ to { background: transparent; }
+ }
+
+ .thread-reply-body {
+ font-size: 0.92rem;
+ }
+
+ .thread-reply-actions {
+ margin-top: var(--space-xs);
+ }
+
+ .reply-link {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ }
+
+ .reply-link:hover {
+ color: var(--accent);
+ text-decoration: underline;
}
.thread-actions {
@@ -1094,6 +1158,133 @@
margin-bottom: var(--space-md);
}
+ /* Thread participation widget (web / Mastodon / email) */
+ .participate {
+ margin-top: var(--space-lg);
+ padding-top: var(--space-md);
+ border-top: 1px solid var(--border-subtle);
+ max-width: 34rem;
+ }
+
+ .participate-tabs {
+ display: flex;
+ gap: var(--space-md);
+ margin-bottom: var(--space-md);
+ }
+
+ .participate-tab {
+ background: none;
+ border: none;
+ padding: var(--space-xs) 0;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ }
+
+ .participate-tab:hover {
+ color: var(--text);
+ }
+
+ .participate-tab:focus-visible {
+ outline: none;
+ color: var(--accent);
+ }
+
+ .participate-tab.active {
+ color: var(--text);
+ border-bottom-color: var(--accent);
+ }
+
+ .participate-hint {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin-top: var(--space-sm);
+ }
+
+ .replying-to {
+ display: none;
+ align-items: center;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-sm);
+ font-size: 0.82rem;
+ color: var(--text-secondary);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ padding: 5px 10px;
+ width: fit-content;
+ }
+
+ .replying-to.on {
+ display: flex;
+ }
+
+ .replying-to button {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 0 0 0 4px;
+ }
+
+ .replying-to button:hover {
+ color: var(--text);
+ }
+
+ .fediverse-handle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ margin: var(--space-sm) 0;
+ }
+
+ .fediverse-handle code {
+ background: var(--bg-subtle);
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 0.9rem;
+ }
+
+ .remote-follow-form {
+ display: flex;
+ gap: var(--space-sm);
+ }
+
+ .remote-follow-form input {
+ flex: 1;
+ }
+
+ .email-magic-form {
+ display: flex;
+ gap: var(--space-sm);
+ }
+
+ .email-magic-form input {
+ flex: 1;
+ }
+
+ .badge-soon {
+ display: inline-block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-secondary);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 1px 6px;
+ margin-left: var(--space-xs);
+ }
+
+ .guest-name-field {
+ max-width: 16rem;
+ }
+
.patch-branches-hint {
font-size: 0.85rem;
color: var(--text-secondary);
@@ -1202,6 +1393,32 @@
el.classList.add('rendered-md');
el.querySelectorAll('pre code').forEach(c => hljs.highlightElement(c));
});
+
+ // Switches between "On the web" / "From Mastodon" / "By email" panels
+ // in the thread participation widget.
+ function fjTab(btn) {
+ const tabs = btn.closest('.participate-tabs');
+ const panel = btn.dataset.panel;
+ tabs.querySelectorAll('.participate-tab').forEach(b => b.classList.toggle('active', b === btn));
+ tabs.parentElement.querySelectorAll('.participate-panel').forEach(p => {
+ p.hidden = p.dataset.panel !== panel;
+ });
+ }
+
+ // Sends the visitor to their own Mastodon (or other ActivityPub app)
+ // instance to follow this repo's actor, using the standard
+ // "remote follow" redirect (authorize_interaction).
+ function fjRemoteFollow(form) {
+ const host = form.instance.value.trim().replace(/^https?:\/\//, '').split('/')[0];
+ if (!host || !/^[a-zA-Z0-9.-]+$/.test(host)) {
+ alert('Enter a valid instance domain, e.g. mastodon.social');
+ return false;
+ }
+ const actorUrl = form.dataset.actorUrl;
+ window.open('https://' + host + '/authorize_interaction?uri=' + encodeURIComponent(actorUrl),
+ '_blank', 'noopener');
+ return false;
+ }
</script>
</body>
diff --git a/templates/thread_list.html b/templates/thread_list.html
index 3b52c9c..71a5477 100644
--- a/templates/thread_list.html
+++ b/templates/thread_list.html
@@ -23,6 +23,10 @@
</div>
<a href="/{{.Repo}}/threads/new" class="btn btn-primary">New thread</a>
</div>
+{{if .APEnabled}}
+<p class="participate-hint">🐘 Follow <code>@{{.Repo}}@{{.Host}}</code> on Mastodon to get new threads in your feed
+ and reply from there.</p>
+{{end}}
{{if .Threads}}
<div class="thread-list">
diff --git a/templates/thread_new.html b/templates/thread_new.html
index 026db52..b5d69c4 100644
--- a/templates/thread_new.html
+++ b/templates/thread_new.html
@@ -7,6 +7,12 @@
<h2>New Thread</h2>
<form method="POST" action="/{{.Repo}}/threads/new">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
+ {{if not .User}}
+ <div class="form-group">
+ <label for="guest_name">Your name (optional)</label>
+ <input type="text" id="guest_name" name="guest_name" placeholder="Anonymous" maxlength="60">
+ </div>
+ {{end}}
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Brief summary" required>
@@ -17,6 +23,13 @@
</div>
<button type="submit" class="btn btn-primary">Create thread</button>
</form>
+ {{if not .User}}
+ <p class="participate-hint">Posting without an account attributes this thread to "Anonymous" unless you give a
+ name above. <a href="/login">Sign in</a> for a verified identity.
+ {{if .APEnabled}}You can also open a thread by mentioning <code>@{{.Repo}}@{{.Host}}</code> in a post on
+ Mastodon.{{end}}
+ </p>
+ {{end}}
</div>
{{template "foot" .}}
diff --git a/templates/thread_view.html b/templates/thread_view.html
index eed65a7..f862b52 100644
--- a/templates/thread_view.html
+++ b/templates/thread_view.html
@@ -14,7 +14,7 @@
</div>
<!-- Original post -->
-<div class="thread-comment">
+<div class="thread-comment" id="reply-root">
<div class="thread-comment-header">
<strong>{{.Thread.AuthorName}}</strong>
<span>{{timeAgo .Thread.Created}}</span>
@@ -22,36 +22,143 @@
<div class="thread-comment-body markdown-body">{{.Thread.Body}}</div>
</div>
-<!-- Replies -->
-{{range .Thread.Replies}}
-<div class="thread-comment">
- <div class="thread-comment-header">
- <strong>{{.AuthorName}}</strong>
- <span>{{timeAgo .Created}}</span>
+<!-- Replies: one linear, chronological conversation. A reply only points -->
+<!-- back at an earlier message when it isn't simply continuing the line -->
+<!-- right above it — that's the rare, explicit callback worth calling out. -->
+{{if .ReplyViews}}
+<div class="thread-replies">
+ {{range .ReplyViews}}
+ <div class="thread-reply" id="reply-{{.ID}}">
+ {{if .ShowParent}}
+ <button type="button" class="reply-pointer" data-jump="{{.ParentID}}">↩ replying to
+ {{.ParentAuthor}}</button>
+ {{end}}
+ <div class="thread-reply-header">
+ <strong>{{.AuthorName}}</strong>
+ <time>{{timeAgo .Created}}</time>
+ </div>
+ <div class="thread-reply-body markdown-body">{{.Body}}</div>
+ <div class="thread-reply-actions">
+ <button type="button" class="reply-link" data-reply-id="{{.ID}}"
+ data-reply-author="{{.AuthorName}}">Reply</button>
+ </div>
</div>
- <div class="thread-comment-body markdown-body">{{.Body}}</div>
+ {{end}}
</div>
{{end}}
-<!-- Reply form -->
-<div class="thread-reply-form">
- <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply">
- <input type="hidden" name="csrf_token" value="{{.CSRF}}">
- <div class="form-group">
- <textarea name="body" rows="4" placeholder="Leave a comment..."></textarea>
- </div>
- <div class="thread-actions">
- {{if eq .Thread.State "open"}}
- <button type="submit" class="btn btn-primary">Comment</button>
- <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close" class="btn btn-secondary">Close
- thread</button>
- {{else}}
- <button type="submit" class="btn btn-primary">Comment</button>
- <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen" class="btn btn-secondary">Reopen
- thread</button>
+<!-- Participate -->
+<div class="participate">
+ {{if not .User}}
+ <div class="participate-tabs" role="tablist">
+ <button type="button" class="participate-tab active" data-panel="web" onclick="fjTab(this)">On the
+ web</button>
+ {{if .APEnabled}}
+ <button type="button" class="participate-tab" data-panel="mastodon" onclick="fjTab(this)">From
+ Mastodon</button>
+ {{end}}
+ <button type="button" class="participate-tab" data-panel="email" onclick="fjTab(this)">By email</button>
+ </div>
+ {{end}}
+
+ <div class="participate-panel" data-panel="web">
+ <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply" id="reply-form">
+ <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+ <input type="hidden" name="parent_id" id="parent_id" value="">
+ <div class="replying-to" id="replying-to">
+ <span>Replying to <strong id="replying-to-name"></strong></span>
+ <button type="button" id="cancel-target" aria-label="Reply to the thread instead">✕</button>
+ </div>
+ {{if not .User}}
+ <div class="form-group">
+ <input type="text" id="guest_name" name="guest_name" placeholder="Your name (optional)"
+ maxlength="60" class="guest-name-field">
+ </div>
{{end}}
+ <div class="form-group">
+ <textarea name="body" id="reply-body" rows="4" placeholder="Leave a comment..."></textarea>
+ </div>
+ <div class="thread-actions">
+ {{if eq .Thread.State "open"}}
+ <button type="submit" class="btn btn-primary">Comment</button>
+ <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close"
+ class="btn btn-secondary">Close
+ thread</button>
+ {{else}}
+ <button type="submit" class="btn btn-primary">Comment</button>
+ <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen"
+ class="btn btn-secondary">Reopen
+ thread</button>
+ {{end}}
+ </div>
+ </form>
+ {{if not .User}}
+ <p class="participate-hint">Posting without an account attributes your comment to "Anonymous" unless you give
+ a name above. <a href="/login">Sign in</a> for a verified identity.</p>
+ {{end}}
+ </div>
+
+ {{if and (not .User) .APEnabled}}
+ <div class="participate-panel" data-panel="mastodon" hidden>
+ <p class="participate-hint">Follow this repository from Mastodon (or any ActivityPub app) to see new threads
+ and replies in your feed. Reply there — to the thread or to any specific message — and your comment
+ appears here automatically, no fierj account needed.</p>
+ <div class="fediverse-handle">
+ <span class="fediverse-icon" aria-hidden="true">🐘</span>
+ <code>@{{.Repo}}@{{.Host}}</code>
+ </div>
+ <form class="remote-follow-form" data-actor-url="{{.ActorURL}}" onsubmit="return fjRemoteFollow(this)">
+ <input type="text" name="instance" placeholder="your-instance.social"
+ aria-label="Your Mastodon instance domain">
+ <button type="submit" class="btn btn-subtle">Follow from my instance</button>
+ </form>
+ </div>
+ {{end}}
+
+ {{if not .User}}
+ <div class="participate-panel" data-panel="email" hidden>
+ <p class="participate-hint">Get a one-time link by email so you can comment without creating an account.
+ <span class="badge-soon">Coming soon</span>
+ </p>
+ <div class="email-magic-form">
+ <input type="email" placeholder="you@example.com" disabled>
+ <button type="button" class="btn btn-subtle" disabled>Email me a link</button>
</div>
- </form>
+ </div>
+ {{end}}
</div>
-{{template "foot" .}}
+<script>
+ function fjSetReplyTarget(id, author) {
+ document.getElementById('parent_id').value = id;
+ document.getElementById('replying-to-name').textContent = author;
+ document.getElementById('replying-to').classList.add('on');
+ const webTab = document.querySelector('.participate-tab[data-panel="web"]');
+ if (webTab) fjTab(webTab);
+ const ta = document.getElementById('reply-body');
+ ta.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ ta.focus();
+ }
+
+ document.querySelectorAll('.reply-link[data-reply-id]').forEach(btn => {
+ btn.addEventListener('click', () => fjSetReplyTarget(btn.dataset.replyId, btn.dataset.replyAuthor));
+ });
+
+ const cancelBtn = document.getElementById('cancel-target');
+ if (cancelBtn) cancelBtn.addEventListener('click', () => {
+ document.getElementById('parent_id').value = '';
+ document.getElementById('replying-to').classList.remove('on');
+ });
+
+ function fjJumpTo(id) {
+ const el = document.getElementById('reply-' + id) || document.getElementById('reply-root');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.classList.add('flash');
+ setTimeout(() => el.classList.remove('flash'), 900);
+ }
+
+ document.querySelectorAll('.reply-pointer[data-jump]').forEach(btn => {
+ btn.addEventListener('click', () => fjJumpTo(btn.dataset.jump));
+ });
+</script>
+{{template "foot" .}}
diff --git a/thread.go b/thread.go
index 9e9fe97..d7b6d97 100644
--- a/thread.go
+++ b/thread.go
@@ -36,6 +36,7 @@ type Thread struct {
type Reply struct {
ID string `json:"id"`
+ ParentID string `json:"parent_id,omitempty"` // ID of parent reply (empty = reply to thread root)
Body string `json:"body"`
Author string `json:"author"`
AuthorName string `json:"author_name"`
@@ -76,6 +77,11 @@ func createThread(repoPath, title, body, author, authorName string) (*Thread, er
}
func addReply(repoPath, threadID, body, author, authorName string) error {
+ return addNestedReply(repoPath, threadID, "", body, author, authorName)
+}
+
+// addNestedReply adds a reply, optionally nesting it under a parent reply.
+func addNestedReply(repoPath, threadID, parentID, body, author, authorName string) error {
threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
unlock := lockThread(threadPath)
defer unlock()
@@ -88,6 +94,7 @@ func addReply(repoPath, threadID, body, author, authorName string) error {
now := time.Now().UTC().Format(time.RFC3339)
t.Replies = append(t.Replies, Reply{
ID: newULID(),
+ ParentID: parentID,
Body: body,
Author: author,
AuthorName: authorName,
@@ -178,6 +185,44 @@ func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
return threads, openCount, closedCount
}
+// ReplyView augments a Reply with a rendering hint: whether its reply
+// target needs to be called out to the reader.
+type ReplyView struct {
+ Reply
+ ShowParent bool
+ ParentAuthor string
+}
+
+// buildReplyViews lays replies out in their natural chronological (storage)
+// order — a thread reads as one linear conversation, not a tree — and marks
+// which ones need a "replying to X" pointer. A reply to the thread root, or
+// to whichever reply immediately precedes it, is the assumed default (that
+// covers a plain web comment and the common "reply to the latest message"
+// case from Mastodon) and stays unannotated. Anything else is a genuine
+// callback into earlier history and gets pointed out.
+func buildReplyViews(replies []Reply) []ReplyView {
+ byID := make(map[string]Reply, len(replies))
+ for _, r := range replies {
+ byID[r.ID] = r
+ }
+ views := make([]ReplyView, len(replies))
+ prevID := ""
+ for i, r := range replies {
+ v := ReplyView{Reply: r}
+ if r.ParentID != "" && r.ParentID != prevID {
+ v.ShowParent = true
+ if p, ok := byID[r.ParentID]; ok {
+ v.ParentAuthor = p.AuthorName
+ } else {
+ v.ParentAuthor = "an earlier message"
+ }
+ }
+ views[i] = v
+ prevID = r.ID
+ }
+ return views
+}
+
func writeThread(repoPath string, t *Thread) error {
dir := threadsDir(repoPath)
if err := os.MkdirAll(dir, 0755); err != nil {
diff --git a/thread_handlers.go b/thread_handlers.go
index cc24233..6e623e1 100644
--- a/thread_handlers.go
+++ b/thread_handlers.go
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"html/template"
+ "log/slog"
"net/http"
"path/filepath"
"strings"
@@ -38,6 +39,8 @@ func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "threads",
+ "Host": cfg.Host,
+ "APEnabled": cfg.Host != "",
}))
}
}
@@ -58,6 +61,8 @@ func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "threads",
+ "Host": cfg.Host,
+ "APEnabled": cfg.Host != "",
}))
}
}
@@ -101,17 +106,28 @@ func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
return
}
ref := git.DefaultBranch()
- tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
+ apEnabled := cfg.Host != ""
+ actorURLStr := ""
+ if apEnabled {
+ actorURLStr = actorURL(cfg.Host, git.Name)
+ }
+ if err := tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
"Repo": git.Repo(),
"IsPrivate": git.IsPrivate(),
"Ref": ref,
"Thread": t,
+ "ReplyViews": buildReplyViews(t.Replies),
"Description": git.Description(),
"Branches": git.Branches(),
"Tags": git.Tags(),
"Commits": git.CommitCount(ref),
"ActiveTab": "threads",
- }))
+ "Host": cfg.Host,
+ "APEnabled": apEnabled,
+ "ActorURL": actorURLStr,
+ })); err != nil {
+ slog.Error("thread view template", "error", err)
+ }
}
}
@@ -129,20 +145,36 @@ func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
}
author, authorName := threadAuthor(r)
rp := repoPath(cfg, git.Name)
- if err := addReply(rp, threadID, body, author, authorName); err != nil {
+ parentID := strings.TrimSpace(r.FormValue("parent_id"))
+ if err := addNestedReply(rp, threadID, parentID, body, author, authorName); err != nil {
renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
return
}
+ // Deliver reply to ActivityPub followers.
+ t, _ := loadThread(rp, threadID)
+ if t != nil {
+ go deliverReplyCreate(cfg, git.Name, t)
+ }
http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
}
}
+// threadAuthor identifies the actor posting a thread/reply. Logged-in users
+// are attributed by their account name. Anonymous web visitors are always
+// recorded as "anonymous" (no identity is verified), but may attach a
+// free-text display name so their comments are distinguishable in the UI.
func threadAuthor(r *http.Request) (author, displayName string) {
- author = User(r)
- if author == "" {
- author = "anonymous"
+ if u := User(r); u != "" {
+ return u, u
+ }
+ name := strings.TrimSpace(r.FormValue("guest_name"))
+ if name == "" {
+ return "anonymous", "Anonymous"
+ }
+ if len(name) > 60 {
+ name = name[:60]
}
- return author, author
+ return "anonymous", name
}
func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {