Go · 4744 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/pat"
22 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
23 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
24 "github.com/tenseleyFlow/shithub/internal/infra/storage"
25 "github.com/tenseleyFlow/shithub/internal/ratelimit"
26 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
27 "github.com/tenseleyFlow/shithub/internal/web/middleware"
28 )
29
30 // Deps is the wiring the API handlers need. Constructed by the web
31 // package and injected at registration time.
32 type Deps struct {
33 Pool *pgxpool.Pool
34 Debouncer *pat.Debouncer
35 Logger *slog.Logger
36 ObjectStore storage.ObjectStore
37 RepoFS *storage.RepoFS
38 RunnerJWT *runnerjwt.Signer
39 SecretBox *secretbox.Box
40 RateLimiter *ratelimit.Limiter
41 }
42
43 // Handlers is the registered API handler set. Construct with New.
44 type Handlers struct {
45 d Deps
46 q *usersdb.Queries
47 }
48
49 // New constructs the handler set, validating Deps.
50 func New(d Deps) (*Handlers, error) {
51 if d.Pool == nil {
52 return nil, errors.New("api: nil Pool")
53 }
54 if d.Debouncer == nil {
55 d.Debouncer = pat.NewDebouncer(0)
56 }
57 if d.Logger == nil {
58 d.Logger = slog.Default()
59 }
60 return &Handlers{d: d, q: usersdb.New()}, nil
61 }
62
63 // apiMaxBodyBytes caps the request body for any /api/v1 handler. The
64 // largest documented payload today is a check-run create with a 64 KiB
65 // summary + 64 KiB text — comfortably below this. Tightening per-route
66 // is fine; widening should happen at the route group, not here.
67 //
68 // The auth-side cap (signup/login/reset) is a separate, lower limit
69 // (`MaxBodySize(8 KiB)`) wired in `auth_wiring.go`. This cap defends
70 // the same surface against a misbehaving PAT-bearing client shipping
71 // a 50 MB JSON blob to weaponize the parser.
72 const apiMaxBodyBytes = 256 * 1024
73
74 // runnerAPIMaxBodyBytes must fit a 512 KiB raw log chunk after base64
75 // expansion plus JSON framing.
76 const runnerAPIMaxBodyBytes = 768 * 1024
77
78 // Mount registers /api/v1/* on r. Caller is responsible for putting r
79 // in a CSRF-exempt group.
80 func (h *Handlers) Mount(r chi.Router) {
81 r.Group(func(r chi.Router) {
82 r.Use(middleware.MaxBodySize(runnerAPIMaxBodyBytes))
83 h.mountRunners(r)
84 })
85 r.Group(func(r chi.Router) {
86 r.Use(middleware.MaxBodySize(apiMaxBodyBytes))
87 r.Use(middleware.PATAuthMiddleware(middleware.PATConfig{
88 Pool: h.d.Pool,
89 Debouncer: h.d.Debouncer,
90 }))
91 r.Group(func(r chi.Router) {
92 r.Use(middleware.RequireScope(pat.ScopeUserRead))
93 r.Get("/api/v1/user", h.userMe)
94 })
95 // S24 check-runs / check-suites — RequireScope is per-route
96 // inside the helper since reads need repo:read but writes need
97 // repo:write.
98 h.mountChecks(r)
99 // S41g Actions lifecycle controls.
100 h.mountActionsLifecycle(r)
101 // S26 stars: PUT/DELETE need user:write, GET needs user:read.
102 h.mountStars(r)
103 })
104 }
105
106 // userResponse is the public shape of a user record. Mirrors GitHub's
107 // /user response in spirit; we'll grow it organically as features land.
108 type userResponse struct {
109 ID int64 `json:"id"`
110 Username string `json:"username"`
111 Name string `json:"name,omitempty"`
112 Verified bool `json:"email_verified"`
113 CreatedAt string `json:"created_at"`
114 }
115
116 func (h *Handlers) userMe(w http.ResponseWriter, r *http.Request) {
117 auth := middleware.PATAuthFromContext(r.Context())
118 if auth.UserID == 0 {
119 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
120 return
121 }
122 user, err := h.q.GetUserByID(r.Context(), h.d.Pool, auth.UserID)
123 if err != nil {
124 writeAPIError(w, http.StatusNotFound, "user not found")
125 return
126 }
127 writeJSON(w, http.StatusOK, userResponse{
128 ID: user.ID,
129 Username: user.Username,
130 Name: user.DisplayName,
131 Verified: user.EmailVerified,
132 CreatedAt: user.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
133 })
134 }
135
136 func writeJSON(w http.ResponseWriter, status int, body any) {
137 w.Header().Set("Content-Type", "application/json; charset=utf-8")
138 w.Header().Set("Cache-Control", "no-store")
139 w.WriteHeader(status)
140 _ = json.NewEncoder(w).Encode(body)
141 }
142
143 func writeAPIError(w http.ResponseWriter, status int, msg string) {
144 writeJSON(w, status, map[string]string{"error": msg})
145 }
146