| 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 | "context" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 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 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 23 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 24 | ) |
| 25 | |
| 26 | // Deps is the wiring the API handlers need. Constructed by the web |
| 27 | // package and injected at registration time. |
| 28 | type Deps struct { |
| 29 | Pool *pgxpool.Pool |
| 30 | Debouncer *pat.Debouncer |
| 31 | } |
| 32 | |
| 33 | // Handlers is the registered API handler set. Construct with New. |
| 34 | type Handlers struct { |
| 35 | d Deps |
| 36 | q *usersdb.Queries |
| 37 | } |
| 38 | |
| 39 | // New constructs the handler set, validating Deps. |
| 40 | func New(d Deps) (*Handlers, error) { |
| 41 | if d.Pool == nil { |
| 42 | return nil, errors.New("api: nil Pool") |
| 43 | } |
| 44 | if d.Debouncer == nil { |
| 45 | d.Debouncer = pat.NewDebouncer(0) |
| 46 | } |
| 47 | return &Handlers{d: d, q: usersdb.New()}, nil |
| 48 | } |
| 49 | |
| 50 | // Mount registers /api/v1/* on r. Caller is responsible for putting r |
| 51 | // in a CSRF-exempt group. |
| 52 | func (h *Handlers) Mount(r chi.Router) { |
| 53 | r.Group(func(r chi.Router) { |
| 54 | r.Use(middleware.PATAuthMiddleware(middleware.PATConfig{ |
| 55 | Pool: h.d.Pool, |
| 56 | Debouncer: h.d.Debouncer, |
| 57 | })) |
| 58 | r.Group(func(r chi.Router) { |
| 59 | r.Use(middleware.RequireScope(pat.ScopeUserRead)) |
| 60 | r.Get("/api/v1/user", h.userMe) |
| 61 | }) |
| 62 | // S24 check-runs / check-suites — RequireScope is per-route |
| 63 | // inside the helper since reads need repo:read but writes need |
| 64 | // repo:write. |
| 65 | h.mountChecks(r) |
| 66 | }) |
| 67 | } |
| 68 | |
| 69 | // userResponse is the public shape of a user record. Mirrors GitHub's |
| 70 | // /user response in spirit; we'll grow it organically as features land. |
| 71 | type userResponse struct { |
| 72 | ID int64 `json:"id"` |
| 73 | Username string `json:"username"` |
| 74 | Name string `json:"name,omitempty"` |
| 75 | Verified bool `json:"email_verified"` |
| 76 | CreatedAt string `json:"created_at"` |
| 77 | } |
| 78 | |
| 79 | func (h *Handlers) userMe(w http.ResponseWriter, r *http.Request) { |
| 80 | auth := middleware.PATAuthFromContext(r.Context()) |
| 81 | if auth.UserID == 0 { |
| 82 | writeAPIError(w, http.StatusUnauthorized, "unauthenticated") |
| 83 | return |
| 84 | } |
| 85 | user, err := h.q.GetUserByID(r.Context(), h.d.Pool, auth.UserID) |
| 86 | if err != nil { |
| 87 | writeAPIError(w, http.StatusNotFound, "user not found") |
| 88 | return |
| 89 | } |
| 90 | writeJSON(w, http.StatusOK, userResponse{ |
| 91 | ID: user.ID, |
| 92 | Username: user.Username, |
| 93 | Name: user.DisplayName, |
| 94 | Verified: user.EmailVerified, |
| 95 | CreatedAt: user.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | func writeJSON(w http.ResponseWriter, status int, body any) { |
| 100 | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 101 | w.Header().Set("Cache-Control", "no-store") |
| 102 | w.WriteHeader(status) |
| 103 | _ = json.NewEncoder(w).Encode(body) |
| 104 | } |
| 105 | |
| 106 | func writeAPIError(w http.ResponseWriter, status int, msg string) { |
| 107 | writeJSON(w, status, map[string]string{"error": msg}) |
| 108 | } |
| 109 | |
| 110 | // silence unused import on the rare branch where context is not used. |
| 111 | var _ = context.Background |
| 112 |