diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index a882442..0000000 --- a/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 4 diff --git a/.gitignore b/.gitignore index 2e63958..9bdf788 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ docker-compose*.yml !docker-compose.example.yml config*.toml arimelody-web -arimelody-web.tar.gz diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 95557e7..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 11e565a..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -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 464379e..7e7860c 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,7 @@ 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 945a507..9402410 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,17 +1,16 @@ package admin import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "fmt" + "net/http" + "net/url" + "os" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func accountHandler(app *model.AppState) http.Handler { @@ -64,7 +63,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Message = sessionMessage session.Error = sessionError - err = accountTemplate.Execute(w, accountResponse{ + err = pages["account"].Execute(w, accountResponse{ Session: session, TOTPs: totps, }) @@ -115,8 +114,6 @@ 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) @@ -136,7 +133,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - if !r.Form.Has("password") { + if !r.Form.Has("password") || !r.Form.Has("totp") { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -145,12 +142,33 @@ func deleteAccountHandler(app *model.AppState) http.Handler { // check password if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) 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) @@ -159,7 +177,11 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + fmt.Printf( + "[%s] INFO: Account \"%s\" deleted by user request.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) controller.SetSessionAccount(app.DB, session, nil) controller.SetSessionError(app.DB, session, "") @@ -168,13 +190,6 @@ 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 { @@ -184,7 +199,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) + err := pages["totp-setup"].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) @@ -197,6 +212,12 @@ 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) @@ -221,7 +242,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 := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) + err := pages["totp-setup"].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) @@ -229,17 +250,10 @@ func totpSetupHandler(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) - } - - err = totpConfirmTemplate.Execute(w, totpConfirmData{ + err = pages["totp-confirm"].Execute(w, totpSetupData{ 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) @@ -255,6 +269,11 @@ 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() @@ -275,7 +294,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: %v\n", err) + fmt.Printf("WARN: Failed to fetch 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 @@ -285,41 +304,19 @@ 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 { - session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } - err = totpConfirmTemplate.Execute(w, totpConfirmData{ + controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.") + err = pages["totp-confirm"].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) @@ -333,11 +330,12 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } - if len(r.URL.Path) < 2 { + name := r.URL.Path + fmt.Printf("%s\n", name); + if len(name) == 0 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - name := r.URL.Path[1:] session := r.Context().Value("session").(*model.Session) @@ -361,8 +359,6 @@ 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 9fa6bb2..5979493 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 = artistTemplate.Execute(w, ArtistResponse{ + err = pages["artist"].Execute(w, ArtistResponse{ Session: session, Artist: artist, Credits: credits, diff --git a/admin/components/credits/addcredit.html b/admin/components/credits/addcredit.html index c09a550..32c43fa 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 4b8f41e..677318d 100644 --- a/admin/components/release/release-list-item.html +++ b/admin/components/release/release-list-item.html @@ -7,7 +7,7 @@

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

    diff --git a/admin/components/tracks/addtrack.html b/admin/components/tracks/addtrack.html index a6dd433..f402763 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 245a152..b6a71ee 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,54 +1,39 @@ 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/log" - "arimelody-web/model" + "arimelody-web/controller" + "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("/totp", loginTOTPHandler(app)) - mux.Handle("/logout", requireAccount(logoutHandler(app))) + mux.Handle("/logout", requireAccount(app, logoutHandler(app))) mux.Handle("/register", registerAccountHandler(app)) - mux.Handle("/account", requireAccount(accountIndexHandler(app))) - mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) + mux.Handle("/account", requireAccount(app, accountIndexHandler(app))) + mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(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("/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("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/", requireAccount(AdminIndexHandler(app))) + mux.Handle("/", requireAccount(app, AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists return enforceSession(app, mux) @@ -91,7 +76,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { Tracks []*model.Track } - err = indexTemplate.Execute(w, IndexData{ + err = pages["index"].Execute(w, IndexData{ Session: session, Releases: releases, Artists: artists, @@ -120,7 +105,7 @@ func registerAccountHandler(app *model.AppState) http.Handler { } render := func() { - err := registerTemplate.Execute(w, registerData{ Session: session }) + err := pages["register"].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) @@ -201,12 +186,15 @@ func registerAccountHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) + fmt.Printf( + "[%s]: Account registered: %s (%s)\n", + time.Now().Format(time.UnixDate), + account.Username, + account.ID, + ) err = controller.DeleteInvite(app.DB, invite.Code) - if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) - } + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } // registration success! controller.SetSessionAccount(app.DB, session, &account) @@ -230,7 +218,7 @@ func loginHandler(app *model.AppState) http.Handler { } render := func() { - err := loginTemplate.Execute(w, loginData{ Session: session }) + err := pages["login"].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) @@ -244,6 +232,7 @@ func loginHandler(app *model.AppState) http.Handler { http.Redirect(w, r, "/admin", http.StatusFound) return } + render() return } @@ -254,15 +243,23 @@ func loginHandler(app *model.AppState) http.Handler { return } - if !r.Form.Has("username") || !r.Form.Has("password") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - 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"), } - username := r.FormValue("username") - password := r.FormValue("password") - - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := controller.GetAccountByUsername(app.DB, credentials.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.") @@ -274,145 +271,81 @@ func loginHandler(app *model.AppState) http.Handler { render() return } - if account.Locked { - controller.SetSessionError(app.DB, session, "This account is locked.") - render() - return - } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.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.") - } + 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.") 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) + var totpMethod *model.TOTP + if len(credentials.TOTP) == 0 { + // check if user has TOTP + totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err) + 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 } - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin/totp", http.StatusFound) - return - } - // login success! - // 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 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 render login TOTP page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + 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 } } - if r.Method == http.MethodGet { - render() - 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, + ) } - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } + // TODO: log login activity to user - 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) - } + // login success! + controller.SetSessionAccount(app.DB, session, account) controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin", http.StatusFound) @@ -440,7 +373,7 @@ func logoutHandler(app *model.AppState) http.Handler { Path: "/", }) - err = logoutTemplate.Execute(w, nil) + err = pages["logout"].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) @@ -448,7 +381,7 @@ func logoutHandler(app *model.AppState) http.Handler { }) } -func requireAccount(next http.Handler) http.HandlerFunc { +func requireAccount(app *model.AppState, 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 { @@ -483,13 +416,30 @@ func staticHandler() http.Handler { func enforceSession(app *model.AppState, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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) + 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) 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()) @@ -513,30 +463,3 @@ 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 deleted file mode 100644 index 7249b16..0000000 --- a/admin/logshttp.go +++ /dev/null @@ -1,67 +0,0 @@ -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 c6b68ab..cd73e98 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("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) + fmt.Printf("FATAL: 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 = releaseTemplate.Execute(w, ReleaseResponse{ + err = pages["release"].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 := editCreditsTemplate.Execute(w, release) + err := components["editcredits"].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("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) + fmt.Printf("FATAL: 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 = addCreditTemplate.Execute(w, response{ + err = components["addcredit"].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("WARN: Failed to pull artists %s: %s\n", artistID, err) + fmt.Printf("FATAL: 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 = newCreditTemplate.Execute(w, artist) + err = components["newcredit"].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 := editLinksTemplate.Execute(w, release) + err := components["editlinks"].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 := editTracksTemplate.Execute(w, editTracksData{ + err := components["edittracks"].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("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) + fmt.Printf("FATAL: 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 = addTrackTemplate.Execute(w, response{ + err = components["addtrack"].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 = newTrackTemplate.Execute(w, track) + err = components["newtrack"].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 deleted file mode 100644 index f0df299..0000000 --- a/admin/static/logs.css +++ /dev/null @@ -1,86 +0,0 @@ -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 606d569..d9a74ca 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,125 +1,80 @@ package admin import ( - "arimelody-web/log" - "fmt" - "html/template" - "path/filepath" - "strings" - "time" + "html/template" + "path/filepath" ) -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"), -)) +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 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"), -)) + "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 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"), -)) + "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 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"), -)) +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 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"), -)) + "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.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"), -)) + "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"))), +} diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 93eacdb..9436671 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 = trackTemplate.Execute(w, TrackResponse{ + err = pages["track"].Execute(w, TrackResponse{ Session: session, Track: track, Releases: releases, diff --git a/admin/views/layout.html b/admin/views/layout.html index 52b0620..8c34c8e 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -23,14 +23,7 @@ - {{if .Session.Account}} - - {{end}} -
      - {{if .Session.Account}}