| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | // Package api owns shithub's PAT-authenticated HTTP API surface. S08 |
| 4 | // ships exactly one route: GET /api/v1/user. Later sprints (S22 PRs, |
| 5 | // S30 orgs, …) extend the surface here. |
| 6 | // |
| 7 | // Routes registered here run inside the CSRF-EXEMPT group of the chi |
| 8 | // router. Auth is provided by the PATAuth middleware: no PAT → 401; |
| 9 | // PAT lacking the required scope → 403; valid scoped PAT → 200. |
| 10 | package api |
| 11 | |
| 12 | import ( |
| 13 | "encoding/json" |
| 14 | "errors" |
| 15 | "log/slog" |
| 16 | "net/http" |
| 17 | |
| 18 | "github.com/go-chi/chi/v5" |
| 19 | "github.com/jackc/pgx/v5/pgxpool" |
| 20 | |
| 21 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 22 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 23 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" |
| 24 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 25 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 26 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 27 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 28 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 29 | "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit" |
| 30 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 31 | ) |
| 32 | |
| 33 | // Deps is the wiring the API handlers need. Constructed by the web |
| 34 | // package and injected at registration time. |
| 35 | type Deps struct { |
| 36 | Pool *pgxpool.Pool |
| 37 | Debouncer *pat.Debouncer |
| 38 | Logger *slog.Logger |
| 39 | ObjectStore storage.ObjectStore |
| 40 | RepoFS *storage.RepoFS |
| 41 | RunnerJWT *runnerjwt.Signer |
| 42 | SecretBox *secretbox.Box |
| 43 | RateLimiter *ratelimit.Limiter |
| 44 | // Audit records security-sensitive mutations (repo create/delete, |
| 45 | // settings changes). Required for any handler that mutates server |
| 46 | // state; nil disables the audit emission for that handler. |
| 47 | Audit *audit.Recorder |
| 48 | // Throttle is the per-actor anti-abuse limiter consulted by |
| 49 | // repos.Create. Independent from RateLimiter (which is the |
| 50 | // shared-counter rate-limit subsystem) — Throttle uses the older |
| 51 | // per-action counter that the HTML create flow shares with us so |
| 52 | // budgets are observed consistently across both surfaces. |
| 53 | Throttle *throttle.Limiter |
| 54 | // ShithubdPath is the absolute path of the running shithubd |
| 55 | // binary, forwarded to repos.Create so its hook shims resolve |
| 56 | // correctly. Empty disables hook installation (test fixtures). |
| 57 | ShithubdPath string |
| 58 | // BaseURL is the public scheme://host prefix used for absolute |
| 59 | // pagination Link headers. Empty falls back to path-relative URLs. |
| 60 | BaseURL string |
| 61 | // APILimit configures the /api/v1/* rate-limit middleware. Zero |
| 62 | // values inherit apilimit.Middleware's no-op fallback. |
| 63 | APILimit apilimit.Config |
| 64 | } |
| 65 | |
| 66 | // Handlers is the registered API handler set. Construct with New. |
| 67 | type Handlers struct { |
| 68 | d Deps |
| 69 | q *usersdb.Queries |
| 70 | } |
| 71 | |
| 72 | // New constructs the handler set, validating Deps. |
| 73 | func New(d Deps) (*Handlers, error) { |
| 74 | if d.Pool == nil { |
| 75 | return nil, errors.New("api: nil Pool") |
| 76 | } |
| 77 | if d.Debouncer == nil { |
| 78 | d.Debouncer = pat.NewDebouncer(0) |
| 79 | } |
| 80 | if d.Logger == nil { |
| 81 | d.Logger = slog.Default() |
| 82 | } |
| 83 | return &Handlers{d: d, q: usersdb.New()}, nil |
| 84 | } |
| 85 | |
| 86 | // apiMaxBodyBytes caps the request body for any /api/v1 handler. The |
| 87 | // largest documented payload today is a check-run create with a 64 KiB |
| 88 | // summary + 64 KiB text — comfortably below this. Tightening per-route |
| 89 | // is fine; widening should happen at the route group, not here. |
| 90 | // |
| 91 | // The auth-side cap (signup/login/reset) is a separate, lower limit |
| 92 | // (`MaxBodySize(8 KiB)`) wired in `auth_wiring.go`. This cap defends |
| 93 | // the same surface against a misbehaving PAT-bearing client shipping |
| 94 | // a 50 MB JSON blob to weaponize the parser. |
| 95 | const apiMaxBodyBytes = 256 * 1024 |
| 96 | |
| 97 | // runnerAPIMaxBodyBytes must fit a 512 KiB raw log chunk after base64 |
| 98 | // expansion plus JSON framing. |
| 99 | const runnerAPIMaxBodyBytes = 768 * 1024 |
| 100 | |
| 101 | // Mount registers /api/v1/* on r. Caller is responsible for putting r |
| 102 | // in a CSRF-exempt group. |
| 103 | // |
| 104 | // Outer middleware on every /api/v1/* request: apilimit stamps the |
| 105 | // X-RateLimit-* headers and refuses over-budget callers with a JSON |
| 106 | // 429. Inner groups attach body caps, PAT auth, and scope decorators |
| 107 | // according to the surface they expose. |
| 108 | func (h *Handlers) Mount(r chi.Router) { |
| 109 | apiLimitMW := apilimit.Middleware(h.d.RateLimiter, apilimit.Config{ |
| 110 | AuthedPerHour: h.d.APILimit.AuthedPerHour, |
| 111 | AnonPerHour: h.d.APILimit.AnonPerHour, |
| 112 | Logger: h.d.Logger, |
| 113 | }) |
| 114 | r.Group(func(r chi.Router) { |
| 115 | r.Use(middleware.MaxBodySize(runnerAPIMaxBodyBytes)) |
| 116 | r.Use(apiLimitMW) |
| 117 | h.mountRunners(r) |
| 118 | }) |
| 119 | r.Group(func(r chi.Router) { |
| 120 | r.Use(middleware.MaxBodySize(apiMaxBodyBytes)) |
| 121 | r.Use(middleware.PATAuthMiddleware(middleware.PATConfig{ |
| 122 | Pool: h.d.Pool, |
| 123 | Debouncer: h.d.Debouncer, |
| 124 | })) |
| 125 | r.Use(apiLimitMW) |
| 126 | // /meta is capability discovery — no scope required, anon ok. |
| 127 | h.mountMeta(r) |
| 128 | r.Group(func(r chi.Router) { |
| 129 | r.Use(middleware.RequireScope(pat.ScopeUserRead)) |
| 130 | r.Get("/api/v1/user", h.userMe) |
| 131 | }) |
| 132 | // S24 check-runs / check-suites — RequireScope is per-route |
| 133 | // inside the helper since reads need repo:read but writes need |
| 134 | // repo:write. |
| 135 | h.mountChecks(r) |
| 136 | // S41g Actions lifecycle controls. |
| 137 | h.mountActionsLifecycle(r) |
| 138 | // S26 stars: PUT/DELETE need user:write, GET needs user:read. |
| 139 | h.mountStars(r) |
| 140 | // S50 §1 — user emails (read-only over REST). |
| 141 | h.mountUserEmails(r) |
| 142 | // S50 §1 — user SSH keys CRUD. |
| 143 | h.mountUserKeys(r) |
| 144 | // S50 §2 — repos REST core (list/single/create/patch/delete). |
| 145 | h.mountRepos(r) |
| 146 | // S50 §3 — issues + comments + lock. |
| 147 | h.mountIssues(r) |
| 148 | // S50 §3 — repo labels CRUD. |
| 149 | h.mountLabels(r) |
| 150 | // S50 §4 — pull requests core (list/get/create/patch/merge). |
| 151 | h.mountPulls(r) |
| 152 | }) |
| 153 | } |
| 154 | |
| 155 | // userResponse is the public shape of a user record. Mirrors GitHub's |
| 156 | // /user response in spirit; we'll grow it organically as features land. |
| 157 | type userResponse struct { |
| 158 | ID int64 `json:"id"` |
| 159 | Username string `json:"username"` |
| 160 | Name string `json:"name,omitempty"` |
| 161 | Verified bool `json:"email_verified"` |
| 162 | CreatedAt string `json:"created_at"` |
| 163 | } |
| 164 | |
| 165 | func (h *Handlers) userMe(w http.ResponseWriter, r *http.Request) { |
| 166 | auth := middleware.PATAuthFromContext(r.Context()) |
| 167 | if auth.UserID == 0 { |
| 168 | writeAPIError(w, http.StatusUnauthorized, "unauthenticated") |
| 169 | return |
| 170 | } |
| 171 | user, err := h.q.GetUserByID(r.Context(), h.d.Pool, auth.UserID) |
| 172 | if err != nil { |
| 173 | writeAPIError(w, http.StatusNotFound, "user not found") |
| 174 | return |
| 175 | } |
| 176 | writeJSON(w, http.StatusOK, userResponse{ |
| 177 | ID: user.ID, |
| 178 | Username: user.Username, |
| 179 | Name: user.DisplayName, |
| 180 | Verified: user.EmailVerified, |
| 181 | CreatedAt: user.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), |
| 182 | }) |
| 183 | } |
| 184 | |
| 185 | func writeJSON(w http.ResponseWriter, status int, body any) { |
| 186 | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 187 | w.Header().Set("Cache-Control", "no-store") |
| 188 | w.WriteHeader(status) |
| 189 | _ = json.NewEncoder(w).Encode(body) |
| 190 | } |
| 191 | |
| 192 | func writeAPIError(w http.ResponseWriter, status int, msg string) { |
| 193 | writeJSON(w, status, map[string]string{"error": msg}) |
| 194 | } |
| 195 |