package handlers import ( "encoding/json" "io" "log" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/config" "github.com/forgeo/forgebucket/internal/domain/federation" "github.com/forgeo/forgebucket/internal/models" ) const activityJSONType = "application/activity+json" type FederationHandler struct { db *xorm.Engine cfg *config.Config } func NewFederationHandler(db *xorm.Engine, cfg *config.Config) *FederationHandler { return &FederationHandler{db: db, cfg: cfg} } // WebFinger handles GET /.well-known/webfinger?resource=acct:user@domain func (h *FederationHandler) WebFinger(w http.ResponseWriter, r *http.Request) { resource := r.URL.Query().Get("resource") if !strings.HasPrefix(resource, "acct:") { http.Error(w, "resource must use acct: scheme", http.StatusBadRequest) return } // acct:username@domain — extract username acct := strings.TrimPrefix(resource, "acct:") username := strings.SplitN(acct, "@", 2)[0] var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actorURL := federation.APID(h.cfg.InstanceURL, username) resp := map[string]any{ "subject": resource, "links": []map[string]any{ { "rel": "self", "type": activityJSONType, "href": actorURL, }, }, } w.Header().Set("Content-Type", "application/jrd+json") json.NewEncoder(w).Encode(resp) //nolint:errcheck } // Actor handles GET /users/{username} — returns the JSON-LD actor document. func (h *FederationHandler) Actor(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL) if err != nil { http.Error(w, "could not get actor", http.StatusInternalServerError) return } doc := federation.ActorJSON(actor, username, username) w.Header().Set("Content-Type", activityJSONType) json.NewEncoder(w).Encode(doc) //nolint:errcheck } // Inbox handles POST /users/{username}/inbox — receive an ActivityPub activity. func (h *FederationHandler) Inbox(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max if err != nil { http.Error(w, "could not read body", http.StatusBadRequest) return } // Verify HTTP signature. In debug mode, skip verification so local testing works. if !h.cfg.Debug { if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil { http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized) return } } if err := federation.Receive(h.db, actor, body); err != nil { http.Error(w, "could not process activity: "+err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusAccepted) } // OutboxGet handles GET /users/{username}/outbox — serve the activity collection. func (h *FederationHandler) OutboxGet(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) coll := federation.Collection(h.db, actor.APID, actor.OutboxURL, page) w.Header().Set("Content-Type", activityJSONType) json.NewEncoder(w).Encode(coll) //nolint:errcheck } // Followers handles GET /users/{username}/followers func (h *FederationHandler) Followers(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } coll := federation.StubCollection(actor.APID + "/followers") w.Header().Set("Content-Type", activityJSONType) json.NewEncoder(w).Encode(coll) //nolint:errcheck } // RepoActor handles GET /repos/{owner}/{repo}/actor — returns the ForgeFed // Repository actor document for cross-instance pull requests. func (h *FederationHandler) RepoActor(w http.ResponseWriter, r *http.Request) { owner := chi.URLParam(r, "owner") repoName := chi.URLParam(r, "repo") var repo models.Repository if found, _ := h.db.Where("name = ?", repoName). Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner). Get(&repo); !found { http.NotFound(w, r) return } doc := federation.RepoActorJSON(owner, repoName, repo.Description, h.cfg.InstanceURL) w.Header().Set("Content-Type", activityJSONType) json.NewEncoder(w).Encode(doc) //nolint:errcheck } // RepoInbox handles POST /repos/{owner}/{repo}/inbox — receive ForgeFed // activities for a repository (e.g. Create(PullRequest)). func (h *FederationHandler) RepoInbox(w http.ResponseWriter, r *http.Request) { owner := chi.URLParam(r, "owner") repoName := chi.URLParam(r, "repo") var repo models.Repository if found, _ := h.db.Where("name = ?", repoName). Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner). Get(&repo); !found { http.NotFound(w, r) return } _ = repo body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "could not read body", http.StatusBadRequest) return } // Determine the local repo actor APID. localActorAPID := federation.RepoAPID(h.cfg.InstanceURL, owner, repoName) // For repository inbox, we need a local actor for the repo owner. var ownerUser models.User if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found { http.Error(w, "owner not found", http.StatusInternalServerError) return } if !h.cfg.Debug { if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil { http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized) return } } // Persist the activity. entry := &models.FederationActivity{ ActorAPID: localActorAPID, Type: "Create", ObjectJSON: string(body), Direction: "inbound", RemoteActor: localActorAPID, Published: time.Now().UTC(), } h.db.Insert(entry) //nolint:errcheck // Handle Create(PullRequest). if err := federation.HandleCreatePullRequest(h.db, body, h.cfg.InstanceURL); err != nil { log.Printf("federation: repo inbox handle: %v", err) } w.WriteHeader(http.StatusAccepted) } func (h *FederationHandler) Following(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User if found, _ := h.db.Where("username = ?", username).Get(&user); !found { http.NotFound(w, r) return } actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } coll := federation.StubCollection(actor.APID + "/following") w.Header().Set("Content-Type", activityJSONType) json.NewEncoder(w).Encode(coll) //nolint:errcheck }