Go · 6990 bytes Raw Blame History
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