Go · 7199 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package web
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "os"
13 "path/filepath"
14 "time"
15
16 "github.com/jackc/pgx/v5/pgxpool"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/audit"
19 "github.com/tenseleyFlow/shithub/internal/auth/email"
20 "github.com/tenseleyFlow/shithub/internal/auth/password"
21 "github.com/tenseleyFlow/shithub/internal/auth/pat"
22 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
23 "github.com/tenseleyFlow/shithub/internal/auth/sealbox"
24 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
25 "github.com/tenseleyFlow/shithub/internal/auth/session"
26 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
27 "github.com/tenseleyFlow/shithub/internal/infra/config"
28 "github.com/tenseleyFlow/shithub/internal/infra/storage"
29 "github.com/tenseleyFlow/shithub/internal/ratelimit"
30 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
31 apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
32 "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
33 authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
34 "github.com/tenseleyFlow/shithub/internal/web/middleware"
35 "github.com/tenseleyFlow/shithub/internal/web/render"
36 "github.com/tenseleyFlow/shithub/internal/webhook"
37 )
38
39 // sharedPATDebouncer is used by both the PATAuth middleware (in the API
40 // route group) and any future paths that want to coordinate last-used
41 // updates across handlers in the same process.
42 var sharedPATDebouncer = pat.NewDebouncer(0)
43
44 // buildAPIHandlers wires the PAT-authenticated API surface.
45 func buildAPIHandlers(
46 cfg config.Config,
47 pool *pgxpool.Pool,
48 objectStore storage.ObjectStore,
49 runnerJWT *runnerjwt.Signer,
50 secretBox *secretbox.Box,
51 rateLimiter *ratelimit.Limiter,
52 logger *slog.Logger,
53 ) (*apih.Handlers, error) {
54 if cfg.Storage.ReposRoot == "" {
55 return nil, errors.New("api: cfg.Storage.ReposRoot is empty")
56 }
57 root, err := filepath.Abs(cfg.Storage.ReposRoot)
58 if err != nil {
59 return nil, fmt.Errorf("api: resolve repos_root: %w", err)
60 }
61 rfs, err := storage.NewRepoFS(root)
62 if err != nil {
63 return nil, fmt.Errorf("api: NewRepoFS: %w", err)
64 }
65 shithubdPath := "shithubd"
66 if exe, err := os.Executable(); err == nil {
67 if abs, absErr := filepath.Abs(exe); absErr == nil {
68 shithubdPath = abs
69 }
70 }
71 // X25519 sealed-box keypair for the REST `actions/secrets/public-key`
72 // endpoint. Loaded from config when present; auto-generated with a
73 // loud warning otherwise (dev convenience — production deployments
74 // MUST set the env knob so secrets survive process restart).
75 var secretsBox *sealbox.Box
76 if pk := cfg.Actions.Secrets.BoxPrivateKeyB64; pk != "" {
77 b, err := sealbox.FromBase64(pk)
78 if err != nil {
79 return nil, fmt.Errorf("api: actions secrets box: %w", err)
80 }
81 secretsBox = b
82 } else {
83 b, err := sealbox.New()
84 if err != nil {
85 return nil, fmt.Errorf("api: actions secrets box (auto): %w", err)
86 }
87 logger.Warn("actions secrets box: auto-generated keypair; secrets PUT against this process will not be decryptable after restart",
88 "hint", "set SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64=$(openssl rand -base64 32) for persistence")
89 secretsBox = b
90 }
91 return apih.New(apih.Deps{
92 Pool: pool,
93 Debouncer: sharedPATDebouncer,
94 Logger: logger,
95 ObjectStore: objectStore,
96 RepoFS: rfs,
97 RunnerJWT: runnerJWT,
98 SecretBox: secretBox,
99 SecretsBox: secretsBox,
100 RateLimiter: rateLimiter,
101 Audit: audit.NewRecorder(),
102 Throttle: throttle.NewLimiter(),
103 ShithubdPath: shithubdPath,
104 BaseURL: cfg.Auth.BaseURL,
105 APILimit: apilimit.Config{
106 AuthedPerHour: cfg.RateLimit.API.AuthedPerHour,
107 AnonPerHour: cfg.RateLimit.API.AnonPerHour,
108 Logger: logger,
109 },
110 WebhookSSRF: webhook.DefaultSSRFConfig(),
111 })
112 }
113
114 // usernameLookup returns the lookup function consumed by middleware.OptionalUser.
115 // It resolves the username, the user's current session_epoch (so the auth
116 // middleware can refuse stale cookies bumped by "log out everywhere"), and
117 // the suspended state (so policy.UserActor can deny writes by suspended
118 // accounts at the web layer — the audit found this gap, S00-S25 audit C1).
119 func usernameLookup(pool *pgxpool.Pool) middleware.UserLookup {
120 q := usersdb.New()
121 return func(ctx context.Context, id int64) (middleware.UserLookupResult, error) {
122 u, err := q.GetUserByID(ctx, pool, id)
123 if err != nil {
124 return middleware.UserLookupResult{}, err
125 }
126 return middleware.UserLookupResult{
127 Username: u.Username,
128 SessionEpoch: u.SessionEpoch,
129 IsSuspended: u.SuspendedAt.Valid,
130 IsSiteAdmin: u.IsSiteAdmin,
131 }, nil
132 }
133 }
134
135 // buildAuthHandlers wires the auth surface from the loaded config and
136 // the bootstrapped DB / session / logger / templates. Selecting the email
137 // backend is config-driven (`auth.email_backend`).
138 func buildAuthHandlers(
139 cfg config.Config,
140 pool *pgxpool.Pool,
141 store session.Store,
142 objectStore storage.ObjectStore,
143 logger *slog.Logger,
144 tmplFS fs.FS,
145 ) (*authh.Handlers, error) {
146 rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
147 if err != nil {
148 return nil, err
149 }
150 sender, err := pickEmailSender(cfg)
151 if err != nil {
152 return nil, err
153 }
154 var box *secretbox.Box
155 if cfg.Auth.TOTPKeyB64 != "" {
156 b, err := secretbox.FromBase64(cfg.Auth.TOTPKeyB64)
157 if err != nil {
158 return nil, err
159 }
160 box = b
161 } else {
162 logger.Warn("auth: no totp_key_b64 configured; 2FA enrollment routes disabled",
163 "hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable 2FA")
164 }
165 return authh.New(authh.Deps{
166 Logger: logger,
167 Render: rr,
168 Pool: pool,
169 SessionStore: store,
170 Email: sender,
171 Branding: email.Branding{
172 SiteName: cfg.Auth.SiteName,
173 BaseURL: cfg.Auth.BaseURL,
174 From: cfg.Auth.EmailFrom,
175 },
176 Argon2: password.Params{
177 Memory: cfg.Auth.Argon2.MemoryKiB,
178 Time: cfg.Auth.Argon2.Time,
179 Threads: cfg.Auth.Argon2.Threads,
180 SaltLen: 16,
181 KeyLen: 32,
182 },
183 Limiter: throttle.NewLimiter(),
184 RateLimiter: ratelimit.New(pool),
185 RequireEmailVerification: cfg.Auth.RequireEmailVerification,
186 SecretBox: box,
187 Audit: audit.NewRecorder(),
188 ObjectStore: objectStore,
189 OrgBillingEnabled: cfg.Billing.Enabled,
190 })
191 }
192
193 func pickEmailSender(cfg config.Config) (email.Sender, error) {
194 switch cfg.Auth.EmailBackend {
195 case "stdout":
196 return email.NewStdoutSender(os.Stdout), nil
197 case "smtp":
198 return &email.SMTPSender{
199 Addr: cfg.Auth.SMTP.Addr,
200 From: cfg.Auth.EmailFrom,
201 Username: cfg.Auth.SMTP.Username,
202 Password: cfg.Auth.SMTP.Password,
203 }, nil
204 case "postmark":
205 return &email.PostmarkSender{
206 ServerToken: cfg.Auth.Postmark.ServerToken,
207 From: cfg.Auth.EmailFrom,
208 HTTP: &http.Client{Timeout: 10 * time.Second},
209 }, nil
210 case "resend":
211 return &email.ResendSender{
212 APIKey: cfg.Auth.Resend.APIKey,
213 From: cfg.Auth.EmailFrom,
214 HTTP: &http.Client{Timeout: 10 * time.Second},
215 }, nil
216 default:
217 return nil, errors.New("auth: unknown email_backend")
218 }
219 }
220