package federation import ( "encoding/json" "fmt" "net/http" "time" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/models" ) const activityJSONType = "application/activity+json" // FetchActor retrieves a remote actor document by APID. If it is already cached // in the remote_actor table it is returned immediately; otherwise it is fetched // over HTTP and persisted before returning. func FetchActor(db *xorm.Engine, apid string) (*models.RemoteActor, error) { var cached models.RemoteActor if found, _ := db.Where("ap_id = ?", apid).Get(&cached); found { return &cached, nil } client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", apid, nil) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Accept", activityJSONType+", application/ld+json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch actor %s: %w", apid, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch actor %s: HTTP %d", apid, resp.StatusCode) } var doc map[string]any if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { return nil, fmt.Errorf("decode actor: %w", err) } inbox, _ := doc["inbox"].(string) pubKey := extractPublicKeyPEM(doc) actor := &models.RemoteActor{ APID: apid, InboxURL: inbox, PublicKey: pubKey, FetchedAt: time.Now().UTC(), } // Upsert: ignore duplicate key errors (concurrent fetch). db.Insert(actor) //nolint:errcheck // Reload to get the DB-assigned ID. db.Where("ap_id = ?", apid).Get(actor) //nolint:errcheck return actor, nil } // DeliverActivity POSTs a signed activity to a remote actor's inbox. func DeliverActivity(localActor *models.FederationActor, activity map[string]any, recipientInbox string) error { body, err := json.Marshal(activity) if err != nil { return fmt.Errorf("marshal activity: %w", err) } req, err := http.NewRequest("POST", recipientInbox, jsonReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", activityJSONType) req.Header.Set("Accept", activityJSONType) if err := Sign(req, localActor.APID+"#main-key", localActor.PrivateKey); err != nil { return fmt.Errorf("sign: %w", err) } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("deliver to %s: %w", recipientInbox, err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("deliver to %s: HTTP %d", recipientInbox, resp.StatusCode) } return nil } func extractPublicKeyPEM(doc map[string]any) string { pk, ok := doc["publicKey"].(map[string]any) if !ok { return "" } pem, _ := pk["publicKeyPem"].(string) return pem } // jsonReader wraps a byte slice in a reader that io.ReadCloser can use. func jsonReader(data []byte) *bytesReader { return &bytesReader{data: data, pos: 0} } type bytesReader struct { data []byte pos int } func (r *bytesReader) Read(p []byte) (int, error) { if r.pos >= len(r.data) { return 0, fmt.Errorf("EOF") } n := copy(p, r.data[r.pos:]) r.pos += n return n, nil }