diff --git a/.air.toml b/.air.toml index c6d499b..070166a 100644 --- a/.air.toml +++ b/.air.toml @@ -7,14 +7,14 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ." delay = 1000 - exclude_dir = ["uploads", "test", "db", "res"] + exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html", "css"] + include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" 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/.forgejo/workflows/push-prod.yaml b/.forgejo/workflows/push-prod.yaml deleted file mode 100644 index 7a9fd05..0000000 --- a/.forgejo/workflows/push-prod.yaml +++ /dev/null @@ -1,50 +0,0 @@ -on: - push: - branches: - - main - -env: - EXEC: arimelody-web - REMOTE: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} - PORT: ${{ secrets.SSH_PORT }} - -jobs: - deploy: - runs-on: docker - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '^1.25.1' - - - name: Run tests - run: go test -v ./model - - - name: Build binary - run: make build - - - name: Bundle tarball - run: make bundle - - - name: Set up SSH keys - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Copy to production server - run: | - ssh-keyscan -p $PORT ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts - scp -P $PORT ./$EXEC.tar.gz $REMOTE:~/ - - - name: Restart production - run: | - ssh -o StrictHostKeyChecking=no $REMOTE -p $PORT << EOT - cd ${{ secrets.DEPLOY_DIR }} - tar xzf ~/$EXEC.tar.gz - /bin/bash ~/restart.sh - rm ~/$EXEC.tar.gz - EOT - 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 98a4ea1..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -EXEC = arimelody-web - -.PHONY: $(EXEC) - -build: - GOOS=linux GOARCH=amd64 go build -o $(EXEC) - -bundle: build - tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/ - -clean: - rm $(EXEC) $(EXEC).tar.gz diff --git a/README.md b/README.md index f1fd392..7e7860c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ -# ari melody website +# arimelody.me home to your local SPACEGIRL! 💫 --- -a slightly-overcomplicated webserver built to show off everything i've worked -on, and then some! this server comes complete with twitch live status tracking, -a portfolio database, and a full-fledged admin CMS panel to manage it all! +built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static) +branch, this powerful, server-side rendered version comes complete with live +updates, powered by a new database and handy admin panel! + +the admin panel currently facilitates live updating of my music discography, +though i plan to expand it towards art portfolio and blog posts in the future. +if all goes well, i'd like to later separate these components into their own +library for others to use in their own sites. exciting stuff! ## build @@ -37,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 113a17a..f0990df 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,72 +1,43 @@ package admin import ( - "database/sql" "fmt" "net/http" - "net/url" "os" + "strings" + "time" - "arimelody-web/admin/templates" "arimelody-web/controller" - "arimelody-web/log" + "arimelody-web/global" "arimelody-web/model" + "github.com/jmoiron/sqlx" "golang.org/x/crypto/bcrypt" ) -func accountHandler(app *model.AppState) http.Handler { - mux := http.NewServeMux() - - mux.Handle("/account/totp-setup", totpSetupHandler(app)) - mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) - mux.Handle("/account/totp-delete", totpDeleteHandler(app)) - - mux.Handle("/account/password", changePasswordHandler(app)) - mux.Handle("/account/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 { - adminPageData - 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 = templates.AccountTemplate.Execute(w, accountResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + err = pages["account"].Execute(w, AccountResponse{ + Account: account, TOTPs: totps, }) if err != nil { @@ -76,116 +47,189 @@ func accountIndexHandler(app *model.AppState) http.Handler { }) } -func changePasswordHandler(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 { + 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 + } + return + } + + type LoginResponse struct { + Account *model.Account + Token string + Message string + } + + 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 + } + } + 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) + http.NotFound(w, r); return } err := r.ParseForm() if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + render(LoginResponse{ Message: "Malformed request." }) return } - if !r.Form.Has("password") { - 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"), } - 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) + account, err := controller.GetAccount(db, credentials.Username) 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) + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + if account == 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)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } - controller.SetSessionAccount(app.DB, session, nil) - controller.SetSessionError(app.DB, session, "") - controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") + 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 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 + } + + 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 + } + } + + 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) }) } -type totpConfirmData struct { - adminPageData - TOTP *model.TOTP - NameEscaped string - QRBase64Image string -} - -func totpSetupHandler(app *model.AppState) http.Handler { +func createAccountHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - session := r.Context().Value("session").(*model.Session) + checkAccount, err := controller.GetAccountByRequest(db, r) + if err != nil { + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if checkAccount != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } - err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session }) + 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: Failed to render TOTP setup page: %s\n", err) + 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 } @@ -194,175 +238,108 @@ func totpSetupHandler(app *model.AppState) http.Handler { return } - err := r.ParseForm() + 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 := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + render(CreateAccountResponse{ + Message: "Malformed data.", }) - 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) + 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"), } - err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, - TOTP: &totp, - NameEscaped: url.PathEscape(totp.Name), - QRBase64Image: qrBase64Image, + // 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.Printf("WARN: Failed to render TOTP confirm page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } }) } - -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 - } - - 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) - } - - code := r.FormValue("totp") - confirmCode := controller.GenerateTOTP(totp.Secret, 0) - confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) - if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) { - session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } - err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, 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.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 - } - - totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) - 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) - return - } - if totp == nil { - http.NotFound(w, r) - return - } - - err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) - 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) - return - } - - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) - - controller.SetSessionError(app.DB, session, "") - controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) - http.Redirect(w, r, "/admin/account", http.StatusFound) - }) -} diff --git a/admin/artisthttp.go b/admin/artisthttp.go index f151ddd..af42cb1 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -5,82 +5,50 @@ import ( "net/http" "strings" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/global" + "arimelody-web/model" + "arimelody-web/controller" ) -func serveArtists(app *model.AppState) http.Handler { +func serveArtist() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/") - artistID := slices[0] - - if len(artistID) > 0 { - serveArtist(app, artistID).ServeHTTP(w, r) - return - } - - artists, err := controller.GetAllArtists(app.DB) - if err != nil { - fmt.Printf("WARN: Failed to fetch artists: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - type ArtistsResponse struct { - adminPageData - Artists []*model.Artist - } - - err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, - Artists: artists, - }) - if err != nil { - fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }) -} - -func serveArtist(app *model.AppState, artistID string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - artist, err := controller.GetArtist(app.DB, artistID) + slices := strings.Split(r.URL.Path[1:], "/") + id := slices[0] + artist, err := controller.GetArtist(global.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) + fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) if err != nil { - fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type ArtistResponse struct { - adminPageData + Account *model.Account Artist *model.Artist Credits []*model.Credit } - err = templates.EditArtistTemplate.Execute(w, ArtistResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + account := r.Context().Value("account").(*model.Account) + + err = pages["artist"].Execute(w, ArtistResponse{ + Account: account, Artist: artist, Credits: credits, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } + diff --git a/admin/templates/html/components/credit/addcredit.html b/admin/components/credits/addcredit.html similarity index 92% rename from admin/templates/html/components/credit/addcredit.html rename to admin/components/credits/addcredit.html index 082ba17..32c43fa 100644 --- a/admin/templates/html/components/credit/addcredit.html +++ b/admin/components/credits/addcredit.html @@ -1,13 +1,13 @@
-

Add Artist Credit

+

Add artist credit

    {{range $Artist := .Artists}}
  • diff --git a/admin/templates/html/components/credit/editcredits.html b/admin/components/credits/editcredits.html similarity index 97% rename from admin/templates/html/components/credit/editcredits.html rename to admin/components/credits/editcredits.html index 38132e2..94dc268 100644 --- a/admin/templates/html/components/credit/editcredits.html +++ b/admin/components/credits/editcredits.html @@ -3,8 +3,8 @@

    Editing: Credits

    Add diff --git a/admin/templates/html/components/credit/newcredit.html b/admin/components/credits/newcredit.html similarity index 100% rename from admin/templates/html/components/credit/newcredit.html rename to admin/components/credits/newcredit.html diff --git a/admin/templates/html/components/link/editlinks.html b/admin/components/links/editlinks.html similarity index 100% rename from admin/templates/html/components/link/editlinks.html rename to admin/components/links/editlinks.html diff --git a/admin/templates/html/components/release/release.html b/admin/components/release/release-list-item.html similarity index 67% rename from admin/templates/html/components/release/release.html rename to admin/components/release/release-list-item.html index 7a5059d..677318d 100644 --- a/admin/templates/html/components/release/release.html +++ b/admin/components/release/release-list-item.html @@ -5,17 +5,17 @@

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

    {{.PrintArtists true true}}

    {{.ReleaseType}} - ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    + ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    diff --git a/admin/templates/html/components/track/addtrack.html b/admin/components/tracks/addtrack.html similarity index 91% rename from admin/templates/html/components/track/addtrack.html rename to admin/components/tracks/addtrack.html index 6a2360b..f402763 100644 --- a/admin/templates/html/components/track/addtrack.html +++ b/admin/components/tracks/addtrack.html @@ -1,6 +1,6 @@
    -

    Add Track

    +

    Add track

      @@ -8,7 +8,7 @@
    • diff --git a/admin/templates/html/components/track/edittracks.html b/admin/components/tracks/edittracks.html similarity index 92% rename from admin/templates/html/components/track/edittracks.html rename to admin/components/tracks/edittracks.html index c06f0c3..0500532 100644 --- a/admin/templates/html/components/track/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/templates/html/components/track/newtrack.html b/admin/components/tracks/newtrack.html similarity index 100% rename from admin/templates/html/components/track/newtrack.html rename to admin/components/tracks/newtrack.html diff --git a/admin/http.go b/admin/http.go index 2a6b4ae..d118647 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,574 +2,124 @@ package admin import ( "context" - "database/sql" "fmt" "net/http" "os" - "strings" - "time" + "path/filepath" - "arimelody-web/admin/templates" "arimelody-web/controller" - "arimelody-web/log" "arimelody-web/model" - "arimelody-web/view" - "golang.org/x/crypto/bcrypt" + "github.com/jmoiron/sqlx" ) -type adminPageData struct { - Path string - Session *model.Session -} - -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(accountHandler(app))) - - mux.Handle("/logs", requireAccount(logsHandler(app))) - - mux.Handle("/releases", requireAccount(serveReleases(app))) - mux.Handle("/releases/", requireAccount(serveReleases(app))) - mux.Handle("/artists", requireAccount(serveArtists(app))) - mux.Handle("/artists/", requireAccount(serveArtists(app))) - mux.Handle("/tracks", requireAccount(serveTracks(app))) - mux.Handle("/tracks/", requireAccount(serveTracks(app))) - - mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/static/admin.css" { - http.ServeFile(w, r, "./admin/static/admin.css") - return - } - if r.URL.Path == "/static/admin.js" { - http.ServeFile(w, r, "./admin/static/admin.js") - return - } - requireAccount( - http.StripPrefix("/static", - view.ServeFiles("./admin/static"))).ServeHTTP(w, r) - })) - - 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("/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("/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, 3, 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 } - releaseCount, err := controller.GetReleaseCount(app.DB, false) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %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 } - artistCount, err := controller.GetArtistCount(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %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 - } - trackCount, err := controller.GetTrackCount(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %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 { - adminPageData - Releases []*model.Release - ReleaseCount int - Artists []*model.Artist - ArtistCount int - Tracks []*model.Track - TrackCount int + Account *model.Account + Releases []*model.Release + Artists []*model.Artist + Tracks []*model.Track } - err = templates.IndexTemplate.Execute(w, IndexData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + err = pages["index"].Execute(w, IndexData{ + Account: account, Releases: releases, - ReleaseCount: releaseCount, Artists: artists, - ArtistCount: artistCount, Tracks: tracks, - TrackCount: trackCount, }) - 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 - } - - render := func() { - err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, 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) - - render := func() { - err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, 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 - } - - render := func() { - err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, 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 = templates.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) - }) -} -/* -//go:embed "static" -var staticFS embed.FS + ctx := context.WithValue(r.Context(), "account", account) -func staticHandler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - uri := strings.TrimPrefix(r.URL.Path, "/static") - file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri))) - if err != nil { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) - w.WriteHeader(http.StatusOK) - - w.Write(file) - }) -} -*/ - -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 +func staticHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) + // does the file exist? + if err != nil { + if os.IsNotExist(err) { + http.NotFound(w, r) + return + } + } + + // is thjs a directory? (forbidden) + if info.IsDir() { + http.NotFound(w, r) + return + } + + http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) + }) } diff --git a/admin/logshttp.go b/admin/logshttp.go deleted file mode 100644 index a6d8e40..0000000 --- a/admin/logshttp.go +++ /dev/null @@ -1,68 +0,0 @@ -package admin - -import ( - "arimelody-web/admin/templates" - "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 { - adminPageData - Logs []*log.Log - } - - err = templates.LogsTemplate.Execute(w, LogsResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, 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 7cca841..9132fe8 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -3,83 +3,41 @@ package admin import ( "fmt" "net/http" - "os" "strings" - "arimelody-web/admin/templates" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func serveReleases(app *model.AppState) http.Handler { +func serveRelease() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/") + slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - var action string = "" - if len(slices) > 1 { - action = slices[1] - } + account := r.Context().Value("account").(*model.Account) - if len(releaseID) > 0 { - serveRelease(app, releaseID, action).ServeHTTP(w, r) - return - } - - type ReleasesData struct { - adminPageData - Releases []*model.Release - } - - releases, err := controller.GetAllReleases(app.DB, false, 0, true) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - err = templates.ReleasesTemplate.Execute(w, ReleasesData{ - adminPageData: adminPageData{ - Path: r.URL.Path, - Session: session, - }, - Releases: releases, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} - -func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - 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 fetch 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 } - if len(action) > 0 { - switch action { + if len(slices) > 1 { + switch slices[1] { case "editcredits": 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) @@ -88,10 +46,10 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han 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) @@ -99,20 +57,16 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han } type ReleaseResponse struct { - adminPageData + Account *model.Account Release *model.Release } - for i, track := range release.Tracks { - track.Number = i + 1 - } - - err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + err = pages["release"].Execute(w, ReleaseResponse{ + Account: account, Release: release, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -121,19 +75,19 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han 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 := templates.EditCreditsTemplate.Execute(w, release) + err := components["editcredits"].Execute(w, release) if err != nil { - fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -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 fetch 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 } @@ -144,23 +98,23 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.AddCreditTemplate.Execute(w, response{ + err = components["addcredit"].Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) if err != nil { - fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -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 fetch artist %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 } @@ -170,9 +124,9 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.NewCreditTemplate.Execute(w, artist) + err = components["newcredit"].Execute(w, artist) if err != nil { - fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err) + fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -181,9 +135,9 @@ 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 := templates.EditLinksTemplate.Execute(w, release) + err := components["editlinks"].Execute(w, release) if err != nil { - fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -192,22 +146,19 @@ 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 } - - err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release }) + err := components["edittracks"].Execute(w, release) if err != nil { - fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -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 fetch 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 } @@ -218,23 +169,24 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.AddTrackTemplate.Execute(w, response{ + err = components["addtrack"].Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) if err != nil { - fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err) + fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } + return }) } -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("WARN: Failed to fetch track %s: %s\n", trackID, err) + fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -244,10 +196,11 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.NewTrackTemplate.Execute(w, track) + err = components["newtrack"].Execute(w, track) if err != nil { - fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err) + fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } + return }) } diff --git a/admin/static/admin.css b/admin/static/admin.css index 8f983c7..32f69bb 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -1,295 +1,91 @@ @import url("/style/prideflag.css"); @import url("/font/inter/inter.css"); -:root { - --bg-0: #101010; - --bg-1: #181818; - --bg-2: #282828; - --bg-3: #404040; - - --fg-0: #b0b0b0; - --fg-1: #c0c0c0; - --fg-2: #d0d0d0; - --fg-3: #e0e0e0; - - --col-shadow-0: #0002; - --col-shadow-1: #0004; - --col-shadow-2: #0006; - --col-highlight-0: #ffffff08; - --col-highlight-1: #fff1; - --col-highlight-2: #fff2; - - --col-new: #b3ee5b; - --col-on-new: #1b2013; - --col-save: #6fd7ff; - --col-on-save: #283f48; - --col-delete: #ff7171; - --col-on-delete: #371919; - - --col-warn: #ffe86a; - --col-on-warn: var(--bg-0); - --col-warn-hover: #ffec81; - - --shadow-sm: - 0 1px 2px var(--col-shadow-2), - inset 0 1px 1px var(--col-highlight-2); - --shadow-md: - 0 2px 4px var(--col-shadow-1), - inset 0 2px 2px var(--col-highlight-1); - --shadow-lg: - 0 4px 8px var(--col-shadow-0), - inset 0 4px 4px var(--col-highlight-0); -} - -@media (prefers-color-scheme: light) { - :root { - --bg-0: #e8e8e8; - --bg-1: #f0f0f0; - --bg-2: #f8f8f8; - --bg-3: #ffffff; - - --fg-0: #606060; - --fg-1: #404040; - --fg-2: #303030; - --fg-3: #202020; - - --col-shadow-0: #0002; - --col-shadow-1: #0004; - --col-shadow-2: #0008; - --col-highlight-0: #fff2; - --col-highlight-1: #fff4; - --col-highlight-2: #fff8; - - --col-warn: #ffe86a; - --col-on-warn: var(--fg-3); - --col-warn-hover: #ffec81; - } -} - -@media (prefers-color-scheme: green) { - :root { - --bg-0: #d0d9c7; - --bg-1: #e2e5de; - --bg-2: #f1f1f1; - --bg-3: #ffffff; - --fg-0: #626f54; - --fg-1: #505c43; - --fg-2: #49523e; - --fg-3: #2c3522; - } -} - -@media (prefers-color-scheme: purple) { - :root { - --bg-0: #15121c; - --bg-1: #1e1a27; - --bg-2: #302a3d; - --bg-3: #4a4358; - --fg-0: #9e8fbf; - --fg-1: #a29bb3; - --fg-2: #b9b0cd; - --fg-3: #dcd5ec; - } -} - -@media (prefers-color-scheme: dark) { - img.icon { - -webkit-filter: invert(.9); - filter: invert(.9); - } -} - body { - width: calc(100% - 180px); + width: 100%; height: calc(100vh - 1em); - margin: 0 0 0 180px; + margin: 0; padding: 0; - display: flex; - flex-direction: row; font-family: "Inter", sans-serif; font-size: 16px; - color: var(--fg-0); - background: var(--bg-0); - transition: background .1s ease-out, color .1s ease-out; + color: #303030; + background: #f0f0f0; } -h1, h2, h3, h4, h5, h6 { - color: var(--fg-3); -} - -header { - display: flex; - justify-content: space-between; - align-items: center; -} nav { - position: fixed; - top: 0; - left: 0; - width: 180px; - height: calc(100vh - 2em); - margin: 0; - padding: 1em 0; + width: min(720px, calc(100% - 2em)); + height: 2em; + margin: 1em auto; display: flex; - flex-direction: column; + flex-direction: row; justify-content: left; - background-color: var(--bg-1); - box-shadow: var(--shadow-md); - transition: background .1s ease-out, color .1s ease-out; - - user-select: none; + background: #f8f8f8; + border-radius: .5em; + border: 1px solid #808080; } nav .icon { - width: fit-content; - height: fit-content; - padding: 0; - margin: 0 auto 1em auto; + height: 100%; +} +nav .title { + width: auto; + height: 100%; + + margin: 0 1em 0 0; + display: flex; - border-radius: 100%; - box-shadow: var(--shadow-sm); - overflow: clip; -} -nav .icon img { - width: 3em; - height: 3em; + line-height: 2em; + text-decoration: none; + + color: inherit; } .nav-item { + width: auto; + height: 100%; + + margin: 0px; + padding: 0 1em; + display: flex; - color: var(--fg-2); + line-height: 2em; - font-weight: 500; - transition: color .1s ease-out, background-color .1s ease-out; } .nav-item:hover { - color: var(--bg-2); - background-color: var(--fg-2); + background: #00000010; text-decoration: none; } -.nav-item.active { - border-left: 4px solid var(--fg-2); -} -.nav-item.active a { - padding-left: calc(1em - 3.5px); -} nav a { - padding: .2em 1em; text-decoration: none; color: inherit; - width: 100%; } -nav a.active { - border-left: 5px solid var(--fg-0); - padding-left: calc(1em - 5px); -} -nav hr { - width: calc(100% - 2em); - margin: .5em auto; - border: none; - border-bottom: 1px solid var(--fg-0); -} -nav .section-label { - margin: .6em 0 .1em 1.6em; - font-size: .6em; - text-transform: uppercase; - font-weight: 600; -} -#toggle-nav { - position: fixed; - top: 16px; - left: 16px; - padding: 8px; - width: 48px; - height: 48px; - display: none; - justify-content: center; - align-items: center; - z-index: 1; -} -#toggle-nav img { - width: 100%; - height: 100%; - object-fit: cover; - transform: translate(1px, 1px); -} -#toggle-nav img:hover { - -webkit-filter: invert(.9); - filter: invert(.9); -} -@media (prefers-color-scheme: dark) { - #toggle-nav img { - -webkit-filter: invert(.9); - filter: invert(.9); - } - #toggle-nav img:hover { - -webkit-filter: none; - filter: none; - } +nav #logout { + /* margin-left: auto; */ } main { - width: 720px; - max-width: calc(100% - 2em); - height: fit-content; - min-height: calc(100vh - 2em); + width: min(720px, calc(100% - 2em)); margin: 0 auto; padding: 1em; } -main.dashboard { - width: 100%; -} a { color: inherit; text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color .1s ease-out, background-color .1s ease-out; } -/* a:hover { text-decoration: underline; } -*/ -img.icon { +a img.icon { height: .8em; - transition: filter .1s ease-out; -} - -code { - background: #303030; - color: #f0f0f0; - padding: .23em .3em; - border-radius: 4px; -} - - - -.cards { - width: 100%; - height: fit-content; - display: flex; - gap: 2em; - flex-wrap: wrap; } .card { - flex-basis: 40em; - padding: 1em; - background: var(--bg-1); - border-radius: 16px; - box-shadow: var(--shadow-lg); - - transition: background .1s ease-out, color .1s ease-out; -} -main:not(.dashboard) .card { margin-bottom: 1em; } @@ -297,7 +93,14 @@ main:not(.dashboard) .card { margin: 0 0 .5em 0; } -.card-header { +/* +.card h3, +.card p { + margin: 0; +} +*/ + +.card-title { margin-bottom: 1em; display: flex; gap: 1em; @@ -305,184 +108,81 @@ main:not(.dashboard) .card { align-items: center; justify-content: space-between; } -.card-header h1, -.card-header h2, -.card-header h3 { - margin: 0; -} -.card-header a:hover { - text-decoration: underline; -} -header :is(h1, h2, h3) small, -.card-header :is(h1, h2, h3) small { - display: inline-block; - font-size: .6em; - transform: translateY(-0.1em); - color: var(--fg-0); +.card-title h1, +.card-title h2, +.card-title h3 { + margin: 0; } .flex-fill { flex-grow: 1; } -.artists-group { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 1em; +@media screen and (max-width: 520px) { + body { + font-size: 12px; + } } -#message, -#error { - margin: 0 0 1em 0; - padding: 1em; - border-radius: 8px; - color: #101010; - background: #ffffff; -} -#message { - background: #a9dfff; -} #error { background: #ffa9b8; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; } -a.delete:not(.button) { - color: #d22828; -} - -.button, button { +button, .button { padding: .5em .8em; font-family: inherit; font-size: inherit; - + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; color: inherit; - background: var(--bg-2); - border: none; - border-radius: 10em; - box-shadow: var(--shadow-sm); - font-weight: 500; - transition: background .1s ease-out, color .1s ease-out; - - cursor: pointer; - user-select: none; } button:hover, .button:hover { background: #fff; + border-color: #d0d0d0; } button:active, .button:active { background: #d0d0d0; + border-color: #808080; } -.button.new, button.new { - color: var(--col-on-new); - background: var(--col-new); -} -.button.save, button.save { - color: var(--col-on-save); - background: var(--col-save); -} -.button.delete, button.delete { - color: var(--col-on-delete); - background: var(--col-delete); -} -.button:hover, button:hover { - color: var(--bg-3); - background: var(--fg-3); -} -.button:active, button:active { - color: var(--bg-2); - background: var(--fg-0); -} -.button[disabled], button[disabled] { - color: var(--fg-0) !important; - background: var(--bg-3) !important; - opacity: .5; - cursor: default !important; -} - - - -form { - width: 100%; - display: block; - color: var(--fg-0); -} -form label { - width: 100%; - margin: 1rem 0 .5rem 0; - display: block; -} -form input[type="text"], -form input[type="password"] { - width: 16em; - max-width: 100%; - margin: .5em 0; - padding: .3em .5em; - display: block; - border-radius: 4px; - border: 1px solid #808080; - font-size: inherit; - font-family: inherit; +button { color: inherit; - background-color: var(--bg-0); } -input[disabled] { +button.new { + background: #c4ff6a; + border-color: #84b141; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; opacity: .5; - cursor: not-allowed; + cursor: not-allowed !important; } - -@media screen and (max-width: 720px) { - main { - padding-top: 0; - } - - body { - width: 100%; - margin: 0; - font-size: 16px; - } - - nav { - width: 100%; - left: -100%; - font-size: 24px; - z-index: 1; - transition: transform .2s cubic-bezier(.2,0,.5,1); - } - nav.open { - transform: translateX(100%); - } - - #toggle-nav { - display: flex; - } - - main > h1:first-of-type { - margin-left: 68px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - main > header { - margin-left: 68px; - } - main > header h1 { - display: flex; - flex-direction: column; - font-size: 1.5em; - } - - .card { - flex-basis: 100%; - max-width: calc(100vw - 4em); - } - - .artists-group { - grid-template-columns: repeat(3, 1fr); - } +a.delete { + color: #d22828; } diff --git a/admin/static/admin.js b/admin/static/admin.js index 8bf2480..0763ab7 100644 --- a/admin/static/admin.js +++ b/admin/static/admin.js @@ -69,28 +69,3 @@ export function makeMagicList(container, itemSelector, callback) { if (callback) callback(); }); } - -export function hijackClickEvent(container, link) { - container.addEventListener('click', event => { - if (event.target.tagName.toLowerCase() === 'a') return; - event.preventDefault(); - link.dispatchEvent(new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - button: event.button, - })); - }); -} - -document.addEventListener("readystatechange", () => { - const navbar = document.getElementById("navbar"); - document.getElementById("toggle-nav").addEventListener("click", event => { - event.preventDefault(); - navbar.classList.toggle("open"); - }) -}); diff --git a/admin/static/artists.css b/admin/static/artists.css deleted file mode 100644 index 516a998..0000000 --- a/admin/static/artists.css +++ /dev/null @@ -1,27 +0,0 @@ -.artist { - padding: .5em; - - color: var(--fg-3); - background: var(--bg-2); - box-shadow: var(--shadow-md); - border-radius: 16px; - text-align: center; - - cursor: pointer; - transition: background .1s ease-out, color .1s ease-out; -} - -.artist:hover { - background: var(--bg-1); - text-decoration: hover; -} - -.artist .artist-avatar { - width: 100%; - object-fit: cover; - border-radius: 8px; -} - -.artist .artist-name { - display: block; -} diff --git a/admin/static/artists.js b/admin/static/artists.js deleted file mode 100644 index 29eab22..0000000 --- a/admin/static/artists.js +++ /dev/null @@ -1,7 +0,0 @@ -import { hijackClickEvent } from "./admin.js"; - -document.addEventListener("readystatechange", () => { - document.querySelectorAll(".artists-group .artist").forEach(el => { - hijackClickEvent(el, el.querySelector("a.artist-name")) - }); -}); diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index c43d6e9..625db13 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -1,21 +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; } -form#change-password input, -form#delete-account input { - width: 20em; - min-width: auto; - max-width: calc(100% - 1em - 2px); +input { + width: 100%; margin: .5rem 0; padding: .3rem .5rem; display: block; @@ -26,16 +33,21 @@ form#delete-account 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; margin-bottom: .5em; display: flex; justify-content: space-between; - - color: var(--fg-3); - background: var(--bg-2); - box-shadow: var(--shadow-md); - border-radius: 16px; } .mfa-device div { @@ -51,14 +63,3 @@ form#delete-account input { .mfa-device .mfa-device-name { font-weight: bold; } - -.mfa-device form input { - display: none !important; -} - -.mfa-actions { - display: flex; - flex-direction: row; - gap: .5em; - flex-wrap: wrap; -} diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 7bf146b..793b989 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -1,3 +1,7 @@ +h1 { + margin: 0 0 1em 0; +} + #artist { margin-bottom: 1em; padding: 1.5em; @@ -5,9 +9,9 @@ flex-direction: row; gap: 1.2em; - border-radius: 16px; - background: var(--bg-2); - box-shadow: var(--shadow-md); + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; } .artist-avatar { @@ -23,8 +27,7 @@ cursor: pointer; } .artist-avatar #remove-avatar { - margin-top: .5em; - padding: .3em .6em; + padding: .3em .4em; } .artist-info { @@ -50,8 +53,8 @@ input[type="text"] { font-family: inherit; font-weight: inherit; color: inherit; - background: var(--bg-0); - border: none; + background: #ffffff; + border: 1px solid transparent; border-radius: 4px; outline: none; } @@ -71,7 +74,7 @@ input[type="text"]:focus { justify-content: right; } -.card-header a.button { +.card-title a.button { text-decoration: none; } @@ -82,17 +85,9 @@ input[type="text"]:focus { flex-direction: row; gap: 1em; align-items: center; - - border-radius: 16px; - background: var(--bg-2); - box-shadow: var(--shadow-md); - - cursor: pointer; - transition: background .1s; -} - -.credit:hover { - background: var(--bg-1); + background: #f8f8f8; + border-radius: 8px; + border: 1px solid #808080; } .release-artwork { @@ -101,14 +96,8 @@ input[type="text"]:focus { border-radius: 4px; } -.credit-info { - overflow: hidden; -} .credit-info h3, .credit-info p { margin: 0; font-size: .9em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } diff --git a/admin/static/edit-artist.js b/admin/static/edit-artist.js index 069c25d..8f1bb2b 100644 --- a/admin/static/edit-artist.js +++ b/admin/static/edit-artist.js @@ -1,5 +1,3 @@ -import { hijackClickEvent } from "./admin.js"; - const artistID = document.getElementById("artist").dataset.id; const nameInput = document.getElementById("name"); const avatarImg = document.getElementById("avatar"); @@ -79,9 +77,3 @@ removeAvatarBtn.addEventListener("click", () => { avatarImg.src = "/img/default-avatar.png" saveBtn.disabled = false; }); - -document.addEventListener('readystatechange', () => { - document.querySelectorAll('#releases .credit').forEach(el => { - hijackClickEvent(el, el.querySelector('.credit-name a')); - }); -}); diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 434b487..10eada3 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -11,11 +11,9 @@ input[type="text"] { flex-direction: row; gap: 1.2em; - border-radius: 8px; - background: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: background .1s ease-out, color .1s ease-out; + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; } .release-artwork { @@ -31,9 +29,7 @@ input[type="text"] { cursor: pointer; } .release-artwork #remove-artwork { - margin-top: .5em; - padding: .3em .6em; - background: var(--bg-3); + padding: .3em .4em; } .release-info { @@ -58,17 +54,17 @@ input[type="text"] { background: transparent; outline: none; cursor: pointer; - transition: background .1s ease-out, border-color .1s ease-out; } #title:hover { - background: var(--bg-3); - border-color: var(--fg-0); + background: #ffffff; + border-color: #80808080; } #title:active, #title:focus { - background: var(--bg-3); + background: #ffffff; + border-color: #808080; } .release-title small { @@ -79,21 +75,19 @@ input[type="text"] { width: 100%; margin: .5em 0; border-collapse: collapse; - color: var(--fg-2); } .release-info table td { padding: .2em; - border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent); - transition: background .1s ease-out, border-color .1s ease-out; + border-bottom: 1px solid #d0d0d0; } .release-info table tr td:first-child { vertical-align: top; - opacity: .5; + opacity: .66; } .release-info table tr td:not(:first-child) select:hover, .release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) textarea:hover { - background: var(--bg-3); + background: #e8e8e8; cursor: pointer; } .release-info table td select, @@ -121,25 +115,13 @@ input[type="text"] { gap: .5em; flex-direction: row; justify-content: right; - color: var(--fg-3); -} - -.release-actions button, -.release-actions .button { - color: var(--fg-2); - background: var(--bg-3); } dialog { width: min(720px, calc(100% - 2em)); padding: 2em; - border: none; - border-radius: 16px; - color: var(--fg-0); - background-color: var(--bg-0); - box-shadow: var(--shadow-lg); - - transition: color .1s ease-out, background-color .1s ease-out; + border: 1px solid #101010; + border-radius: 8px; } dialog header { @@ -162,7 +144,7 @@ dialog div.dialog-actions { gap: .5em; } -.card-header a.button { +.card-title a.button { text-decoration: none; } @@ -170,7 +152,7 @@ dialog div.dialog-actions { * RELEASE CREDITS */ -#credits .credit { +.card.credits .credit { margin-bottom: .5em; padding: .5em; display: flex; @@ -178,47 +160,28 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 16px; - background-color: var(--bg-2); - box-shadow: var(--shadow-md); - - cursor: pointer; - transition: background .1s ease-out; -} -#credits .credit:hover { - background-color: var(--bg-1); + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; } -#credits .credit p { +.card.credits .credit p { margin: 0; } -#credits .credit .artist-avatar { - border-radius: 12px; +.card.credits .credit .artist-avatar { + border-radius: .5em; } -#credits .credit .artist-name { - color: var(--fg-3); +.card.credits .credit .artist-name { font-weight: bold; } -#credits .credit .artist-role small { +.card.credits .credit .artist-role small { font-size: inherit; opacity: .66; } -#credits .credit .credit-info { - overflow: hidden; -} - -#credits .credit .credit-info :is(h3, p) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - - - #editcredits ul { margin: 0; padding: 0; @@ -233,9 +196,9 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; - background: var(--bg-2); - box-shadow: var(--shadow-md); + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; } #editcredits .credit { @@ -252,7 +215,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: 8px; + border-radius: .5em; } #editcredits .credit .credit-info { @@ -265,29 +228,21 @@ dialog div.dialog-actions { } #editcredits .credit .credit-info .credit-attribute label { - width: auto; - margin: 0; display: flex; align-items: center; - color: inherit; } #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; - border: none; + border: 1px solid #8888; border-radius: 4px; - color: var(--fg-2); - background: var(--bg-0); -} -#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { - margin: 0 .3em; + color: inherit; } #editcredits .credit .artist-name { - color: var(--fg-2); font-weight: bold; } @@ -296,12 +251,8 @@ dialog div.dialog-actions { opacity: .66; } -#editcredits .credit .delete { - margin-right: .5em; - cursor: pointer; -} -#editcredits .credit .delete:hover { - text-decoration: underline; +#editcredits .credit button.delete { + margin-left: auto; } #addcredit ul { @@ -333,39 +284,24 @@ dialog div.dialog-actions { * RELEASE LINKS */ -#links ul { - padding: 0; +.card.links { display: flex; gap: .2em; } -#links a img.icon { - -webkit-filter: none; - filter: none; -} - -#links a.button:hover { - color: var(--bg-3) !important; - background-color: var(--fg-3) !important; -} - -#links a.button[data-name="spotify"] { - color: #101010; +.card.links a.button[data-name="spotify"] { background-color: #8cff83 } -#links a.button[data-name="apple music"] { - color: #101010; +.card.links a.button[data-name="apple music"] { background-color: #8cd9ff } -#links a.button[data-name="soundcloud"] { - color: #101010; +.card.links a.button[data-name="soundcloud"] { background-color: #fdaa6d } -#links a.button[data-name="youtube"] { - color: #101010; +.card.links a.button[data-name="youtube"] { background-color: #ff6e6e } @@ -433,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; @@ -448,6 +382,62 @@ dialog div.dialog-actions { outline: 1px solid #808080; } +/* + * RELEASE TRACKS + */ + +.card.tracks .track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.card.tracks .track h3, +.card.tracks .track p { + margin: 0; +} + +.card.tracks h2.track-title { + margin: 0; + display: flex; + gap: .5em; +} + +.card.tracks h2.track-title .track-number { + opacity: .5; +} + +.card.tracks .track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +.card.tracks .track-album.empty { + color: #ff2020; + opacity: 1; +} + +.card.tracks .track-description { + font-style: italic; +} + +.card.tracks .track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +.card.tracks .track .empty { + opacity: 0.75; +} + #edittracks ul { padding: 0; list-style: none; @@ -499,17 +489,15 @@ dialog div.dialog-actions { padding: .5em; display: flex; gap: .5em; - background-color: var(--bg-0); cursor: pointer; - transition: background-color .1s ease-out, color .1s ease-out; } #addtrack ul li.new-track:nth-child(even) { - background: color-mix(in srgb, var(--bg-0) 95%, #fff); + background: #f0f0f0; } #addtrack ul li.new-track:hover { - background: color-mix(in srgb, var(--bg-0) 90%, #fff); + background: #e0e0e0; } @media only screen and (max-width: 1105px) { diff --git a/admin/static/edit-release.js b/admin/static/edit-release.js index ca2754f..11d21f0 100644 --- a/admin/static/edit-release.js +++ b/admin/static/edit-release.js @@ -1,5 +1,3 @@ -import { hijackClickEvent } from "./admin.js"; - const releaseID = document.getElementById("release").dataset.id; const titleInput = document.getElementById("title"); const artworkImg = document.getElementById("artwork"); @@ -98,9 +96,3 @@ removeArtworkBtn.addEventListener("click", () => { artworkData = ""; saveBtn.disabled = false; }); - -document.addEventListener("readystatechange", () => { - document.querySelectorAll("#credits .credit").forEach(el => { - hijackClickEvent(el, el.querySelector(".artist-name a")); - }); -}); diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index f292ca5..8a05089 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -1,5 +1,9 @@ @import url("/admin/static/release-list-item.css"); +h1 { + margin: 0 0 .5em 0; +} + #track { margin-bottom: 1em; padding: .5em 1.5em 1.5em 1.5em; @@ -7,9 +11,9 @@ flex-direction: row; gap: 1.2em; - border-radius: 16px; - background: var(--bg-2); - box-shadow: var(--shadow-md); + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; } .track-info { @@ -45,8 +49,7 @@ font-weight: inherit; font-family: inherit; font-size: inherit; - background: var(--bg-0); - border: none; + border: 1px solid transparent; border-radius: 4px; outline: none; color: inherit; diff --git a/admin/static/index.css b/admin/static/index.css new file mode 100644 index 0000000..9d38940 --- /dev/null +++ b/admin/static/index.css @@ -0,0 +1,100 @@ +@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; + display: flex; + flex-direction: row; + align-items: center; + gap: .5em; + + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.artist:hover { + text-decoration: hover; +} + +.artist-avatar { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 100%; +} + +.track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.track p { + margin: 0; +} + +.card h2.track-title { + margin: 0; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.track-id { + width: fit-content; + font-family: "Monaspace Argon", monospace; + font-size: .8em; + font-style: italic; + line-height: 1em; + user-select: all; +} + +.track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +.track-album.empty { + color: #ff2020; + opacity: 1; +} + +.track-description { + font-style: italic; +} + +.track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +.track .empty { + opacity: 0.75; +} diff --git a/admin/static/index.js b/admin/static/index.js index 60bdfd0..e251802 100644 --- a/admin/static/index.js +++ b/admin/static/index.js @@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({id}) }).then(res => { - if (res.ok) location = "/admin/releases/" + id; + if (res.ok) location = "/admin/release/" + id; else { res.text().then(err => { alert("Request failed: " + err); @@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/artists/" + id; + location = "/admin/artist/" + id; } else { alert("Request failed: " + text); console.error(text); @@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/tracks/" + text; + location = "/admin/track/" + text; } else { alert("Request failed: " + text); console.error(text); diff --git a/admin/static/logs.css b/admin/static/logs.css deleted file mode 100644 index 8da60d0..0000000 --- a/admin/static/logs.css +++ /dev/null @@ -1,107 +0,0 @@ -main { - width: min(1080px, calc(100% - 2em))!important -} - -form#search-form { - width: calc(100% - 2em); - margin: 1em 0; - padding: 1em; - border-radius: 16px; - color: var(--fg-0); - background: var(--bg-2); - box-shadow: var(--shadow-md); -} - -div#search { - display: flex; -} - -#search input { - margin: 0; - padding: .3em .8em; - flex-grow: 1; - border: none; - border-radius: 16px; - color: var(--fg-1); - background: var(--bg-0); - box-shadow: var(--shadow-sm); -} - -#search button { - margin-left: .5em; - padding: 0 .8em; -} - -form #filters p { - margin: .5em 0 0 0; -} -form #filters label { - color: inherit; - display: inline; -} -form #filters input { - margin-right: 1em; - display: inline; -} - -#logs { - width: 100%; - overflow: scroll; -} -@media screen and (max-width: 720px) { - #logs { - font-size: 12px; - } -} - -#logs table{ - 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; -} - -#logs .log { - color: var(--fg-2); -} - -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; -} - -#logs .log:hover { - background: color-mix(in srgb, var(--fg-3) 10%, transparent); -} - -#logs .log.warn { - color: var(--col-on-warn); - background: var(--col-warn); -} -#logs .log.warn:hover { - background: var(--col-warn-hover); -} diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css new file mode 100644 index 0000000..ee67de7 --- /dev/null +++ b/admin/static/release-list-item.css @@ -0,0 +1,87 @@ +.release { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: row; + gap: 1em; + + border-radius: .5em; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.release h3, +.release p { + margin: 0; +} + +.release-artwork { + width: 96px; + + display: flex; + justify-content: center; + align-items: center; +} + +.release-artwork img { + width: 100%; + aspect-ratio: 1; +} + +.release-title small { + opacity: .75; +} + +.release-links { + margin: .5em 0; + padding: 0; + display: flex; + flex-direction: row; + list-style: none; + flex-wrap: wrap; + gap: .5em; +} + +.release-links li { + flex-grow: 1; +} + +.release-links a { + padding: .5em; + display: block; + + border-radius: .5em; + text-decoration: none; + color: #f0f0f0; + background: #303030; + text-align: center; + + transition: color .1s, background .1s; +} + +.release-links a:hover { + color: #303030; + background: #f0f0f0; +} + +.release-actions { + margin-top: .5em; +} + +.release-actions a { + margin-right: .3em; + padding: .3em .5em; + display: inline-block; + + border-radius: .3em; + background: #e0e0e0; + + transition: color .1s, background .1s; +} + +.release-actions a:hover { + color: #303030; + background: #f0f0f0; + + text-decoration: none; +} diff --git a/admin/static/releases.css b/admin/static/releases.css deleted file mode 100644 index 0694875..0000000 --- a/admin/static/releases.css +++ /dev/null @@ -1,80 +0,0 @@ -.release { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: row; - gap: 1em; - - border-radius: 16px; - background: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: background .1s ease-out, color .1s ease-out; -} - -.release h3, -.release p { - margin: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.release .release-artwork { - margin: auto 0; - width: 96px; - - display: flex; - justify-content: center; - align-items: center; - border-radius: 4px; - overflow: hidden; - box-shadow: var(--shadow-sm); -} - -.release .release-artwork img { - width: 100%; - aspect-ratio: 1; -} - -.release .release-info { - max-width: calc(100% - 96px - 1em); -} - -.release .release-title small { - opacity: .75; -} - -.release .release-links { - margin: .5em 0; - padding: 0; - display: flex; - flex-direction: row; - list-style: none; - flex-wrap: wrap; - gap: .5em; -} - -.release .release-actions { - margin-top: .5em; - user-select: none; - color: var(--fg-3); -} - -.release .release-actions a { - margin-right: .3em; - padding: .3em .5em; - display: inline-block; - - border-radius: 4px; - background: var(--bg-3); - box-shadow: var(--shadow-sm); - - transition: color .1s ease-out, background .1s ease-out; -} - -.release .release-actions a:hover { - background: var(--bg-0); - color: var(--fg-3); - text-decoration: none; -} diff --git a/admin/static/tracks.css b/admin/static/tracks.css deleted file mode 100644 index c36c1b1..0000000 --- a/admin/static/tracks.css +++ /dev/null @@ -1,127 +0,0 @@ -#tracks h2.track-title { - margin: 0; - display: flex; - gap: .5em; -} - -#tracks .track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 16px; - background: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: background .1s ease-out, color .1s ease-out; -} - -#tracks .track h3, -#tracks .track p { - margin: 0; -} - -#tracks h2.track-title { - margin: 0; - display: flex; - gap: .5em; -} - -#tracks h2.track-title .track-number { - opacity: .5; -} - -#tracks a:hover { - text-decoration: underline; -} - -#tracks .track-album { - margin-left: auto; - font-style: italic; - font-size: .75em; - opacity: .5; -} - -#tracks .track-album.empty { - color: #ff2020; - opacity: 1; -} - -#tracks .track-description { - font-style: italic; -} - -#tracks .track-lyrics { - max-height: 10em; - overflow-y: scroll; -} - -#tracks .track .empty { - opacity: 0.75; -} - - -.card h2.track-title { - margin: 0; - display: flex; - flex-direction: row; - /* - justify-content: space-between; - */ -} - -/* -.track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 8px; - background-color: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: color .1s ease-out, background-color .1s ease-out; -} - -.track p { - margin: 0; -} - -.track-id { - width: fit-content; - font-family: "Monaspace Argon", monospace; - font-size: .8em; - font-style: italic; - line-height: 1em; - user-select: all; -} - -.track-album { - margin-left: auto; - font-style: italic; - font-size: .75em; - opacity: .5; -} - -.track-album.empty { - color: #ff2020; - opacity: 1; -} - -.track-description { - font-style: italic; -} - -.track-lyrics { - max-height: 10em; - overflow-y: scroll; -} - -.track .empty { - opacity: 0.75; -} -*/ diff --git a/admin/templates.go b/admin/templates.go new file mode 100644 index 0000000..1fa7a65 --- /dev/null +++ b/admin/templates.go @@ -0,0 +1,65 @@ +package admin + +import ( + "html/template" + "path/filepath" +) + +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"), + )), + + "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"), + )), + + "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 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"))), + + "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.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/templates/html/artists.html b/admin/templates/html/artists.html deleted file mode 100644 index f4a6432..0000000 --- a/admin/templates/html/artists.html +++ /dev/null @@ -1,26 +0,0 @@ -{{define "head"}} -Artists - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        -
        -

        Artists ({{len .Artists}} total)

        - Create New -
        - - {{if .Artists}} -
        - {{range .Artists}} - {{block "artist" .}}{{end}} - {{end}} -
        - {{else}} -

        There are no artists.

        - {{end}} -
        - - -{{end}} diff --git a/admin/templates/html/components/artist/artist.html b/admin/templates/html/components/artist/artist.html deleted file mode 100644 index 86ac4cc..0000000 --- a/admin/templates/html/components/artist/artist.html +++ /dev/null @@ -1,6 +0,0 @@ -{{define "artist"}} -
        - - {{.Name}} -
        -{{end}} diff --git a/admin/templates/html/components/track/track.html b/admin/templates/html/components/track/track.html deleted file mode 100644 index 4db20a3..0000000 --- a/admin/templates/html/components/track/track.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "track"}} -
        -

        - {{if .Number}} - {{.Number}} - {{end}} - {{.Title}} -

        - -

        Description

        - {{if .Description}} -

        {{.GetDescriptionHTML}}

        - {{else}} -

        No description provided.

        - {{end}} - -

        Lyrics

        - {{if .Lyrics}} -

        {{.GetLyricsHTML}}

        - {{else}} -

        There are no lyrics.

        - {{end}} -
        -{{end}} diff --git a/admin/templates/html/edit-account.html b/admin/templates/html/edit-account.html deleted file mode 100644 index a63d37a..0000000 --- a/admin/templates/html/edit-account.html +++ /dev/null @@ -1,91 +0,0 @@ -{{define "head"}} -Account Settings - ari melody 💫 - - -{{end}} - -{{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}})

        - -
        -
        -

        Change Password

        -
        - - - - - - - - - - -
        - - - -
        - -
        -
        -

        MFA Devices

        -
        - {{if .TOTPs}} - {{range .TOTPs}} -
        -
        -

        {{.TOTP.Name}}

        -

        Added: {{.CreatedAtString}}

        -
        -
        -
        - - -
        -
        -
        - {{end}} - {{else}} -

        You have no MFA devices.

        - {{end}} - -
        - - Add TOTP Device -
        -
        - -
        -
        -

        Danger Zone

        -
        -

        - Clicking the button below will delete your account. - This action is irreversible. - You will need to enter your password and TOTP below. -

        -
        - - - - - - -
        - - -
        -
        - -
        - - -{{end}} diff --git a/admin/templates/html/index.html b/admin/templates/html/index.html deleted file mode 100644 index afb9471..0000000 --- a/admin/templates/html/index.html +++ /dev/null @@ -1,61 +0,0 @@ -{{define "head"}} -Admin - ari melody 💫 - - - - -{{end}} - -{{define "content"}} -
        -

        Dashboard

        - -
        -
        -
        -

        Releases ({{.ReleaseCount}} total)

        - Create New -
        - {{if .Artists}} - {{range .Releases}} - {{block "release" .}}{{end}} - {{end}} - {{else}} -

        There are no releases.

        - {{end}} -
        - -
        -
        -

        Artists ({{.ArtistCount}} total)

        - Create New -
        - {{if .Artists}} -
        - {{range .Artists}} - {{block "artist" .}}{{end}} - {{end}} -
        - {{else}} -

        There are no artists.

        - {{end}} -
        - -
        -
        -

        Tracks ({{.TrackCount}} total)

        - Create New -
        -

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

        -
        - {{range .Tracks}} - {{block "track" .}}{{end}} - {{end}} -
        -
        - -
        - - - -{{end}} diff --git a/admin/templates/html/layout.html b/admin/templates/html/layout.html deleted file mode 100644 index bf1b542..0000000 --- a/admin/templates/html/layout.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - {{block "head" .}}{{end}} - - - - - - - -
        - - -
        - - {{block "content" .}}{{end}} - - {{template "prideflag"}} - - - diff --git a/admin/templates/html/login-totp.html b/admin/templates/html/login-totp.html deleted file mode 100644 index f03d11b..0000000 --- a/admin/templates/html/login-totp.html +++ /dev/null @@ -1,53 +0,0 @@ -{{define "head"}} -Login - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        - {{if .Session.Message.Valid}} -

        {{html .Session.Message.String}}

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

        {{html .Session.Error.String}}

        - {{end}} - -
        -

        Two-Factor Authentication

        - -
        - - -
        - - -
        -
        -{{end}} diff --git a/admin/templates/html/login.html b/admin/templates/html/login.html deleted file mode 100644 index aa77967..0000000 --- a/admin/templates/html/login.html +++ /dev/null @@ -1,56 +0,0 @@ -{{define "head"}} -Login - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        - {{if .Session.Message.Valid}} -

        {{html .Session.Message.String}}

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

        {{html .Session.Error.String}}

        - {{end}} - -
        -

        Log In

        - -
        - - - - - -
        - - -
        -
        -{{end}} diff --git a/admin/templates/html/logs.html b/admin/templates/html/logs.html deleted file mode 100644 index 4f24c72..0000000 --- a/admin/templates/html/logs.html +++ /dev/null @@ -1,69 +0,0 @@ -{{define "head"}} -Audit Logs - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        -

        Audit Logs

        - -
        - -
        -
        -

        Level:

        - - - - -
        -
        -

        Type:

        - - - - - - - - - - - - - - -
        -
        -
        - -
        - -
        - - - - - - - - - - - {{range .Logs}} - - - - - - - {{end}} - -
        TimeLevelTypeMessage
        {{prettyTime .CreatedAt}}{{parseLevel .Level}}{{titleCase .Type}}{{.Content}}
        -
        -
        -{{end}} diff --git a/admin/templates/html/prideflag.html b/admin/templates/html/prideflag.html deleted file mode 100644 index 47ce4c7..0000000 --- a/admin/templates/html/prideflag.html +++ /dev/null @@ -1,21 +0,0 @@ -{{define "prideflag"}} - - - - - - - - - - - - - - - - - - - -{{end}} diff --git a/admin/templates/html/releases.html b/admin/templates/html/releases.html deleted file mode 100644 index ed5aec6..0000000 --- a/admin/templates/html/releases.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "head"}} -Releases - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        -
        -

        Releases ({{len .Releases}} total)

        - Create New -
        - - {{if .Releases}} -
        - {{range .Releases}} - {{block "release" .}}{{end}} - {{end}} -
        - {{else}} -

        There are no releases.

        - {{end}} -
        -{{end}} diff --git a/admin/templates/html/totp-confirm.html b/admin/templates/html/totp-confirm.html deleted file mode 100644 index 459f3fc..0000000 --- a/admin/templates/html/totp-confirm.html +++ /dev/null @@ -1,63 +0,0 @@ -{{define "head"}} -TOTP Confirmation - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        -

        Two-Factor Authentication

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

        {{html .Session.Error.String}}

        - {{end}} - -
        - {{if .QRBase64Image}} - - -

        - Scan the QR code above into your authentication app or password manager, - then enter your 2FA code below. -

        - -

        - If the QR code does not work, you may also enter this secret code: -

        - {{else}} -

        - Paste the below secret code into your authentication app or password manager, - then enter your 2FA code below: -

        - {{end}} - -

        {{.TOTP.Secret}}

        - - - - - -
        -
        -{{end}} diff --git a/admin/templates/html/totp-setup.html b/admin/templates/html/totp-setup.html deleted file mode 100644 index 9fcda9d..0000000 --- a/admin/templates/html/totp-setup.html +++ /dev/null @@ -1,21 +0,0 @@ -{{define "head"}} -TOTP Setup - ari melody 💫 - -{{end}} - -{{define "content"}} -
        -

        Two-Factor Authentication

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

        {{html .Session.Error.String}}

        - {{end}} - -
        - - - - -
        -
        -{{end}} diff --git a/admin/templates/html/tracks.html b/admin/templates/html/tracks.html deleted file mode 100644 index 6bb6f18..0000000 --- a/admin/templates/html/tracks.html +++ /dev/null @@ -1,34 +0,0 @@ -{{define "head"}} -Releases - ari melody 💫 - - -{{end}} - -{{define "content"}} -
        -
        -

        Tracks ({{len .Tracks}} total)

        - Create New -
        - -
        - {{range $Track := .Tracks}} -
        -

        - {{$Track.Title}} -

        - {{if $Track.Description}} -

        {{$Track.GetDescriptionHTML}}

        - {{else}} -

        No description provided.

        - {{end}} - {{if $Track.Lyrics}} -

        {{$Track.GetLyricsHTML}}

        - {{else}} -

        There are no lyrics.

        - {{end}} -
        - {{end}} -
        -
        -{{end}} diff --git a/admin/templates/templates.go b/admin/templates/templates.go deleted file mode 100644 index 51b1376..0000000 --- a/admin/templates/templates.go +++ /dev/null @@ -1,191 +0,0 @@ -package templates - -import ( - "arimelody-web/log" - _ "embed" - "fmt" - "html/template" - "strings" - "time" -) - -//go:embed "html/layout.html" -var layoutHTML string -//go:embed "html/prideflag.html" -var prideflagHTML string - -//go:embed "html/index.html" -var indexHTML string - -//go:embed "html/register.html" -var registerHTML string -//go:embed "html/login.html" -var loginHTML string -//go:embed "html/login-totp.html" -var loginTotpHTML string -//go:embed "html/totp-confirm.html" -var totpConfirmHTML string -//go:embed "html/totp-setup.html" -var totpSetupHTML string -//go:embed "html/logout.html" -var logoutHTML string - -//go:embed "html/logs.html" -var logsHTML string - -//go:embed "html/edit-account.html" -var editAccountHTML string - -//go:embed "html/releases.html" -var releasesHTML string -//go:embed "html/artists.html" -var artistsHTML string -//go:embed "html/tracks.html" -var tracksHTML string - -//go:embed "html/edit-release.html" -var editReleaseHTML string -//go:embed "html/edit-artist.html" -var editArtistHTML string -//go:embed "html/edit-track.html" -var editTrackHTML string - -//go:embed "html/components/credit/newcredit.html" -var componentNewCreditHTML string -//go:embed "html/components/credit/addcredit.html" -var componentAddCreditHTML string -//go:embed "html/components/credit/editcredits.html" -var componentEditCreditsHTML string - -//go:embed "html/components/link/editlinks.html" -var componentEditLinksHTML string - -//go:embed "html/components/release/release.html" -var componentReleaseHTML string -//go:embed "html/components/artist/artist.html" -var componentArtistHTML string -//go:embed "html/components/track/track.html" -var componentTrackHTML string - -//go:embed "html/components/track/newtrack.html" -var componentNewTrackHTML string -//go:embed "html/components/track/addtrack.html" -var componentAddTrackHTML string -//go:embed "html/components/track/edittracks.html" -var componentEditTracksHTML string - -var BaseTemplate = template.Must( - template.New("base").Funcs( - template.FuncMap{ - "hasPrefix": strings.HasPrefix, - }, - ).Parse(strings.Join([]string{ - layoutHTML, - prideflagHTML, - }, "\n"))) - -var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - indexHTML, - componentReleaseHTML, - componentArtistHTML, - componentTrackHTML, - }, "\n"), -)) - - - -var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML)) -var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML)) -var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML)) -var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML)) -var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML)) -var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML)) -var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML)) - - - -var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{ - "parseLevel": parseLevel, - "titleCase": titleCase, - "toLower": toLower, - "prettyTime": prettyTime, -}).Parse(logsHTML)) - - - -var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - releasesHTML, - componentReleaseHTML, - }, "\n"), -)) -var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - artistsHTML, - componentArtistHTML, - }, "\n"), -)) -var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - tracksHTML, - componentTrackHTML, - }, "\n"), -)) - - - -var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - editReleaseHTML, - componentTrackHTML, - }, "\n"), -)) -var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML)) -var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( - strings.Join([]string{ - editTrackHTML, - componentReleaseHTML, - }, "\n"), -)) - - - -var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML)) -var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML)) -var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML)) - -var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML)) - -var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML)) -var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML)) -var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML)) - - - -func parseLevel(level log.LogLevel) string { - switch level { - case log.LEVEL_INFO: - return "INFO" - case log.LEVEL_WARN: - return "WARN" - } - return fmt.Sprintf("%d?", level) -} -func titleCase(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) -} -func toLower(str string) string { - return strings.ToLower(str) -} -func prettyTime(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") -} diff --git a/admin/trackhttp.go b/admin/trackhttp.go index bcb5220..2cea123 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -5,53 +5,18 @@ import ( "net/http" "strings" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/global" + "arimelody-web/model" + "arimelody-web/controller" ) -func serveTracks(app *model.AppState) http.Handler { +func serveTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/") - trackID := slices[0] - - if len(trackID) > 0 { - serveTrack(app, trackID).ServeHTTP(w, r) - return - } - - tracks, err := controller.GetAllTracks(app.DB) + slices := strings.Split(r.URL.Path[1:], "/") + id := slices[0] + track, err := controller.GetTrack(global.DB, id) if err != nil { - fmt.Printf("WARN: Failed to fetch tracks: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - type TracksResponse struct { - adminPageData - Tracks []*model.Track - } - - err = templates.TracksTemplate.Execute(w, TracksResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, - Tracks: tracks, - }) - if err != nil { - fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }) -} - -func serveTrack(app *model.AppState, trackID string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - track, err := controller.GetTrack(app.DB, trackID) - if err != nil { - fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -60,27 +25,30 @@ func serveTrack(app *model.AppState, trackID string) 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("WARN: Failed to fetch releases for track %s: %s\n", trackID, err) + fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type TrackResponse struct { - adminPageData + Account *model.Account Track *model.Track Releases []*model.Release } - err = templates.EditTrackTemplate.Execute(w, TrackResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + account := r.Context().Value("account").(*model.Account) + + err = pages["track"].Execute(w, TrackResponse{ + Account: account, Track: track, Releases: releases, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } + diff --git a/admin/templates/html/register.html b/admin/views/create-account.html similarity index 51% rename from admin/templates/html/register.html rename to admin/views/create-account.html index a6460e0..8d59c0f 100644 --- a/admin/templates/html/register.html +++ b/admin/views/create-account.html @@ -1,6 +1,7 @@ {{define "head"}} Register - ari melody 💫 + {{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 new file mode 100644 index 0000000..4d89052 --- /dev/null +++ b/admin/views/edit-account.html @@ -0,0 +1,69 @@ +{{define "head"}} +Account Settings - ari melody 💫 + + +{{end}} + +{{define "content"}} +
        +

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

        + +
        +

        Change Password

        +
        +
        + +
        + + + + + + + + +
        + + + +
        + +
        +

        MFA Devices

        +
        +
        + {{if .TOTPs}} + {{range .TOTPs}} +
        +
        +

        {{.Name}}

        +

        Added: {{.CreatedAt}}

        +
        +
        + Delete +
        +
        + {{end}} + {{else}} +

        You have no MFA devices.

        + {{end}} + + +
        + +
        +

        Danger Zone

        +
        +
        +

        + Clicking the button below will delete your account. + This action is irreversible. + You will be prompted to confirm this decision. +

        + +
        + +
        + + +{{end}} diff --git a/admin/templates/html/edit-artist.html b/admin/views/edit-artist.html similarity index 74% rename from admin/templates/html/edit-artist.html rename to admin/views/edit-artist.html index e9b829a..ccb3a45 100644 --- a/admin/templates/html/edit-artist.html +++ b/admin/views/edit-artist.html @@ -2,7 +2,6 @@ Editing {{.Artist.Name}} - ari melody 💫 - {{end}} {{define "content"}} @@ -30,20 +29,20 @@
        -
        -
        -

        Featured in

        -
        +
        +

        Featured in

        +
        +
        {{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}}

        @@ -55,10 +54,10 @@ {{end}}
        -
        -
        -

        Danger Zone

        -
        +
        +

        Danger Zone

        +
        +

        Clicking the button below will delete this artist. This action is irreversible. diff --git a/admin/templates/html/edit-release.html b/admin/views/edit-release.html similarity index 70% rename from admin/templates/html/edit-release.html rename to admin/views/edit-release.html index 6b47f75..02447e1 100644 --- a/admin/templates/html/edit-release.html +++ b/admin/views/edit-release.html @@ -2,13 +2,10 @@ Editing {{.Release.Title}} - ari melody 💫 - - {{end}} {{define "content"}}

        -

        Editing {{.Release.Title}}

        @@ -100,21 +97,21 @@
        -
        -
        -

        Credits ({{len .Release.Credits}} total)

        - Edit -
        +
        +

        Credits ({{len .Release.Credits}})

        + Edit +
        +
        {{range .Release.Credits}}
        -

        {{.Artist.Name}}

        +

        {{.Artist.Name}}

        {{.Role}} {{if .Primary}} @@ -129,42 +126,59 @@ {{end}}

        -
        diff --git a/views/prideflag.html b/views/prideflag.html new file mode 100644 index 0000000..5e25f70 --- /dev/null +++ b/views/prideflag.html @@ -0,0 +1,21 @@ +{{define "prideflag"}} + + + + + + + + + + + + + + + + + + + +{{end}}