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 cccde2b..2e63958 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ uploads/ 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 f0990df..945a507 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,43 +1,71 @@ package admin import ( - "fmt" - "net/http" - "os" - "strings" - "time" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/controller" - "arimelody-web/global" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) -type TemplateData struct { - Account *model.Account - Message string - Token string +func accountHandler(app *model.AppState) http.Handler { + mux := http.NewServeMux() + + mux.Handle("/totp-setup", totpSetupHandler(app)) + mux.Handle("/totp-confirm", totpConfirmHandler(app)) + mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) + + mux.Handle("/password", changePasswordHandler(app)) + mux.Handle("/delete", deleteAccountHandler(app)) + + return mux } -func AccountHandler(db *sqlx.DB) http.Handler { +func accountIndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - totps, err := controller.GetTOTPsForAccount(db, account.ID) + dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) if err != nil { fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - type AccountResponse struct { - Account *model.Account - TOTPs []model.TOTP + type ( + TOTP struct { + model.TOTP + CreatedAtString string + } + + accountResponse struct { + Session *model.Session + TOTPs []TOTP + } + ) + + totps := []TOTP{} + for _, totp := range dbTOTPs { + totps = append(totps, TOTP{ + TOTP: totp, + CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"), + }) } - err = pages["account"].Execute(w, AccountResponse{ - Account: account, + sessionMessage := session.Message + sessionError := session.Error + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + session.Message = sessionMessage + session.Error = sessionError + + err = accountTemplate.Execute(w, accountResponse{ + Session: session, TOTPs: totps, }) if err != nil { @@ -47,299 +75,296 @@ func AccountHandler(db *sqlx.DB) http.Handler { }) } -func LoginHandler(db *sqlx.DB) http.Handler { +func changePasswordHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - account, err := controller.GetAccountByRequest(db, r) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) - return - } - if account != nil { - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - err = pages["login"].Execute(w, TemplateData{}) - 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) - return - } + if r.Method != http.MethodPost { + http.NotFound(w, r) return } - type LoginResponse struct { - Account *model.Account - Token string - Message string + session := r.Context().Value("session").(*model.Session) + + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + + r.ParseForm() + + currentPassword := r.Form.Get("current-password") + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { + controller.SetSessionError(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return } - render := func(data LoginResponse) { - err := pages["login"].Execute(w, data) - 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) - return - } + newPassword := r.Form.Get("new-password") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return } + session.Account.Password = string(hashedPassword) + err = controller.UpdateAccount(app.DB, session.Account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\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\" 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) + }) +} + +func deleteAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.NotFound(w, r); + http.NotFound(w, r) return } err := r.ParseForm() if err != nil { - render(LoginResponse{ Message: "Malformed request." }) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - 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("password") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return } - account, err := controller.GetAccount(db, credentials.Username) + session := r.Context().Value("session").(*model.Session) + + // 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)) + controller.SetSessionError(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + err = controller.DeleteAccount(app.DB, session.Account.ID) if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return - } - if account == nil { - render(LoginResponse{ Message: "Invalid username or password" }) + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) return } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) - if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return - } + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) - totps, err := controller.GetTOTPsForAccount(db, account.ID) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - if len(totps) > 0 { - success := false - for _, totp := range totps { - check := controller.GenerateTOTP(totp.Secret, 0) - if check == credentials.TOTP { - success = true - break - } - } - if !success { - render(LoginResponse{ Message: "Invalid TOTP" }) - return - } - } else { - // TODO: user should be prompted to add 2FA method - } - - // login success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - render(LoginResponse{ Account: account, Token: token.Token }) + controller.SetSessionAccount(app.DB, session, nil) + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") + http.Redirect(w, r, "/admin/login", http.StatusFound) }) } -func LogoutHandler(db *sqlx.DB) 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 { + type totpSetupData struct { + Session *model.Session + } + + session := r.Context().Value("session").(*model.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) + } + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + session := r.Context().Value("session").(*model.Session) + + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) + totp := model.TOTP { + AccountID: session.Account.ID, + Name: name, + Secret: string(secret), + } + err = controller.CreateTOTP(app.DB, &totp) + 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 }) + if err != nil { + fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + 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{ + 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) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func totpConfirmHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + code := r.FormValue("totp") + if len(code) != controller.TOTP_CODE_LENGTH { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) + if err != nil { + 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 + } + if totp == nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + 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{ + 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) + }) +} + +func totpDeleteHandler(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 } - tokenStr := controller.GetTokenFromRequest(db, r) - - if len(tokenStr) > 0 { - err := controller.DeleteToken(db, tokenStr) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + if len(r.URL.Path) < 2 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return } + name := r.URL.Path[1:] - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = "" - cookie.Expires = time.Now() - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - http.Redirect(w, r, "/admin/login", http.StatusFound) - }) -} + session := r.Context().Value("session").(*model.Session) -func createAccountHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checkAccount, err := controller.GetAccountByRequest(db, r) + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) if err != nil { - fmt.Printf("WARN: Failed to fetch account: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + 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 } - if checkAccount != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - type CreateAccountResponse struct { - Account *model.Account - Message string - } - - render := func(data CreateAccountResponse) { - err := pages["create-account"].Execute(w, data) - if err != nil { - fmt.Printf("WARN: Error rendering create account page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - } - - if r.Method == http.MethodGet { - render(CreateAccountResponse{}) - return - } - - if r.Method != http.MethodPost { + if totp == nil { http.NotFound(w, r) return } - err = r.ParseForm() + err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) if err != nil { - render(CreateAccountResponse{ - Message: "Malformed data.", - }) + fmt.Printf("WARN: Failed to delete 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 } - type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Invite string `json:"invite"` - } - credentials := RegisterRequest{ - Username: r.Form.Get("username"), - Email: r.Form.Get("email"), - Password: r.Form.Get("password"), - Invite: r.Form.Get("invite"), - } + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) - // make sure code exists in DB - invite, err := controller.GetInvite(db, credentials.Invite) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - if invite == nil || time.Now().After(invite.ExpiresAt) { - if invite != nil { - err := controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - } - render(CreateAccountResponse{ - Message: "Invalid invite code.", - }) - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - account := model.Account{ - Username: credentials.Username, - Password: string(hashedPassword), - Email: credentials.Email, - AvatarURL: "/img/default-avatar.png", - } - err = controller.CreateAccount(db, &account) - if err != nil { - if strings.HasPrefix(err.Error(), "pq: duplicate key") { - render(CreateAccountResponse{ - Message: "An account with that username already exists.", - }) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - err = controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - - // registration success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - // gracefully redirect user to login page - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, TemplateData{ - Account: &account, - Token: token.Token, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + 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 af42cb1..9fa6bb2 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -1,20 +1,19 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveArtist() http.Handler { +func serveArtist(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - artist, err := controller.GetArtist(global.DB, id) + artist, err := controller.GetArtist(app.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -25,7 +24,7 @@ func serveArtist() http.Handler { return } - credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -33,15 +32,15 @@ func serveArtist() http.Handler { } type ArtistResponse struct { - Account *model.Account + Session *model.Session Artist *model.Artist Credits []*model.Credit } - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - err = pages["artist"].Execute(w, ArtistResponse{ - Account: account, + 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/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index 0500532..d03f80a 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,20 +3,20 @@

      Editing: Tracks

      Add -
      +
        - {{range $i, $track := .Tracks}} + {{range $i, $track := .Release.Tracks}}
      • - {{$track.Add $i 1}} + {{.Add $i 1}} {{$track.Title}}

        Delete diff --git a/admin/http.go b/admin/http.go index d118647..245a152 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,105 +1,462 @@ package admin import ( - "context" - "fmt" - "net/http" - "os" - "path/filepath" + "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" - "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" ) -func Handler(db *sqlx.DB) http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() - mux.Handle("/login", LoginHandler(db)) - mux.Handle("/register", createAccountHandler(db)) - mux.Handle("/logout", RequireAccount(db, LogoutHandler(db))) - mux.Handle("/account", RequireAccount(db, AccountHandler(db))) + 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("/register", registerAccountHandler(app)) + + mux.Handle("/account", requireAccount(accountIndexHandler(app))) + mux.Handle("/account/", requireAccount(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("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/release/", RequireAccount(db, http.StripPrefix("/release", serveRelease()))) - mux.Handle("/artist/", RequireAccount(db, http.StripPrefix("/artist", serveArtist()))) - mux.Handle("/track/", RequireAccount(db, http.StripPrefix("/track", serveTrack()))) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + mux.Handle("/", requireAccount(AdminIndexHandler(app))) + + // response wrapper to make sure a session cookie exists + return enforceSession(app, mux) +} + +func AdminIndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - account, err := controller.GetAccountByRequest(db, r) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) - } - if account == nil { - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } + session := r.Context().Value("session").(*model.Session) - releases, err := controller.GetAllReleases(db, false, 0, true) + releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(db) + artists, err := controller.GetAllArtists(app.DB) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := controller.GetOrphanTracks(db) - if err != nil { + tracks, err := controller.GetOrphanTracks(app.DB) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } type IndexData struct { - Account *model.Account + Session *model.Session Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } - err = pages["index"].Execute(w, IndexData{ - Account: account, + err = indexTemplate.Execute(w, IndexData{ + Session: session, Releases: releases, Artists: artists, Tracks: tracks, }) - if err != nil { + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - })) - - return mux + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) } -func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { +func registerAccountHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - account, err := controller.GetAccountByRequest(db, r) + session := r.Context().Value("session").(*model.Session) + + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type registerData struct { + Session *model.Session + } + + render := func() { + 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) + } + } + + if r.Method == http.MethodGet { + render() + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err := r.ParseForm() if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure invite code exists in DB + invite, err := controller.GetInvite(app.DB, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(app.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + controller.SetSessionError(app.DB, session, "Invalid invite code.") + render() + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: sql.NullString{ String: credentials.Email, Valid: true }, + AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, + } + err = controller.CreateAccount(app.DB, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + controller.SetSessionError(app.DB, session, "An account with that username already exists.") + render() + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + 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)) + + 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) + } + + // registration success! + controller.SetSessionAccount(app.DB, session, &account) + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) +} + +func loginHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + type loginData struct { + Session *model.Session + } + + render := func() { + 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) + return + } + } + + if r.Method == http.MethodGet { + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + render() + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + if !r.Form.Has("username") || !r.Form.Has("password") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + 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.") + render() return } if account == nil { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() + return + } + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") + render() + return + } + + 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 set attempt session: %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 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) + }) +} + +func logoutHandler(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) + err := controller.DeleteSession(app.DB, session.Token) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Expires: time.Now(), + Path: "/", + }) + + 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) + } + }) +} + +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 { // TODO: include context in redirect http.Redirect(w, r, "/admin/login", http.StatusFound) return } - - ctx := context.WithValue(r.Context(), "account", account) - - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r) }) } @@ -123,3 +480,63 @@ func staticHandler() http.Handler { http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) }) } + +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) + return + } + + if session == nil { + // create a new session + session, err = controller.CreateSession(app.DB, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Value: session.Token, + Expires: session.ExpiresAt, + Secure: strings.HasPrefix(app.Config.BaseUrl, "https"), + HttpOnly: true, + Path: "/", + }) + } + + ctx := context.WithValue(r.Context(), "session", session) + 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 9132fe8..c6b68ab 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -1,29 +1,28 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/global" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) -func serveRelease() http.Handler { +func serveRelease(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - release, err := controller.GetRelease(global.DB, releaseID, true) + release, err := controller.GetRelease(app.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { 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 } @@ -34,10 +33,10 @@ func serveRelease() http.Handler { serveEditCredits(release).ServeHTTP(w, r) return case "addcredit": - serveAddCredit(release).ServeHTTP(w, r) + serveAddCredit(app, release).ServeHTTP(w, r) return case "newcredit": - serveNewCredit().ServeHTTP(w, r) + serveNewCredit(app).ServeHTTP(w, r) return case "editlinks": serveEditLinks(release).ServeHTTP(w, r) @@ -46,10 +45,10 @@ func serveRelease() http.Handler { serveEditTracks(release).ServeHTTP(w, r) return case "addtrack": - serveAddTrack(release).ServeHTTP(w, r) + serveAddTrack(app, release).ServeHTTP(w, r) return case "newtrack": - serveNewTrack().ServeHTTP(w, r) + serveNewTrack(app).ServeHTTP(w, r) return } http.NotFound(w, r) @@ -57,12 +56,12 @@ func serveRelease() http.Handler { } type ReleaseResponse struct { - Account *model.Account + Session *model.Session Release *model.Release } - err = pages["release"].Execute(w, ReleaseResponse{ - Account: account, + err = releaseTemplate.Execute(w, ReleaseResponse{ + Session: session, Release: release, }) if err != nil { @@ -75,7 +74,7 @@ func serveRelease() 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) @@ -83,11 +82,11 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(release *model.Release) http.Handler { +func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID) + 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 } @@ -98,7 +97,7 @@ func serveAddCredit(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, }) @@ -109,12 +108,12 @@ func serveAddCredit(release *model.Release) http.Handler { }) } -func serveNewCredit() http.Handler { +func serveNewCredit(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(global.DB, artistID) + 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 } @@ -124,7 +123,7 @@ func serveNewCredit() 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) @@ -135,7 +134,7 @@ func serveNewCredit() 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) @@ -146,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler { func serveEditTracks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["edittracks"].Execute(w, release) + + type editTracksData struct { + Release *model.Release + Add func(a int, b int) int + } + + err := editTracksTemplate.Execute(w, editTracksData{ + Release: release, + Add: func(a, b int) int { return a + b }, + }) if err != nil { fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -154,11 +162,11 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(release *model.Release) http.Handler { +func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID) + 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 } @@ -169,7 +177,7 @@ func serveAddTrack(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, }) @@ -181,10 +189,10 @@ func serveAddTrack(release *model.Release) http.Handler { }) } -func serveNewTrack() http.Handler { +func serveNewTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(app.DB, trackID) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -196,7 +204,7 @@ func serveNewTrack() 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/admin.css b/admin/static/admin.css index 32f69bb..877b5da 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -24,7 +24,7 @@ nav { justify-content: left; background: #f8f8f8; - border-radius: .5em; + border-radius: 4px; border: 1px solid #808080; } nav .icon { @@ -85,6 +85,15 @@ a img.icon { height: .8em; } +code { + background: #303030; + color: #f0f0f0; + padding: .23em .3em; + border-radius: 4px; +} + + + .card { margin-bottom: 1em; } @@ -93,13 +102,6 @@ a img.icon { margin: 0 0 .5em 0; } -/* -.card h3, -.card p { - margin: 0; -} -*/ - .card-title { margin-bottom: 1em; display: flex; @@ -127,20 +129,34 @@ a img.icon { +#message, #error { - background: #ffa9b8; - border: 1px solid #dc5959; + margin: 0 0 1em 0; padding: 1em; border-radius: 4px; + background: #ffffff; + border: 1px solid #888; +} +#message { + background: #a9dfff; + border-color: #599fdc; +} +#error { + background: #ffa9b8; + border-color: #dc5959; } +a.delete:not(.button) { + color: #d22828; +} + button, .button { padding: .5em .8em; font-family: inherit; font-size: inherit; - border-radius: .5em; + border-radius: 4px; border: 1px solid #a0a0a0; background: #f0f0f0; color: inherit; @@ -154,35 +170,59 @@ button:active, .button:active { border-color: #808080; } -button { +.button, button { color: inherit; } -button.new { +.button.new, button.new { background: #c4ff6a; border-color: #84b141; } -button.save { +.button.save, button.save { background: #6fd7ff; border-color: #6f9eb0; } -button.delete { +.button.delete, button.delete { background: #ff7171; border-color: #7d3535; } -button:hover { +.button:hover, button:hover { background: #fff; border-color: #d0d0d0; } -button:active { +.button:active, button:active { background: #d0d0d0; border-color: #808080; } -button[disabled] { +.button[disabled], button[disabled] { background: #d0d0d0 !important; border-color: #808080 !important; opacity: .5; cursor: not-allowed !important; } -a.delete { - color: #d22828; + + + +form { + width: 100%; + display: block; +} +form label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} +form input { + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} +input[disabled] { + opacity: .5; + cursor: not-allowed; } diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 625db13..7a4d34a 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -1,28 +1,18 @@ @import url("/admin/static/index.css"); -form#change-password { - width: 100%; - display: flex; - flex-direction: column; - align-items: start; -} - -form div { - width: 20rem; -} - -form button { - margin-top: 1rem; +div.card { + margin-bottom: 2rem; } label { - width: 100%; - margin: 1rem 0 .5rem 0; - display: block; - color: #10101080; + width: auto; + margin: 0; + display: flex; + align-items: center; + color: inherit; } input { - width: 100%; + width: min(20rem, calc(100% - 1rem)); margin: .5rem 0; padding: .3rem .5rem; display: block; @@ -33,18 +23,11 @@ input { color: inherit; } -#error { - background: #ffa9b8; - border: 1px solid #dc5959; - padding: 1em; - border-radius: 4px; -} - .mfa-device { padding: .75em; background: #f8f8f8f8; border: 1px solid #808080; - border-radius: .5em; + border-radius: 8px; margin-bottom: .5em; display: flex; justify-content: space-between; diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 793b989..5627e64 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -9,7 +9,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 10eada3..aa70e34 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -11,7 +11,7 @@ input[type="text"] { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -160,7 +160,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -170,7 +170,7 @@ dialog div.dialog-actions { } .card.credits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } .card.credits .credit .artist-name { @@ -196,7 +196,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -215,7 +215,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } #editcredits .credit .credit-info { @@ -228,12 +228,14 @@ dialog div.dialog-actions { } #editcredits .credit .credit-info .credit-attribute label { + width: auto; + margin: 0; display: flex; align-items: center; } #editcredits .credit .credit-info .credit-attribute input[type="text"] { - margin-left: .25em; + margin: 0 0 0 .25em; padding: .2em .4em; flex-grow: 1; font-family: inherit; @@ -241,6 +243,9 @@ dialog div.dialog-actions { border-radius: 4px; color: inherit; } +#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { + margin: 0 .3em; +} #editcredits .credit .artist-name { font-weight: bold; @@ -369,8 +374,10 @@ dialog div.dialog-actions { #editlinks td input[type="text"] { width: calc(100% - .6em); height: 100%; + margin: 0; padding: 0 .3em; border: none; + border-radius: 0; outline: none; cursor: pointer; background: none; @@ -393,7 +400,7 @@ dialog div.dialog-actions { flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 8a05089..600b680 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -11,7 +11,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/index.css b/admin/static/index.css index 9d38940..9fcd731 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,23 +1,5 @@ @import url("/admin/static/release-list-item.css"); -.create-btn { - background: #c4ff6a; - padding: .5em .8em; - border-radius: .5em; - border: 1px solid #84b141; - text-decoration: none; -} -.create-btn:hover { - background: #fff; - border-color: #d0d0d0; - text-decoration: inherit; -} -.create-btn:active { - background: #d0d0d0; - border-color: #808080; - text-decoration: inherit; -} - .artist { margin-bottom: .5em; padding: .5em; @@ -26,7 +8,7 @@ align-items: center; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -49,7 +31,7 @@ flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } 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/static/release-list-item.css b/admin/static/release-list-item.css index ee67de7..638eac0 100644 --- a/admin/static/release-list-item.css +++ b/admin/static/release-list-item.css @@ -5,7 +5,7 @@ flex-direction: row; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -50,7 +50,7 @@ padding: .5em; display: block; - border-radius: .5em; + border-radius: 8px; text-decoration: none; color: #f0f0f0; background: #303030; @@ -73,7 +73,7 @@ padding: .3em .5em; display: inline-block; - border-radius: .3em; + border-radius: 4px; background: #e0e0e0; transition: color .1s, background .1s; diff --git a/admin/templates.go b/admin/templates.go index 1fa7a65..606d569 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,65 +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"), - )), - "create-account": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "create-account.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"), - )), +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 2cea123..93eacdb 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -1,20 +1,19 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveTrack() http.Handler { +func serveTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - track, err := controller.GetTrack(global.DB, id) + track, err := controller.GetTrack(app.DB, id) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -25,7 +24,7 @@ func serveTrack() http.Handler { return } - releases, err := controller.GetTrackReleases(global.DB, track.ID, true) + releases, err := controller.GetTrackReleases(app.DB, track.ID, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -33,15 +32,15 @@ func serveTrack() http.Handler { } type TrackResponse struct { - Account *model.Account + Session *model.Session Track *model.Track Releases []*model.Release } - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - err = pages["track"].Execute(w, TrackResponse{ - Account: account, + err = trackTemplate.Execute(w, TrackResponse{ + Session: session, Track: track, Releases: releases, }) diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html index 4d89052..6c17088 100644 --- a/admin/views/edit-account.html +++ b/admin/views/edit-account.html @@ -6,23 +6,27 @@ {{define "content"}}
        -

        Account Settings ({{.Account.Username}})

        + {{if .Session.Message.Valid}} +

        {{html .Session.Message.String}}

        + {{end}} + {{if .Session.Error.Valid}} +

        {{html .Session.Error.String}}

        + {{end}} +

        Account Settings ({{.Session.Account.Username}})

        Change Password

        - -
        - - + + + - - + + - - -
        + + @@ -36,11 +40,11 @@ {{range .TOTPs}}
        -

        {{.Name}}

        -

        Added: {{.CreatedAt}}

        +

        {{.TOTP.Name}}

        +

        Added: {{.CreatedAtString}}

        {{end}} @@ -48,7 +52,10 @@

        You have no MFA devices.

        {{end}} - +
        + + Add TOTP Device +
        @@ -58,9 +65,17 @@

        Clicking the button below will delete your account. This action is irreversible. - You will be prompted to confirm this decision. + You will need to enter your password and TOTP below.

        - +
        + + + + + + + +
        diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index ccb3a45..b0cfb41 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -36,13 +36,13 @@ {{if .Credits}} {{range .Credits}}
        - +
        -

        {{.Artist.Release.Title}}

        -

        {{.Artist.Release.PrintArtists true true}}

        +

        {{.Release.Title}}

        +

        {{.Release.PrintArtists true true}}

        - Role: {{.Artist.Role}} - {{if .Artist.Primary}} + Role: {{.Role}} + {{if .Primary}} (Primary) {{end}}

        diff --git a/admin/views/index.html b/admin/views/index.html index 8f42e0e..2b9c897 100644 --- a/admin/views/index.html +++ b/admin/views/index.html @@ -9,7 +9,7 @@

        Releases

        - Create New + Create New
        {{range .Releases}} @@ -22,7 +22,7 @@

        Artists

        - Create New + Create New
        {{range $Artist := .Artists}} @@ -38,7 +38,7 @@

        Tracks

        - Create New + Create New

        "Orphaned" tracks that have not yet been bound to a release.

        diff --git a/admin/views/layout.html b/admin/views/layout.html index 0a33b72..52b0620 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -23,10 +23,20 @@ -
        - {{if .Account}} + {{if .Session.Account}} + {{end}} + +
        + + {{if .Session.Account}} + + {{else}}