package httpapi import ( "context" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "strings" "time" "hopweb/internal/gitea" "hopweb/internal/store" ) const maxWebhookBody = 2 << 20 type giteaClient interface { Repository(context.Context, string, string) (gitea.Repository, error) } type server struct { store store.Store gitea giteaClient adminToken string webhookSecret string logger *slog.Logger } func New(data store.Store, client giteaClient, adminToken, webhookSecret string, logger *slog.Logger) http.Handler { s := &server{store: data, gitea: client, adminToken: adminToken, webhookSecret: webhookSecret, logger: logger} mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /readyz", s.ready) mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories))) mux.Handle("POST /api/v1/repositories/link", s.requireAdmin(http.HandlerFunc(s.linkRepository))) mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook) return requestLogger(logger, mux) } func (s *server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *server) ready(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := s.store.Ping(ctx); err != nil { writeError(w, http.StatusServiceUnavailable, "database unavailable") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } func (s *server) listRepositories(w http.ResponseWriter, r *http.Request) { repositories, err := s.store.ListRepositories(r.Context()) if err != nil { s.logger.Error("list repositories", "error", err) writeError(w, http.StatusInternalServerError, "could not list repositories") return } writeJSON(w, http.StatusOK, map[string]any{"repositories": repositories}) } func (s *server) linkRepository(w http.ResponseWriter, r *http.Request) { var input struct { Owner string `json:"owner"` Name string `json:"name"` } if err := decodeJSON(w, r, &input); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if !validSlug(input.Owner) || !validSlug(input.Name) { writeError(w, http.StatusBadRequest, "owner and name are required and cannot contain slashes") return } repository, err := s.gitea.Repository(r.Context(), input.Owner, input.Name) if err != nil { if errors.Is(err, gitea.ErrTokenNotConfigured) { writeError(w, http.StatusServiceUnavailable, err.Error()) return } s.logger.Warn("gitea repository lookup failed", "owner", input.Owner, "name", input.Name, "error", err) writeError(w, http.StatusBadGateway, "could not load repository from Gitea") return } saved, err := s.store.UpsertRepository(r.Context(), repository) if err != nil { s.logger.Error("link repository", "error", err) writeError(w, http.StatusInternalServerError, "could not link repository") return } writeJSON(w, http.StatusCreated, saved) } func (s *server) giteaWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody)) if err != nil { writeError(w, http.StatusRequestEntityTooLarge, "webhook payload too large") return } if !gitea.VerifySignature(body, r.Header.Get("X-Gitea-Signature"), s.webhookSecret) { writeError(w, http.StatusUnauthorized, "invalid webhook signature") return } deliveryID := strings.TrimSpace(r.Header.Get("X-Gitea-Delivery")) event := strings.TrimSpace(r.Header.Get("X-Gitea-Event")) if deliveryID == "" || event == "" { writeError(w, http.StatusBadRequest, "missing Gitea delivery or event header") return } var payload struct { Repository *gitea.Repository `json:"repository"` } if err := json.Unmarshal(body, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON payload") return } digest := sha256.Sum256(body) duplicate, err := s.store.RecordWebhook(r.Context(), store.WebhookDelivery{ DeliveryID: deliveryID, Event: event, EventType: r.Header.Get("X-Gitea-Event-Type"), PayloadSHA256: hex.EncodeToString(digest[:]), Repository: payload.Repository, }) if err != nil { s.logger.Error("record webhook", "delivery_id", deliveryID, "error", err) writeError(w, http.StatusInternalServerError, "could not record webhook") return } if duplicate { w.Header().Set("X-Hop-Duplicate", "true") } w.WriteHeader(http.StatusNoContent) } func (s *server) requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authorization := r.Header.Get("Authorization") if !strings.HasPrefix(authorization, "Bearer ") { writeError(w, http.StatusUnauthorized, "unauthorized") return } provided := strings.TrimPrefix(authorization, "Bearer ") if len(provided) != len(s.adminToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.adminToken)) != 1 { writeError(w, http.StatusUnauthorized, "unauthorized") return } next.ServeHTTP(w, r) }) } func validSlug(value string) bool { value = strings.TrimSpace(value) return value != "" && !strings.ContainsAny(value, "/\\") } func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return errors.New("invalid JSON body") } if err := decoder.Decode(&struct{}{}); err != io.EOF { return errors.New("request body must contain one JSON object") } return nil } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}}) } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func requestLogger(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() next.ServeHTTP(w, r) logger.Info("request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds()) }) }