diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a882442 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore index 9bdf788..2e63958 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docker-compose*.yml !docker-compose.example.yml config*.toml arimelody-web +arimelody-web.tar.gz diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..95557e7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025-present ari melody + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11e565a --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +EXEC = arimelody-web + +.PHONY: $(EXEC) + +$(EXEC): + GOOS=linux GOARCH=amd64 go build -o $(EXEC) + +bundle: $(EXEC) + tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ + +clean: + rm $(EXEC) $(EXEC).tar.gz diff --git a/README.md b/README.md index 7e7860c..464379e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,11 @@ need to be up for this, making this ideal for some offline maintenance. - `listTOTP `: Lists an account's TOTP methods. - `deleteTOTP `: Deletes an account's TOTP method. - `testTOTP `: Generates the code for an account's TOTP method. +- `cleanTOTP`: Cleans up unconfirmed (dangling) TOTP methods. - `createInvite`: Creates an invite code to register new accounts. - `purgeInvites`: Deletes all available invite codes. - `listAccounts`: Lists all active accounts. - `deleteAccount `: Deletes an account with a given `username`. +- `lockAccount `: Locks the account under `username`. +- `unlockAccount `: Unlocks the account under `username`. +- `logs`: Shows system logs. diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 9402410..945a507 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,16 +1,17 @@ package admin import ( - "fmt" - "net/http" - "net/url" - "os" - "time" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func accountHandler(app *model.AppState) http.Handler { @@ -63,7 +64,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Message = sessionMessage session.Error = sessionError - err = pages["account"].Execute(w, accountResponse{ + err = accountTemplate.Execute(w, accountResponse{ Session: session, TOTPs: totps, }) @@ -114,6 +115,8 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") http.Redirect(w, r, "/admin/account", http.StatusFound) @@ -133,7 +136,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - if !r.Form.Has("password") || !r.Form.Has("totp") { + if !r.Form.Has("password") { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -142,33 +145,12 @@ func deleteAccountHandler(app *model.AppState) http.Handler { // check password if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { - fmt.Printf( - "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) + app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Incorrect password.") http.Redirect(w, r, "/admin/account", http.StatusFound) return } - totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp")) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) - return - } - if totpMethod == nil { - fmt.Printf( - "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) - controller.SetSessionError(app.DB, session, "Incorrect TOTP.") - http.Redirect(w, r, "/admin/account", http.StatusFound) - } - err = controller.DeleteAccount(app.DB, session.Account.ID) if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) @@ -177,11 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "[%s] INFO: Account \"%s\" deleted by user request.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionAccount(app.DB, session, nil) controller.SetSessionError(app.DB, session, "") @@ -190,6 +168,13 @@ func deleteAccountHandler(app *model.AppState) http.Handler { }) } +type totpConfirmData struct { + Session *model.Session + TOTP *model.TOTP + NameEscaped string + QRBase64Image string +} + func totpSetupHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { @@ -199,7 +184,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session }) + err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -212,12 +197,6 @@ func totpSetupHandler(app *model.AppState) http.Handler { return } - type totpSetupData struct { - Session *model.Session - TOTP *model.TOTP - NameEscaped string - } - err := r.ParseForm() if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) @@ -242,7 +221,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session }) + err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -250,10 +229,17 @@ func totpSetupHandler(app *model.AppState) http.Handler { return } - err = pages["totp-confirm"].Execute(w, totpSetupData{ + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) + } + + err = totpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: &totp, NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, }) if err != nil { fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err) @@ -269,11 +255,6 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } - type totpConfirmData struct { - Session *model.Session - TOTP *model.TOTP - } - session := r.Context().Value("session").(*model.Session) err := r.ParseForm() @@ -294,7 +275,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) if err != nil { - fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) + fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") http.Redirect(w, r, "/admin/account", http.StatusFound) return @@ -304,19 +285,41 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) + } + confirmCode := controller.GenerateTOTP(totp.Secret, 0) if code != confirmCode { confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) if code != confirmCodeOffset { - controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.") - err = pages["totp-confirm"].Execute(w, totpConfirmData{ + session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } + err = totpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: totp, + NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } return } } + err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) + if err != nil { + fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) + controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) http.Redirect(w, r, "/admin/account", http.StatusFound) @@ -330,12 +333,11 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } - name := r.URL.Path - fmt.Printf("%s\n", name); - if len(name) == 0 { + if len(r.URL.Path) < 2 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } + name := r.URL.Path[1:] session := r.Context().Value("session").(*model.Session) @@ -359,6 +361,8 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) + controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) http.Redirect(w, r, "/admin/account", http.StatusFound) diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 5979493..9fa6bb2 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -1,9 +1,9 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" "arimelody-web/model" "arimelody-web/controller" @@ -39,7 +39,7 @@ func serveArtist(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = pages["artist"].Execute(w, ArtistResponse{ + err = artistTemplate.Execute(w, ArtistResponse{ Session: session, Artist: artist, Credits: credits, diff --git a/admin/components/credits/addcredit.html b/admin/components/credits/addcredit.html index 32c43fa..c09a550 100644 --- a/admin/components/credits/addcredit.html +++ b/admin/components/credits/addcredit.html @@ -1,6 +1,6 @@
-

Add artist credit

+

Add Artist Credit

    diff --git a/admin/components/release/release-list-item.html b/admin/components/release/release-list-item.html index 677318d..4b8f41e 100644 --- a/admin/components/release/release-list-item.html +++ b/admin/components/release/release-list-item.html @@ -7,7 +7,7 @@

    {{.Title}} - {{.GetReleaseYear}} + {{.ReleaseDate.Year}} {{if not .Visible}}(hidden){{end}}

    diff --git a/admin/components/tracks/addtrack.html b/admin/components/tracks/addtrack.html index f402763..a6dd433 100644 --- a/admin/components/tracks/addtrack.html +++ b/admin/components/tracks/addtrack.html @@ -1,6 +1,6 @@
    -

    Add track

    +

    Add Track

      diff --git a/admin/http.go b/admin/http.go index b6a71ee..245a152 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,39 +1,54 @@ package admin import ( - "context" - "database/sql" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "context" + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() + mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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") + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Write([]byte("")) + })) + mux.Handle("/login", loginHandler(app)) - mux.Handle("/logout", requireAccount(app, logoutHandler(app))) + mux.Handle("/totp", loginTOTPHandler(app)) + mux.Handle("/logout", requireAccount(logoutHandler(app))) mux.Handle("/register", registerAccountHandler(app)) - mux.Handle("/account", requireAccount(app, accountIndexHandler(app))) - mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app)))) + mux.Handle("/account", requireAccount(accountIndexHandler(app))) + mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) - mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app)))) - mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app)))) - mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app)))) + mux.Handle("/logs", requireAccount(logsHandler(app))) + + mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app)))) + mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app)))) + mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app)))) mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/", requireAccount(app, AdminIndexHandler(app))) + mux.Handle("/", requireAccount(AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists return enforceSession(app, mux) @@ -76,7 +91,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { Tracks []*model.Track } - err = pages["index"].Execute(w, IndexData{ + err = indexTemplate.Execute(w, IndexData{ Session: session, Releases: releases, Artists: artists, @@ -105,7 +120,7 @@ func registerAccountHandler(app *model.AppState) http.Handler { } render := func() { - err := pages["register"].Execute(w, registerData{ Session: session }) + err := registerTemplate.Execute(w, registerData{ Session: session }) if err != nil { fmt.Printf("WARN: Error rendering create account page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -186,15 +201,12 @@ func registerAccountHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "[%s]: Account registered: %s (%s)\n", - time.Now().Format(time.UnixDate), - account.Username, - account.ID, - ) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) err = controller.DeleteInvite(app.DB, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) + } // registration success! controller.SetSessionAccount(app.DB, session, &account) @@ -218,7 +230,7 @@ func loginHandler(app *model.AppState) http.Handler { } render := func() { - err := pages["login"].Execute(w, loginData{ Session: session }) + err := loginTemplate.Execute(w, loginData{ Session: session }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -232,7 +244,6 @@ func loginHandler(app *model.AppState) http.Handler { http.Redirect(w, r, "/admin", http.StatusFound) return } - render() return } @@ -243,23 +254,15 @@ func loginHandler(app *model.AppState) http.Handler { return } - // new accounts won't have TOTP methods at first. there should be a - // second phase of login that prompts the user for a TOTP *only* - // if that account has a TOTP method. - // TODO: login phases (username & password -> TOTP) - - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - credentials := LoginRequest{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - TOTP: r.Form.Get("totp"), + if !r.Form.Has("username") || !r.Form.Has("password") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return } - account, err := controller.GetAccountByUsername(app.DB, credentials.Username) + username := r.FormValue("username") + password := r.FormValue("password") + + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) controller.SetSessionError(app.DB, session, "Invalid username or password.") @@ -271,81 +274,145 @@ func loginHandler(app *model.AppState) http.Handler { render() return } - - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) - if err != nil { - fmt.Printf( - "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n", - time.Now().Format(time.UnixDate), - account.Username, - ) - controller.SetSessionError(app.DB, session, "Invalid username or password.") + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") render() return } - var totpMethod *model.TOTP - if len(credentials.TOTP) == 0 { - // check if user has TOTP - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, account, r); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + } else { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + } + render() + return + } + + totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + if len(totps) > 0 { + err = controller.SetSessionAttemptAccount(app.DB, session, account) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } - - if len(totps) > 0 { - type loginTOTPData struct { - Session *model.Session - Username string - Password string - } - err = pages["login-totp"].Execute(w, loginTOTPData{ - Session: session, - Username: credentials.Username, - Password: credentials.Password, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - } else { - totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - if totpMethod == nil { - controller.SetSessionError(app.DB, session, "Invalid TOTP.") - render() - return - } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin/totp", http.StatusFound) + return } - if totpMethod != nil { - fmt.Printf( - "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", - time.Now().Format(time.UnixDate), - account.Username, - totpMethod.Name, - ) - } else { - fmt.Printf( - "[%s] INFO: Account \"%s\" logged in\n", - time.Now().Format(time.UnixDate), - account.Username, - ) - } - - // TODO: log login activity to user - // login success! - controller.SetSessionAccount(app.DB, session, account) + // TODO: log login activity to user + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) + + err = controller.SetSessionAccount(app.DB, session, account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) +} + +func loginTOTPHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + if session.AttemptAccount == nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + type loginTOTPData struct { + Session *model.Session + } + + render := func() { + err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + if r.Method == http.MethodGet { + render() + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + r.ParseForm() + + if !r.Form.Has("totp") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + totpCode := r.FormValue("totp") + + if len(totpCode) != controller.TOTP_CODE_LENGTH { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + controller.SetSessionError(app.DB, session, "Invalid TOTP.") + render() + return + } + + totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + if totpMethod == nil { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + controller.SetSessionAttemptAccount(app.DB, session, nil) + http.Redirect(w, r, "/admin", http.StatusFound) + } else { + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + } + render() + return + } + + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) + + err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + err = controller.SetSessionAttemptAccount(app.DB, session, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err) + } controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin", http.StatusFound) @@ -373,7 +440,7 @@ func logoutHandler(app *model.AppState) http.Handler { Path: "/", }) - err = pages["logout"].Execute(w, nil) + err = logoutTemplate.Execute(w, nil) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -381,7 +448,7 @@ func logoutHandler(app *model.AppState) http.Handler { }) } -func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { +func requireAccount(next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) if session.Account == nil { @@ -416,30 +483,13 @@ func staticHandler() http.Handler { func enforceSession(app *model.AppState, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) - if err != nil && err != http.ErrNoCookie { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + session, err := controller.GetSessionFromRequest(app, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - var session *model.Session - - if sessionCookie != nil { - // fetch existing session - session, err = controller.GetSession(app.DB, sessionCookie.Value) - - if err != nil && !strings.Contains(err.Error(), "no rows") { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if session != nil { - // TODO: consider running security checks here (i.e. user agent mismatches) - } - } - if session == nil { // create a new session session, err = controller.CreateSession(app.DB, r.UserAgent()) @@ -463,3 +513,30 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } + +func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { + locked, err := controller.IncrementAccountFails(app.DB, account.ID) + if err != nil { + fmt.Fprintf( + os.Stderr, + "WARN: Failed to increment login failures for \"%s\": %v\n", + account.Username, + err, + ) + app.Log.Warn( + log.TYPE_ACCOUNT, + "Failed to increment login failures for \"%s\"", + account.Username, + ) + } + if locked { + app.Log.Warn( + log.TYPE_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), + ) + } + return locked +} diff --git a/admin/logshttp.go b/admin/logshttp.go new file mode 100644 index 0000000..7249b16 --- /dev/null +++ b/admin/logshttp.go @@ -0,0 +1,67 @@ +package admin + +import ( + "arimelody-web/log" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "strings" +) + +func logsHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + levelFilter := []log.LogLevel{} + typeFilter := []string{} + + query := r.URL.Query().Get("q") + + for key, value := range r.URL.Query() { + if strings.HasPrefix(key, "level-") && value[0] == "on" { + m := map[string]log.LogLevel{ + "info": log.LEVEL_INFO, + "warn": log.LEVEL_WARN, + } + level, ok := m[strings.TrimPrefix(key, "level-")] + if ok { + levelFilter = append(levelFilter, level) + } + continue + } + + if strings.HasPrefix(key, "type-") && value[0] == "on" { + typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-"))) + continue + } + } + + logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type LogsResponse struct { + Session *model.Session + Logs []*log.Log + } + + err = logsTemplate.Execute(w, LogsResponse{ + Session: session, + Logs: logs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/admin/releasehttp.go b/admin/releasehttp.go index cd73e98..c6b68ab 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -1,12 +1,12 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) func serveRelease(app *model.AppState) http.Handler { @@ -22,7 +22,7 @@ func serveRelease(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err) + fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -60,7 +60,7 @@ func serveRelease(app *model.AppState) http.Handler { Release *model.Release } - err = pages["release"].Execute(w, ReleaseResponse{ + err = releaseTemplate.Execute(w, ReleaseResponse{ Session: session, Release: release, }) @@ -74,7 +74,7 @@ func serveRelease(app *model.AppState) http.Handler { func serveEditCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["editcredits"].Execute(w, release) + err := editCreditsTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -86,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addcredit"].Execute(w, response{ + err = addCreditTemplate.Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -113,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler { artistID := strings.Split(r.URL.Path, "/")[3] artist, err := controller.GetArtist(app.DB, artistID) if err != nil { - fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) + fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newcredit"].Execute(w, artist) + err = newCreditTemplate.Execute(w, artist) if err != nil { fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -134,7 +134,7 @@ func serveNewCredit(app *model.AppState) http.Handler { func serveEditLinks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["editlinks"].Execute(w, release) + err := editLinksTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -151,7 +151,7 @@ func serveEditTracks(release *model.Release) http.Handler { Add func(a int, b int) int } - err := components["edittracks"].Execute(w, editTracksData{ + err := editTracksTemplate.Execute(w, editTracksData{ Release: release, Add: func(a, b int) int { return a + b }, }) @@ -166,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -177,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addtrack"].Execute(w, response{ + err = addTrackTemplate.Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -204,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newtrack"].Execute(w, track) + err = newTrackTemplate.Execute(w, track) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/static/logs.css b/admin/static/logs.css new file mode 100644 index 0000000..f0df299 --- /dev/null +++ b/admin/static/logs.css @@ -0,0 +1,86 @@ +main { + width: min(1080px, calc(100% - 2em))!important +} + +form { + margin: 1em 0; +} + +div#search { + display: flex; +} + +#search input { + margin: 0; + flex-grow: 1; + + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +#search button { + padding: 0 .5em; + + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +form #filters p { + margin: .5em 0 0 0; +} +form #filters label { + display: inline; +} +form #filters input { + margin-right: 1em; + display: inline; +} + +#logs { + width: 100%; + border-collapse: collapse; +} + +#logs tr { +} + +#logs tr td { + border-bottom: 1px solid #8888; +} + +#logs tr td:nth-child(even) { + background: #00000004; +} + +#logs th, #logs td { + padding: .4em .8em; +} + +td, th { + width: 1%; + text-align: left; + white-space: nowrap; +} +td.log-level, +th.log-level, +td.log-type, +th.log-type { + text-align: center; +} +td.log-content, +td.log-content { + width: 100%; + white-space: collapse; +} + +.log:hover { + background: #fff8; +} + +.log.warn { + background: #ffe86a; +} +.log.warn:hover { + background: #ffec81; +} diff --git a/admin/templates.go b/admin/templates.go index d9a74ca..606d569 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,80 +1,125 @@ package admin import ( - "html/template" - "path/filepath" + "arimelody-web/log" + "fmt" + "html/template" + "path/filepath" + "strings" + "time" ) -var pages = map[string]*template.Template{ - "index": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "index.html"), - )), +var indexTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "index.html"), +)) - "login": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "login.html"), - )), - "login-totp": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "login-totp.html"), - )), - "register": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "register.html"), - )), - "logout": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "logout.html"), - )), - "account": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-account.html"), - )), - "totp-setup": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "totp-setup.html"), - )), - "totp-confirm": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "totp-confirm.html"), - )), +var loginTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login.html"), +)) +var loginTOTPTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), +)) +var registerTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "register.html"), +)) +var logoutTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logout.html"), +)) +var accountTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), +)) +var totpSetupTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-setup.html"), +)) +var totpConfirmTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-confirm.html"), +)) - "release": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-release.html"), - )), - "artist": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-artist.html"), - )), - "track": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "edit-track.html"), - )), -} +var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{ + "parseLevel": func(level log.LogLevel) string { + switch level { + case log.LEVEL_INFO: + return "INFO" + case log.LEVEL_WARN: + return "WARN" + } + return fmt.Sprintf("%d?", level) + }, + "titleCase": func(logType string) string { + runes := []rune(logType) + for i, r := range runes { + if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { + runes[i] = r + ('A' - 'a') + } + } + return string(runes) + }, + "lower": func(str string) string { return strings.ToLower(str) }, + "prettyTime": func(t time.Time) string { + // return t.Format("2006-01-02 15:04:05") + // return t.Format("15:04:05, 2 Jan 2006") + return t.Format("02 Jan 2006, 15:04:05") + }, +}).ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logs.html"), +)) -var components = map[string]*template.Template{ - "editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))), - "addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))), - "newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))), +var releaseTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-release.html"), +)) +var artistTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-artist.html"), +)) +var trackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "edit-track.html"), +)) - "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))), +var editCreditsTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "editcredits.html"), +)) +var addCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "addcredit.html"), +)) +var newCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "newcredit.html"), +)) - "edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))), - "addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))), - "newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))), -} +var editLinksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "links", "editlinks.html"), +)) + +var editTracksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "edittracks.html"), +)) +var addTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "addtrack.html"), +)) +var newTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "newtrack.html"), +)) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 9436671..93eacdb 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -1,9 +1,9 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" "arimelody-web/model" "arimelody-web/controller" @@ -39,7 +39,7 @@ func serveTrack(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = pages["track"].Execute(w, TrackResponse{ + err = trackTemplate.Execute(w, TrackResponse{ Session: session, Track: track, Releases: releases, diff --git a/admin/views/layout.html b/admin/views/layout.html index 8c34c8e..52b0620 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -23,7 +23,14 @@ + {{if .Session.Account}} + + {{end}} +
      + {{if .Session.Account}}