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..cccde2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,3 @@ uploads/ 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..f0990df 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,71 +1,43 @@ package admin import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "fmt" + "net/http" + "os" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/global" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" ) -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 +type TemplateData struct { + Account *model.Account + Message string + Token string } -func accountIndexHandler(app *model.AppState) http.Handler { +func AccountHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) + totps, err := controller.GetTOTPsForAccount(db, 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 ( - 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"), - }) + type AccountResponse struct { + Account *model.Account + TOTPs []model.TOTP } - 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, + err = pages["account"].Execute(w, AccountResponse{ + Account: account, TOTPs: totps, }) if err != nil { @@ -75,296 +47,299 @@ func accountIndexHandler(app *model.AppState) http.Handler { }) } -func changePasswordHandler(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) - - 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 - } - - 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) - return - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - if !r.Form.Has("password") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - 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 { - 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 - } - - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) - - controller.SetSessionAccount(app.DB, session, nil) - controller.SetSessionError(app.DB, session, "") - controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") - http.Redirect(w, r, "/admin/login", http.StatusFound) - }) -} - -type totpConfirmData struct { - Session *model.Session - TOTP *model.TOTP - NameEscaped string - QRBase64Image string -} - -func totpSetupHandler(app *model.AppState) http.Handler { +func LoginHandler(db *sqlx.DB) 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 }) + account, err := controller.GetAccountByRequest(db, r) if err != nil { - fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 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 } - 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 }) + err = pages["login"].Execute(w, TemplateData{}) if err != nil { - fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } 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) + type LoginResponse struct { + Account *model.Account + Token string + Message string } - 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) - } + 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 } } - 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) + if r.Method != http.MethodPost { + http.NotFound(w, r); return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) + err := r.ParseForm() + if err != nil { + render(LoginResponse{ Message: "Malformed request." }) + return + } - 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) + 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"), + } + + account, err := controller.GetAccount(db, credentials.Username) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + if account == nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + + 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 }) }) } -func totpDeleteHandler(app *model.AppState) http.Handler { +func LogoutHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } - if len(r.URL.Path) < 2 { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - 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 + } } - name := r.URL.Path[1:] - session := r.Context().Value("session").(*model.Session) + 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) + }) +} - totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) +func createAccountHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkAccount, err := controller.GetAccountByRequest(db, r) if err != nil { - 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) + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if totp == nil { + 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 { http.NotFound(w, r) return } - err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) + err = r.ParseForm() if err != nil { - 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) + render(CreateAccountResponse{ + Message: "Malformed data.", + }) return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) + 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"), + } - 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) + // 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 + } }) } diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 9fa6bb2..af42cb1 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -1,19 +1,20 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveArtist(app *model.AppState) http.Handler { +func serveArtist() 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(app.DB, id) + artist, err := controller.GetArtist(global.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -24,7 +25,7 @@ func serveArtist(app *model.AppState) http.Handler { return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(global.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) @@ -32,15 +33,15 @@ func serveArtist(app *model.AppState) http.Handler { } type ArtistResponse struct { - Session *model.Session + Account *model.Account Artist *model.Artist Credits []*model.Credit } - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - err = artistTemplate.Execute(w, ArtistResponse{ - Session: session, + err = pages["artist"].Execute(w, ArtistResponse{ + Account: account, 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/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index d03f80a..0500532 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,20 +3,20 @@

      Editing: Tracks

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

        Delete diff --git a/admin/http.go b/admin/http.go index 245a152..d118647 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,462 +1,105 @@ package admin import ( - "context" - "database/sql" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "context" + "fmt" + "net/http" + "os" + "path/filepath" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "github.com/jmoiron/sqlx" ) -func Handler(app *model.AppState) http.Handler { +func Handler(db *sqlx.DB) 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("/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("/login", LoginHandler(db)) + mux.Handle("/register", createAccountHandler(db)) + mux.Handle("/logout", RequireAccount(db, LogoutHandler(db))) + mux.Handle("/account", RequireAccount(db, AccountHandler(db))) mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - - 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) { + 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) { if r.URL.Path != "/" { http.NotFound(w, r) return } - session := r.Context().Value("session").(*model.Session) + 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 + } - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := controller.GetAllReleases(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(app.DB) + artists, err := controller.GetAllArtists(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(app.DB) - if err != nil { + tracks, err := controller.GetOrphanTracks(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 { - Session *model.Session + Account *model.Account Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } - err = indexTemplate.Execute(w, IndexData{ - Session: session, + err = pages["index"].Execute(w, IndexData{ + Account: account, 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 +} + +func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account, err := controller.GetAccountByRequest(db, r) + if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} - -func registerAccountHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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.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() + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 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 } - next.ServeHTTP(w, r) + + ctx := context.WithValue(r.Context(), "account", account) + + next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -480,63 +123,3 @@ 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 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..9132fe8 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -1,28 +1,29 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/global" + "arimelody-web/controller" + "arimelody-web/model" ) -func serveRelease(app *model.AppState) http.Handler { +func serveRelease() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - release, err := controller.GetRelease(app.DB, releaseID, true) + release, err := controller.GetRelease(global.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { 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 } @@ -33,10 +34,10 @@ func serveRelease(app *model.AppState) http.Handler { serveEditCredits(release).ServeHTTP(w, r) return case "addcredit": - serveAddCredit(app, release).ServeHTTP(w, r) + serveAddCredit(release).ServeHTTP(w, r) return case "newcredit": - serveNewCredit(app).ServeHTTP(w, r) + serveNewCredit().ServeHTTP(w, r) return case "editlinks": serveEditLinks(release).ServeHTTP(w, r) @@ -45,10 +46,10 @@ func serveRelease(app *model.AppState) http.Handler { serveEditTracks(release).ServeHTTP(w, r) return case "addtrack": - serveAddTrack(app, release).ServeHTTP(w, r) + serveAddTrack(release).ServeHTTP(w, r) return case "newtrack": - serveNewTrack(app).ServeHTTP(w, r) + serveNewTrack().ServeHTTP(w, r) return } http.NotFound(w, r) @@ -56,12 +57,12 @@ func serveRelease(app *model.AppState) http.Handler { } type ReleaseResponse struct { - Session *model.Session + Account *model.Account Release *model.Release } - err = releaseTemplate.Execute(w, ReleaseResponse{ - Session: session, + err = pages["release"].Execute(w, ReleaseResponse{ + Account: account, Release: release, }) if err != nil { @@ -74,7 +75,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) @@ -82,11 +83,11 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { +func serveAddCredit(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) + artists, err := controller.GetArtistsNotOnRelease(global.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 +98,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, }) @@ -108,12 +109,12 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewCredit(app *model.AppState) http.Handler { +func serveNewCredit() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := controller.GetArtist(global.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 +124,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 +135,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) @@ -145,16 +146,7 @@ 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") - - 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 }, - }) + err := components["edittracks"].Execute(w, release) 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) @@ -162,11 +154,11 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { +func serveAddTrack(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) + tracks, err := controller.GetTracksNotOnRelease(global.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 +169,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, }) @@ -189,10 +181,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewTrack(app *model.AppState) http.Handler { +func serveNewTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(app.DB, trackID) + track, err := controller.GetTrack(global.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) @@ -204,7 +196,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/admin.css b/admin/static/admin.css index 877b5da..32f69bb 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -24,7 +24,7 @@ nav { justify-content: left; background: #f8f8f8; - border-radius: 4px; + border-radius: .5em; border: 1px solid #808080; } nav .icon { @@ -85,15 +85,6 @@ a img.icon { height: .8em; } -code { - background: #303030; - color: #f0f0f0; - padding: .23em .3em; - border-radius: 4px; -} - - - .card { margin-bottom: 1em; } @@ -102,6 +93,13 @@ code { margin: 0 0 .5em 0; } +/* +.card h3, +.card p { + margin: 0; +} +*/ + .card-title { margin-bottom: 1em; display: flex; @@ -129,34 +127,20 @@ code { -#message, -#error { - 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; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; } -a.delete:not(.button) { - color: #d22828; -} - button, .button { padding: .5em .8em; font-family: inherit; font-size: inherit; - border-radius: 4px; + border-radius: .5em; border: 1px solid #a0a0a0; background: #f0f0f0; color: inherit; @@ -170,59 +154,35 @@ 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; } - - - -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; +a.delete { + color: #d22828; } diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 7a4d34a..625db13 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -1,18 +1,28 @@ @import url("/admin/static/index.css"); -div.card { - margin-bottom: 2rem; +form#change-password { + width: 100%; + display: flex; + flex-direction: column; + align-items: start; +} + +form div { + width: 20rem; +} + +form button { + margin-top: 1rem; } label { - width: auto; - margin: 0; - display: flex; - align-items: center; - color: inherit; + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; } input { - width: min(20rem, calc(100% - 1rem)); + width: 100%; margin: .5rem 0; padding: .3rem .5rem; display: block; @@ -23,11 +33,18 @@ 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: 8px; + border-radius: .5em; margin-bottom: .5em; display: flex; justify-content: space-between; diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 5627e64..793b989 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index aa70e34..10eada3 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -160,7 +160,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -170,7 +170,7 @@ dialog div.dialog-actions { } .card.credits .credit .artist-avatar { - border-radius: 8px; + border-radius: .5em; } .card.credits .credit .artist-name { @@ -196,7 +196,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -215,7 +215,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: 8px; + border-radius: .5em; } #editcredits .credit .credit-info { @@ -228,14 +228,12 @@ 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: 0 0 0 .25em; + margin-left: .25em; padding: .2em .4em; flex-grow: 1; font-family: inherit; @@ -243,9 +241,6 @@ 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; @@ -374,10 +369,8 @@ 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; @@ -400,7 +393,7 @@ dialog div.dialog-actions { flex-direction: column; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 600b680..8a05089 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/index.css b/admin/static/index.css index 9fcd731..9d38940 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,5 +1,23 @@ @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; @@ -8,7 +26,7 @@ align-items: center; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -31,7 +49,7 @@ flex-direction: column; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } 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/static/release-list-item.css b/admin/static/release-list-item.css index 638eac0..ee67de7 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -50,7 +50,7 @@ padding: .5em; display: block; - border-radius: 8px; + border-radius: .5em; text-decoration: none; color: #f0f0f0; background: #303030; @@ -73,7 +73,7 @@ padding: .3em .5em; display: inline-block; - border-radius: 4px; + border-radius: .3em; background: #e0e0e0; transition: color .1s, background .1s; diff --git a/admin/templates.go b/admin/templates.go index 606d569..1fa7a65 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,125 +1,65 @@ 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"), + )), + "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 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..2cea123 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -1,19 +1,20 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveTrack(app *model.AppState) http.Handler { +func serveTrack() 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(app.DB, id) + track, err := controller.GetTrack(global.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) @@ -24,7 +25,7 @@ func serveTrack(app *model.AppState) http.Handler { return } - releases, err := controller.GetTrackReleases(app.DB, track.ID, true) + releases, err := controller.GetTrackReleases(global.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) @@ -32,15 +33,15 @@ func serveTrack(app *model.AppState) http.Handler { } type TrackResponse struct { - Session *model.Session + Account *model.Account Track *model.Track Releases []*model.Release } - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - err = trackTemplate.Execute(w, TrackResponse{ - Session: session, + err = pages["track"].Execute(w, TrackResponse{ + Account: account, Track: track, Releases: releases, }) diff --git a/admin/views/register.html b/admin/views/create-account.html similarity index 55% rename from admin/views/register.html rename to admin/views/create-account.html index 37f0947..8d59c0f 100644 --- a/admin/views/register.html +++ b/admin/views/create-account.html @@ -11,7 +11,7 @@ a.discord { color: #5865F2; } -form#register { +form { width: 100%; display: flex; flex-direction: column; @@ -26,33 +26,45 @@ form button { margin-top: 1rem; } +label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} input { - width: calc(100% - 1rem - 2px); + width: 100%; + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; } {{end}} {{define "content"}}
        - {{if .Session.Error.Valid}} -

        {{html .Session.Error.String}}

        + {{if .Message}} +

        {{.Message}}

        {{end}} - -

        Create Account

        - +
        - + - + - + - +
        diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html index 6c17088..4d89052 100644 --- a/admin/views/edit-account.html +++ b/admin/views/edit-account.html @@ -6,27 +6,23 @@ {{define "content"}}
        - {{if .Session.Message.Valid}} -

        {{html .Session.Message.String}}

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

        {{html .Session.Error.String}}

        - {{end}} -

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

        +

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

        Change Password

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

        {{.TOTP.Name}}

        -

        Added: {{.CreatedAtString}}

        +

        {{.Name}}

        +

        Added: {{.CreatedAt}}

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

        You have no MFA devices.

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

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

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

        {{.Release.Title}}

        -

        {{.Release.PrintArtists true true}}

        +

        {{.Artist.Release.Title}}

        +

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

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

        diff --git a/admin/views/index.html b/admin/views/index.html index 2b9c897..8f42e0e 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 52b0620..0a33b72 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -23,20 +23,10 @@ - {{if .Session.Account}} - - {{end}} -
        - - {{if .Session.Account}} + {{if .Account}} - {{else}}