Go · 5908 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/secretbox"
24 "github.com/tenseleyFlow/shithub/internal/auth/session"
25 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
26 "github.com/tenseleyFlow/shithub/internal/infra/config"
27 "github.com/tenseleyFlow/shithub/internal/infra/storage"
28 "github.com/tenseleyFlow/shithub/internal/ratelimit"
29 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
30 apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
31 "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
32 authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
33 "github.com/tenseleyFlow/shithub/internal/web/middleware"
34 "github.com/tenseleyFlow/shithub/internal/web/render"
35 )
36
37 // sharedPATDebouncer is used by both the PATAuth middleware (in the API
38 // route group) and any future paths that want to coordinate last-used
39 // updates across handlers in the same process.
40 var sharedPATDebouncer = pat.NewDebouncer(0)
41
42 // buildAPIHandlers wires the PAT-authenticated API surface.
43 func buildAPIHandlers(
44 cfg config.Config,
45 pool *pgxpool.Pool,
46 objectStore storage.ObjectStore,
47 runnerJWT *runnerjwt.Signer,
48 secretBox *secretbox.Box,
49 rateLimiter *ratelimit.Limiter,
50 logger *slog.Logger,
51 ) (*apih.Handlers, error) {
52 if cfg.Storage.ReposRoot == "" {
53 return nil, errors.New("api: cfg.Storage.ReposRoot is empty")
54 }
55 root, err := filepath.Abs(cfg.Storage.ReposRoot)
56 if err != nil {
57 return nil, fmt.Errorf("api: resolve repos_root: %w", err)
58 }
59 rfs, err := storage.NewRepoFS(root)
60 if err != nil {
61 return nil, fmt.Errorf("api: NewRepoFS: %w", err)
62 }
63 shithubdPath := "shithubd"
64 if exe, err := os.Executable(); err == nil {
65 if abs, absErr := filepath.Abs(exe); absErr == nil {
66 shithubdPath = abs
67 }
68 }
69 return apih.New(apih.Deps{
70 Pool: pool,
71 Debouncer: sharedPATDebouncer,
72 Logger: logger,
73 ObjectStore: objectStore,
74 RepoFS: rfs,
75 RunnerJWT: runnerJWT,
76 SecretBox: secretBox,
77 RateLimiter: rateLimiter,
78 Audit: audit.NewRecorder(),
79 Throttle: throttle.NewLimiter(),
80 ShithubdPath: shithubdPath,
81 BaseURL: cfg.Auth.BaseURL,
82 APILimit: apilimit.Config{
83 AuthedPerHour: cfg.RateLimit.API.AuthedPerHour,
84 AnonPerHour: cfg.RateLimit.API.AnonPerHour,
85 Logger: logger,
86 },
87 })
88 }
89
90 // usernameLookup returns the lookup function consumed by middleware.OptionalUser.
91 // It resolves the username, the user's current session_epoch (so the auth
92 // middleware can refuse stale cookies bumped by "log out everywhere"), and
93 // the suspended state (so policy.UserActor can deny writes by suspended
94 // accounts at the web layer — the audit found this gap, S00-S25 audit C1).
95 func usernameLookup(pool *pgxpool.Pool) middleware.UserLookup {
96 q := usersdb.New()
97 return func(ctx context.Context, id int64) (middleware.UserLookupResult, error) {
98 u, err := q.GetUserByID(ctx, pool, id)
99 if err != nil {
100 return middleware.UserLookupResult{}, err
101 }
102 return middleware.UserLookupResult{
103 Username: u.Username,
104 SessionEpoch: u.SessionEpoch,
105 IsSuspended: u.SuspendedAt.Valid,
106 IsSiteAdmin: u.IsSiteAdmin,
107 }, nil
108 }
109 }
110
111 // buildAuthHandlers wires the auth surface from the loaded config and
112 // the bootstrapped DB / session / logger / templates. Selecting the email
113 // backend is config-driven (`auth.email_backend`).
114 func buildAuthHandlers(
115 cfg config.Config,
116 pool *pgxpool.Pool,
117 store session.Store,
118 objectStore storage.ObjectStore,
119 logger *slog.Logger,
120 tmplFS fs.FS,
121 ) (*authh.Handlers, error) {
122 rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
123 if err != nil {
124 return nil, err
125 }
126 sender, err := pickEmailSender(cfg)
127 if err != nil {
128 return nil, err
129 }
130 var box *secretbox.Box
131 if cfg.Auth.TOTPKeyB64 != "" {
132 b, err := secretbox.FromBase64(cfg.Auth.TOTPKeyB64)
133 if err != nil {
134 return nil, err
135 }
136 box = b
137 } else {
138 logger.Warn("auth: no totp_key_b64 configured; 2FA enrollment routes disabled",
139 "hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable 2FA")
140 }
141 return authh.New(authh.Deps{
142 Logger: logger,
143 Render: rr,
144 Pool: pool,
145 SessionStore: store,
146 Email: sender,
147 Branding: email.Branding{
148 SiteName: cfg.Auth.SiteName,
149 BaseURL: cfg.Auth.BaseURL,
150 From: cfg.Auth.EmailFrom,
151 },
152 Argon2: password.Params{
153 Memory: cfg.Auth.Argon2.MemoryKiB,
154 Time: cfg.Auth.Argon2.Time,
155 Threads: cfg.Auth.Argon2.Threads,
156 SaltLen: 16,
157 KeyLen: 32,
158 },
159 Limiter: throttle.NewLimiter(),
160 RateLimiter: ratelimit.New(pool),
161 RequireEmailVerification: cfg.Auth.RequireEmailVerification,
162 SecretBox: box,
163 Audit: audit.NewRecorder(),
164 ObjectStore: objectStore,
165 })
166 }
167
168 func pickEmailSender(cfg config.Config) (email.Sender, error) {
169 switch cfg.Auth.EmailBackend {
170 case "stdout":
171 return email.NewStdoutSender(os.Stdout), nil
172 case "smtp":
173 return &email.SMTPSender{
174 Addr: cfg.Auth.SMTP.Addr,
175 From: cfg.Auth.EmailFrom,
176 Username: cfg.Auth.SMTP.Username,
177 Password: cfg.Auth.SMTP.Password,
178 }, nil
179 case "postmark":
180 return &email.PostmarkSender{
181 ServerToken: cfg.Auth.Postmark.ServerToken,
182 From: cfg.Auth.EmailFrom,
183 HTTP: &http.Client{Timeout: 10 * time.Second},
184 }, nil
185 default:
186 return nil, errors.New("auth: unknown email_backend")
187 }
188 }
189