New Sign in

fierj

Public

Tiny personal git forge

← fierj / ap.go
package main

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"

// ----- 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 == "" {
		return
	}
	actor := actorURL(cfg.Host, repoName)
	threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID

	note := APNote{
		ID:           threadURL,
		Type:         "Note",
		Published:    t.Created,
		AttributedTo: actor,
		Content:      t.Title + "\n\n" + t.Body,
		URL:          threadURL,
		To:           []string{apPublic},
		CC:           []string{followersURL(cfg.Host, repoName)},
	}
	create := map[string]any{
		"@context":  apContext,
		"id":        threadURL + "/activity",
		"type":      "Create",
		"actor":     actor,
		"published": t.Created,
		"to":        []string{apPublic},
		"cc":        []string{followersURL(cfg.Host, repoName)},
		"object":    note,
	}

	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
}