home to your local SPACEGIRL 馃挮
arimelody.space
1package admin
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "net/http"
8 "os"
9 "strings"
10 "time"
11
12 "arimelody-web/admin/templates"
13 "arimelody-web/controller"
14 "arimelody-web/log"
15 "arimelody-web/model"
16 "arimelody-web/view"
17
18 "golang.org/x/crypto/bcrypt"
19)
20
21type adminPageData struct {
22 Path string
23 Session *model.Session
24}
25
26func Handler(app *model.AppState) http.Handler {
27 mux := http.NewServeMux()
28
29 mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30 qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
31 if err != nil {
32 fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
33 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
34 return
35 }
36
37 w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
38 }))
39
40 mux.Handle("/login", loginHandler(app))
41 mux.Handle("/totp", loginTOTPHandler(app))
42 mux.Handle("/logout", requireAccount(logoutHandler(app)))
43
44 mux.Handle("/register", registerAccountHandler(app))
45
46 mux.Handle("/account", requireAccount(accountIndexHandler(app)))
47 mux.Handle("/account/", requireAccount(accountHandler(app)))
48
49 mux.Handle("/logs", requireAccount(logsHandler(app)))
50
51 mux.Handle("/releases", requireAccount(serveReleases(app)))
52 mux.Handle("/releases/", requireAccount(serveReleases(app)))
53 mux.Handle("/artists", requireAccount(serveArtists(app)))
54 mux.Handle("/artists/", requireAccount(serveArtists(app)))
55 mux.Handle("/tracks", requireAccount(serveTracks(app)))
56 mux.Handle("/tracks/", requireAccount(serveTracks(app)))
57
58 mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 if r.URL.Path == "/static/admin.css" {
60 http.ServeFile(w, r, "./admin/static/admin.css")
61 return
62 }
63 if r.URL.Path == "/static/admin.js" {
64 http.ServeFile(w, r, "./admin/static/admin.js")
65 return
66 }
67 requireAccount(
68 http.StripPrefix("/static",
69 view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
70 }))
71
72 mux.Handle("/", requireAccount(AdminIndexHandler(app)))
73
74 // response wrapper to make sure a session cookie exists
75 return enforceSession(app, mux)
76}
77
78func AdminIndexHandler(app *model.AppState) http.Handler {
79 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80 if r.URL.Path != "/" {
81 http.NotFound(w, r)
82 return
83 }
84
85 session := r.Context().Value("session").(*model.Session)
86
87 releases, err := controller.GetAllReleases(app.DB, false, 3, true)
88 if err != nil {
89 fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
90 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
91 return
92 }
93 releaseCount, err := controller.GetReleaseCount(app.DB, false)
94 if err != nil {
95 fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
96 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
97 return
98 }
99
100 artists, err := controller.GetAllArtists(app.DB)
101 if err != nil {
102 fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
103 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
104 return
105 }
106 artistCount, err := controller.GetArtistCount(app.DB)
107 if err != nil {
108 fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
109 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
110 return
111 }
112
113 tracks, err := controller.GetOrphanTracks(app.DB)
114 if err != nil {
115 fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
116 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
117 return
118 }
119 trackCount, err := controller.GetTrackCount(app.DB)
120 if err != nil {
121 fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
122 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
123 return
124 }
125
126 type IndexData struct {
127 adminPageData
128 Releases []*model.Release
129 ReleaseCount int
130 Artists []*model.Artist
131 ArtistCount int
132 Tracks []*model.Track
133 TrackCount int
134 }
135
136 err = templates.IndexTemplate.Execute(w, IndexData{
137 adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
138 Releases: releases,
139 ReleaseCount: releaseCount,
140 Artists: artists,
141 ArtistCount: artistCount,
142 Tracks: tracks,
143 TrackCount: trackCount,
144 })
145 if err != nil {
146 fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
147 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
148 return
149 }
150 })
151}
152
153func registerAccountHandler(app *model.AppState) http.Handler {
154 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 session := r.Context().Value("session").(*model.Session)
156
157 if session.Account != nil {
158 // user is already logged in
159 http.Redirect(w, r, "/admin", http.StatusFound)
160 return
161 }
162
163 render := func() {
164 err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
165 if err != nil {
166 fmt.Printf("WARN: Error rendering create account page: %s\n", err)
167 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
168 }
169 }
170
171 if r.Method == http.MethodGet {
172 render()
173 return
174 }
175
176 if r.Method != http.MethodPost {
177 http.NotFound(w, r)
178 return
179 }
180
181 err := r.ParseForm()
182 if err != nil {
183 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
184 return
185 }
186
187 type RegisterRequest struct {
188 Username string `json:"username"`
189 Email string `json:"email"`
190 Password string `json:"password"`
191 Invite string `json:"invite"`
192 }
193 credentials := RegisterRequest{
194 Username: r.Form.Get("username"),
195 Email: r.Form.Get("email"),
196 Password: r.Form.Get("password"),
197 Invite: r.Form.Get("invite"),
198 }
199
200 // make sure invite code exists in DB
201 invite, err := controller.GetInvite(app.DB, credentials.Invite)
202 if err != nil {
203 fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
204 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
205 render()
206 return
207 }
208 if invite == nil || time.Now().After(invite.ExpiresAt) {
209 if invite != nil {
210 err := controller.DeleteInvite(app.DB, invite.Code)
211 if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
212 }
213 controller.SetSessionError(app.DB, session, "Invalid invite code.")
214 render()
215 return
216 }
217
218 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
219 if err != nil {
220 fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
221 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
222 render()
223 return
224 }
225
226 account := model.Account{
227 Username: credentials.Username,
228 Password: string(hashedPassword),
229 Email: sql.NullString{ String: credentials.Email, Valid: true },
230 AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
231 }
232 err = controller.CreateAccount(app.DB, &account)
233 if err != nil {
234 if strings.HasPrefix(err.Error(), "pq: duplicate key") {
235 controller.SetSessionError(app.DB, session, "An account with that username already exists.")
236 render()
237 return
238 }
239 fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
240 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
241 render()
242 return
243 }
244
245 app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
246
247 err = controller.DeleteInvite(app.DB, invite.Code)
248 if err != nil {
249 app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
250 }
251
252 // registration success!
253 controller.SetSessionAccount(app.DB, session, &account)
254 controller.SetSessionMessage(app.DB, session, "")
255 controller.SetSessionError(app.DB, session, "")
256 http.Redirect(w, r, "/admin", http.StatusFound)
257 })
258}
259
260func loginHandler(app *model.AppState) http.Handler {
261 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262 if r.Method != http.MethodGet && r.Method != http.MethodPost {
263 http.NotFound(w, r)
264 return
265 }
266
267 session := r.Context().Value("session").(*model.Session)
268
269 render := func() {
270 err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
271 if err != nil {
272 fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
273 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
274 return
275 }
276 }
277
278 if r.Method == http.MethodGet {
279 if session.Account != nil {
280 // user is already logged in
281 http.Redirect(w, r, "/admin", http.StatusFound)
282 return
283 }
284 render()
285 return
286 }
287
288 err := r.ParseForm()
289 if err != nil {
290 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
291 return
292 }
293
294 if !r.Form.Has("username") || !r.Form.Has("password") {
295 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
296 return
297 }
298
299 username := r.FormValue("username")
300 password := r.FormValue("password")
301
302 account, err := controller.GetAccountByUsername(app.DB, username)
303 if err != nil {
304 fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
305 controller.SetSessionError(app.DB, session, "Invalid username or password.")
306 render()
307 return
308 }
309 if account == nil {
310 controller.SetSessionError(app.DB, session, "Invalid username or password.")
311 render()
312 return
313 }
314 if account.Locked {
315 controller.SetSessionError(app.DB, session, "This account is locked.")
316 render()
317 return
318 }
319
320 err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
321 if err != nil {
322 app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
323 if locked := handleFailedLogin(app, account, r); locked {
324 controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
325 } else {
326 controller.SetSessionError(app.DB, session, "Invalid username or password.")
327 }
328 render()
329 return
330 }
331
332 totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
333 if err != nil {
334 fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
335 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
336 render()
337 return
338 }
339
340 if len(totps) > 0 {
341 err = controller.SetSessionAttemptAccount(app.DB, session, account)
342 if err != nil {
343 fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
344 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
345 render()
346 return
347 }
348 controller.SetSessionMessage(app.DB, session, "")
349 controller.SetSessionError(app.DB, session, "")
350 http.Redirect(w, r, "/admin/totp", http.StatusFound)
351 return
352 }
353
354 // login success!
355 // TODO: log login activity to user
356 app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
357 app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
358
359 err = controller.SetSessionAccount(app.DB, session, account)
360 if err != nil {
361 fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
362 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
363 render()
364 return
365 }
366 controller.SetSessionMessage(app.DB, session, "")
367 controller.SetSessionError(app.DB, session, "")
368 http.Redirect(w, r, "/admin", http.StatusFound)
369 })
370}
371
372func loginTOTPHandler(app *model.AppState) http.Handler {
373 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
374 session := r.Context().Value("session").(*model.Session)
375
376 if session.AttemptAccount == nil {
377 http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
378 return
379 }
380
381 render := func() {
382 err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
383 if err != nil {
384 fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
385 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
386 return
387 }
388 }
389
390 if r.Method == http.MethodGet {
391 render()
392 return
393 }
394
395 if r.Method != http.MethodPost {
396 http.NotFound(w, r)
397 return
398 }
399
400 r.ParseForm()
401
402 if !r.Form.Has("totp") {
403 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
404 return
405 }
406
407 totpCode := r.FormValue("totp")
408
409 if len(totpCode) != controller.TOTP_CODE_LENGTH {
410 app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
411 controller.SetSessionError(app.DB, session, "Invalid TOTP.")
412 render()
413 return
414 }
415
416 totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
417 if err != nil {
418 fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
419 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
420 render()
421 return
422 }
423 if totpMethod == nil {
424 app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
425 if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
426 controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
427 controller.SetSessionAttemptAccount(app.DB, session, nil)
428 http.Redirect(w, r, "/admin", http.StatusFound)
429 } else {
430 controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
431 }
432 render()
433 return
434 }
435
436 app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
437
438 err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
439 if err != nil {
440 fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
441 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
442 render()
443 return
444 }
445 err = controller.SetSessionAttemptAccount(app.DB, session, nil)
446 if err != nil {
447 fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
448 }
449 controller.SetSessionMessage(app.DB, session, "")
450 controller.SetSessionError(app.DB, session, "")
451 http.Redirect(w, r, "/admin", http.StatusFound)
452 })
453}
454
455func logoutHandler(app *model.AppState) http.Handler {
456 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
457 if r.Method != http.MethodGet {
458 http.NotFound(w, r)
459 return
460 }
461
462 session := r.Context().Value("session").(*model.Session)
463 err := controller.DeleteSession(app.DB, session.Token)
464 if err != nil {
465 fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
466 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
467 return
468 }
469
470 http.SetCookie(w, &http.Cookie{
471 Name: model.COOKIE_TOKEN,
472 Expires: time.Now(),
473 Path: "/",
474 })
475
476 err = templates.LogoutTemplate.Execute(w, nil)
477 if err != nil {
478 fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
479 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
480 }
481 })
482}
483
484func requireAccount(next http.Handler) http.HandlerFunc {
485 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486 session := r.Context().Value("session").(*model.Session)
487 if session.Account == nil {
488 // TODO: include context in redirect
489 http.Redirect(w, r, "/admin/login", http.StatusFound)
490 return
491 }
492 next.ServeHTTP(w, r)
493 })
494}
495
496/*
497//go:embed "static"
498var staticFS embed.FS
499
500func staticHandler() http.Handler {
501 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
502 uri := strings.TrimPrefix(r.URL.Path, "/static")
503 file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
504 if err != nil {
505 http.NotFound(w, r)
506 return
507 }
508
509 w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
510 w.WriteHeader(http.StatusOK)
511
512 w.Write(file)
513 })
514}
515*/
516
517func enforceSession(app *model.AppState, next http.Handler) http.Handler {
518 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
519 session, err := controller.GetSessionFromRequest(app, r)
520 if err != nil {
521 fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
522 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
523 return
524 }
525
526 if session == nil {
527 // create a new session
528 session, err = controller.CreateSession(app.DB, r.UserAgent())
529 if err != nil {
530 fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
531 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
532 return
533 }
534
535 http.SetCookie(w, &http.Cookie{
536 Name: model.COOKIE_TOKEN,
537 Value: session.Token,
538 Expires: session.ExpiresAt,
539 Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
540 HttpOnly: true,
541 Path: "/",
542 })
543 }
544
545 ctx := context.WithValue(r.Context(), "session", session)
546 next.ServeHTTP(w, r.WithContext(ctx))
547 })
548}
549
550func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
551 locked, err := controller.IncrementAccountFails(app.DB, account.ID)
552 if err != nil {
553 fmt.Fprintf(
554 os.Stderr,
555 "WARN: Failed to increment login failures for \"%s\": %v\n",
556 account.Username,
557 err,
558 )
559 app.Log.Warn(
560 log.TYPE_ACCOUNT,
561 "Failed to increment login failures for \"%s\"",
562 account.Username,
563 )
564 }
565 if locked {
566 app.Log.Warn(
567 log.TYPE_ACCOUNT,
568 "Account \"%s\" was locked: %d failed login attempts (IP: %s)",
569 account.Username,
570 model.MAX_LOGIN_FAIL_ATTEMPTS,
571 controller.ResolveIP(app, r),
572 )
573 }
574 return locked
575}