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