home to your local SPACEGIRL 馃挮 arimelody.space
at dev 575 lines 22 kB view raw
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}