refactor: move music admin to /admin/music; keep /admin generic
This commit is contained in:
parent
ddbf3444eb
commit
7293c672e2
31 changed files with 1081 additions and 906 deletions
|
@ -1,22 +1,25 @@
|
||||||
package admin
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/admin/templates"
|
||||||
"arimelody-web/log"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"arimelody-web/log"
|
||||||
|
"arimelody-web/model"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func accountHandler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.Handle("/", accountIndexHandler(app))
|
||||||
|
|
||||||
mux.Handle("/totp-setup", totpSetupHandler(app))
|
mux.Handle("/totp-setup", totpSetupHandler(app))
|
||||||
mux.Handle("/totp-confirm", totpConfirmHandler(app))
|
mux.Handle("/totp-confirm", totpConfirmHandler(app))
|
||||||
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
|
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
|
||||||
|
@ -64,7 +67,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
||||||
session.Message = sessionMessage
|
session.Message = sessionMessage
|
||||||
session.Error = sessionError
|
session.Error = sessionError
|
||||||
|
|
||||||
err = accountTemplate.Execute(w, accountResponse{
|
err = templates.AccountTemplate.Execute(w, accountResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTPs: totps,
|
TOTPs: totps,
|
||||||
})
|
})
|
||||||
|
@ -92,7 +95,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
currentPassword := r.Form.Get("current-password")
|
currentPassword := r.Form.Get("current-password")
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,7 +105,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +114,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,7 +122,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +150,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
|
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))
|
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.")
|
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,7 +158,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,7 +187,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
|
err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -221,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
|
err := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -235,7 +238,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTP: &totp,
|
TOTP: &totp,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
NameEscaped: url.PathEscape(totp.Name),
|
||||||
|
@ -277,7 +280,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
|
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if totp == nil {
|
if totp == nil {
|
||||||
|
@ -296,7 +299,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
||||||
if code != confirmCodeOffset {
|
if code != confirmCodeOffset {
|
||||||
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
||||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTP: totp,
|
TOTP: totp,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
NameEscaped: url.PathEscape(totp.Name),
|
||||||
|
@ -314,7 +317,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -322,7 +325,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
|
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -345,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if totp == nil {
|
if totp == nil {
|
||||||
|
@ -357,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -365,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
|
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account/", http.StatusFound)
|
||||||
})
|
})
|
||||||
}
|
}
|
386
admin/auth/authhttp.go
Normal file
386
admin/auth/authhttp.go
Normal file
|
@ -0,0 +1,386 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"arimelody-web/admin/templates"
|
||||||
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/log"
|
||||||
|
"arimelody-web/model"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterAccountHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
if session.Account != nil {
|
||||||
|
// user is already logged in
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
err := templates.RegisterTemplate.Execute(w, registerData{ Session: session })
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Invite string `json:"invite"`
|
||||||
|
}
|
||||||
|
credentials := RegisterRequest{
|
||||||
|
Username: r.Form.Get("username"),
|
||||||
|
Email: r.Form.Get("email"),
|
||||||
|
Password: r.Form.Get("password"),
|
||||||
|
Invite: r.Form.Get("invite"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure invite code exists in DB
|
||||||
|
invite, err := controller.GetInvite(app.DB, credentials.Invite)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
||||||
|
if invite != nil {
|
||||||
|
err := controller.DeleteInvite(app.DB, invite.Code)
|
||||||
|
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||||
|
}
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid invite code.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account := model.Account{
|
||||||
|
Username: credentials.Username,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Email: sql.NullString{ String: credentials.Email, Valid: true },
|
||||||
|
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
|
||||||
|
}
|
||||||
|
err = controller.CreateAccount(app.DB, &account)
|
||||||
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||||
|
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
|
||||||
|
|
||||||
|
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||||
|
if err != nil {
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registration success!
|
||||||
|
controller.SetSessionAccount(app.DB, session, &account)
|
||||||
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
type loginData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
err := templates.LoginTemplate.Execute(w, loginData{ Session: session })
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
if session.Account != nil {
|
||||||
|
// user is already logged in
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Form.Has("username") || !r.Form.Has("password") {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account == nil {
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account.Locked {
|
||||||
|
controller.SetSessionError(app.DB, session, "This account is locked.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||||
|
if locked := handleFailedLogin(app, account, r); locked {
|
||||||
|
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||||
|
} else {
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(totps) > 0 {
|
||||||
|
err = controller.SetSessionAttemptAccount(app.DB, session, account)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// login success!
|
||||||
|
// TODO: log login activity to user
|
||||||
|
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
||||||
|
|
||||||
|
err = controller.SetSessionAccount(app.DB, session, account)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginTOTPHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
if session.AttemptAccount == nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginTOTPData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseForm()
|
||||||
|
|
||||||
|
if !r.Form.Has("totp") {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totpCode := r.FormValue("totp")
|
||||||
|
|
||||||
|
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totpMethod == nil {
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||||
|
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
|
||||||
|
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||||
|
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
} else {
|
||||||
|
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
|
||||||
|
|
||||||
|
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
|
||||||
|
}
|
||||||
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogoutHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
err := controller.DeleteSession(app.DB, session.Token)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: model.COOKIE_TOKEN,
|
||||||
|
Expires: time.Now(),
|
||||||
|
Path: "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
err = 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 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
|
||||||
|
}
|
|
@ -7,7 +7,7 @@
|
||||||
{{range $Artist := .Artists}}
|
{{range $Artist := .Artists}}
|
||||||
<li class="new-artist"
|
<li class="new-artist"
|
||||||
data-id="{{$Artist.ID}}"
|
data-id="{{$Artist.ID}}"
|
||||||
hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
|
hx-get="/admin/music/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
|
||||||
hx-target="#editcredits ul"
|
hx-target="#editcredits ul"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
<h2>Editing: Credits</h2>
|
<h2>Editing: Credits</h2>
|
||||||
<a id="add-credit"
|
<a id="add-credit"
|
||||||
class="button new"
|
class="button new"
|
||||||
href="/admin/release/{{.ID}}/addcredit"
|
href="/admin/music/release/{{.ID}}/addcredit"
|
||||||
hx-get="/admin/release/{{.ID}}/addcredit"
|
hx-get="/admin/music/release/{{.ID}}/addcredit"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Add</a>
|
>Add</a>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="release-info">
|
<div class="release-info">
|
||||||
<h3 class="release-title">
|
<h3 class="release-title">
|
||||||
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
|
<a href="/admin/music/release/{{.ID}}">{{.Title}}</a>
|
||||||
<small>
|
<small>
|
||||||
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
||||||
{{if not .Visible}}(hidden){{end}}
|
{{if not .Visible}}(hidden){{end}}
|
||||||
|
@ -13,9 +13,9 @@
|
||||||
</h3>
|
</h3>
|
||||||
<p class="release-artists">{{.PrintArtists true true}}</p>
|
<p class="release-artists">{{.PrintArtists true true}}</p>
|
||||||
<p class="release-type-single">{{.ReleaseType}}
|
<p class="release-type-single">{{.ReleaseType}}
|
||||||
(<a href="/admin/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
|
(<a href="/admin/music/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
|
||||||
<div class="release-actions">
|
<div class="release-actions">
|
||||||
<a href="/admin/release/{{.ID}}">Edit</a>
|
<a href="/admin/music/release/{{.ID}}">Edit</a>
|
||||||
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="new-track"
|
<li class="new-track"
|
||||||
data-id="{{$Track.ID}}"
|
data-id="{{$Track.ID}}"
|
||||||
hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
|
hx-get="/admin/music/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
|
||||||
hx-target="#edittracks ul"
|
hx-target="#edittracks ul"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
<h2>Editing: Tracks</h2>
|
<h2>Editing: Tracks</h2>
|
||||||
<a id="add-track"
|
<a id="add-track"
|
||||||
class="button new"
|
class="button new"
|
||||||
href="/admin/release/{{.Release.ID}}/addtrack"
|
href="/admin/music/release/{{.Release.ID}}/addtrack"
|
||||||
hx-get="/admin/release/{{.Release.ID}}/addtrack"
|
hx-get="/admin/music/release/{{.Release.ID}}/addtrack"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Add</a>
|
>Add</a>
|
||||||
|
|
56
admin/core/funcs.go
Normal file
56
admin/core/funcs.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
506
admin/http.go
506
admin/http.go
|
@ -1,57 +1,38 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"fmt"
|
||||||
"database/sql"
|
"net/http"
|
||||||
"fmt"
|
"os"
|
||||||
"net/http"
|
"path/filepath"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/admin/account"
|
||||||
"arimelody-web/log"
|
"arimelody-web/admin/auth"
|
||||||
"arimelody-web/model"
|
"arimelody-web/admin/core"
|
||||||
|
"arimelody-web/admin/logs"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"arimelody-web/admin/music"
|
||||||
|
"arimelody-web/admin/templates"
|
||||||
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/register", auth.RegisterAccountHandler(app))
|
||||||
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")
|
mux.Handle("/login", auth.LoginHandler(app))
|
||||||
if err != nil {
|
mux.Handle("/totp", auth.LoginTOTPHandler(app))
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
|
mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
|
mux.Handle("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app))))
|
||||||
}))
|
mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
|
||||||
|
mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app))))
|
||||||
mux.Handle("/login", loginHandler(app))
|
|
||||||
mux.Handle("/totp", loginTOTPHandler(app))
|
|
||||||
mux.Handle("/logout", requireAccount(logoutHandler(app)))
|
|
||||||
|
|
||||||
mux.Handle("/register", registerAccountHandler(app))
|
|
||||||
|
|
||||||
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
|
|
||||||
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app))))
|
|
||||||
|
|
||||||
mux.Handle("/logs", requireAccount(logsHandler(app)))
|
|
||||||
|
|
||||||
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
|
|
||||||
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
|
|
||||||
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
|
|
||||||
|
|
||||||
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
||||||
|
mux.Handle("/", core.RequireAccount(AdminIndexHandler(app)))
|
||||||
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
|
|
||||||
|
|
||||||
// response wrapper to make sure a session cookie exists
|
// response wrapper to make sure a session cookie exists
|
||||||
return enforceSession(app, mux)
|
return core.EnforceSession(app, mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminIndexHandler(app *model.AppState) http.Handler {
|
func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
|
@ -63,39 +44,21 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
latestRelease, err := controller.GetLatestRelease(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
artists, err := controller.GetAllArtists(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks, err := controller.GetOrphanTracks(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type IndexData struct {
|
type IndexData struct {
|
||||||
Session *model.Session
|
Session *model.Session
|
||||||
Releases []*model.Release
|
LatestRelease *model.Release
|
||||||
Artists []*model.Artist
|
|
||||||
Tracks []*model.Track
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = indexTemplate.Execute(w, IndexData{
|
err = templates.IndexTemplate.Execute(w, IndexData{
|
||||||
Session: session,
|
Session: session,
|
||||||
Releases: releases,
|
LatestRelease: latestRelease,
|
||||||
Artists: artists,
|
|
||||||
Tracks: tracks,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
|
||||||
|
@ -105,361 +68,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAccountHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
if session.Account != nil {
|
|
||||||
// user is already logged in
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type registerData struct {
|
|
||||||
Session *model.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
err := registerTemplate.Execute(w, registerData{ Session: session })
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterRequest struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Invite string `json:"invite"`
|
|
||||||
}
|
|
||||||
credentials := RegisterRequest{
|
|
||||||
Username: r.Form.Get("username"),
|
|
||||||
Email: r.Form.Get("email"),
|
|
||||||
Password: r.Form.Get("password"),
|
|
||||||
Invite: r.Form.Get("invite"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure invite code exists in DB
|
|
||||||
invite, err := controller.GetInvite(app.DB, credentials.Invite)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
|
||||||
if invite != nil {
|
|
||||||
err := controller.DeleteInvite(app.DB, invite.Code)
|
|
||||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
|
||||||
}
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid invite code.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
account := model.Account{
|
|
||||||
Username: credentials.Username,
|
|
||||||
Password: string(hashedPassword),
|
|
||||||
Email: sql.NullString{ String: credentials.Email, Valid: true },
|
|
||||||
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
|
|
||||||
}
|
|
||||||
err = controller.CreateAccount(app.DB, &account)
|
|
||||||
if err != nil {
|
|
||||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
|
||||||
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
|
|
||||||
|
|
||||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
|
||||||
if err != nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// registration success!
|
|
||||||
controller.SetSessionAccount(app.DB, session, &account)
|
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
type loginData struct {
|
|
||||||
Session *model.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
err := loginTemplate.Execute(w, loginData{ Session: session })
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
if session.Account != nil {
|
|
||||||
// user is already logged in
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !r.Form.Has("username") || !r.Form.Has("password") {
|
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.FormValue("username")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account == nil {
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account.Locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "This account is locked.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
|
||||||
if err != nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
|
||||||
if locked := handleFailedLogin(app, account, r); locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
|
||||||
} else {
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(totps) > 0 {
|
|
||||||
err = controller.SetSessionAttemptAccount(app.DB, session, account)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// login success!
|
|
||||||
// TODO: log login activity to user
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
|
||||||
|
|
||||||
err = controller.SetSessionAccount(app.DB, session, account)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginTOTPHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
if session.AttemptAccount == nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginTOTPData struct {
|
|
||||||
Session *model.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.ParseForm()
|
|
||||||
|
|
||||||
if !r.Form.Has("totp") {
|
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totpCode := r.FormValue("totp")
|
|
||||||
|
|
||||||
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if totpMethod == nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
|
||||||
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
|
||||||
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
} else {
|
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
|
|
||||||
|
|
||||||
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
|
|
||||||
}
|
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func logoutHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
err := controller.DeleteSession(app.DB, session.Token)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: model.COOKIE_TOKEN,
|
|
||||||
Expires: time.Now(),
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
err = logoutTemplate.Execute(w, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireAccount(next http.Handler) http.HandlerFunc {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
if session.Account == nil {
|
|
||||||
// TODO: include context in redirect
|
|
||||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func staticHandler() http.Handler {
|
func staticHandler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
|
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
|
||||||
|
@ -480,63 +88,3 @@ func staticHandler() http.Handler {
|
||||||
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
|
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if session == nil {
|
|
||||||
// create a new session
|
|
||||||
session, err = controller.CreateSession(app.DB, r.UserAgent())
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: model.COOKIE_TOKEN,
|
|
||||||
Value: session.Token,
|
|
||||||
Expires: session.ExpiresAt,
|
|
||||||
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), "session", session)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
|
|
||||||
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(
|
|
||||||
os.Stderr,
|
|
||||||
"WARN: Failed to increment login failures for \"%s\": %v\n",
|
|
||||||
account.Username,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Failed to increment login failures for \"%s\"",
|
|
||||||
account.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
|
|
||||||
account.Username,
|
|
||||||
model.MAX_LOGIN_FAIL_ATTEMPTS,
|
|
||||||
controller.ResolveIP(app, r),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return locked
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
package admin
|
package logs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/log"
|
"arimelody-web/admin/templates"
|
||||||
"arimelody-web/model"
|
"arimelody-web/log"
|
||||||
"fmt"
|
"arimelody-web/model"
|
||||||
"net/http"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"strings"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func logsHandler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
@ -54,7 +55,7 @@ func logsHandler(app *model.AppState) http.Handler {
|
||||||
Logs []*log.Log
|
Logs []*log.Log
|
||||||
}
|
}
|
||||||
|
|
||||||
err = logsTemplate.Execute(w, LogsResponse{
|
err = templates.LogsTemplate.Execute(w, LogsResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Logs: logs,
|
Logs: logs,
|
||||||
})
|
})
|
|
@ -1,12 +1,13 @@
|
||||||
package admin
|
package music
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/admin/templates"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveArtist(app *model.AppState) http.Handler {
|
func serveArtist(app *model.AppState) http.Handler {
|
||||||
|
@ -39,7 +40,7 @@ func serveArtist(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err = artistTemplate.Execute(w, ArtistResponse{
|
err = templates.ArtistTemplate.Execute(w, ArtistResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
71
admin/music/musichttp.go
Normal file
71
admin/music/musichttp.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"arimelody-web/admin/templates"
|
||||||
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Println(r.URL.Path)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.Handle("/release/", http.StripPrefix("/release", serveRelease(app)))
|
||||||
|
mux.Handle("/artist/", http.StripPrefix("/artist", serveArtist(app)))
|
||||||
|
mux.Handle("/track/", http.StripPrefix("/track", serveTrack(app)))
|
||||||
|
mux.Handle("/", musicHandler(app))
|
||||||
|
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func musicHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artists, err := controller.GetAllArtists(app.DB)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := controller.GetOrphanTracks(app.DB)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type MusicData struct {
|
||||||
|
Session *model.Session
|
||||||
|
Releases []*model.Release
|
||||||
|
Artists []*model.Artist
|
||||||
|
Tracks []*model.Track
|
||||||
|
}
|
||||||
|
|
||||||
|
err = templates.MusicTemplate.Execute(w, MusicData{
|
||||||
|
Session: session,
|
||||||
|
Releases: releases,
|
||||||
|
Artists: artists,
|
||||||
|
Tracks: tracks,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,12 +1,13 @@
|
||||||
package admin
|
package music
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/admin/templates"
|
||||||
"arimelody-web/model"
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveRelease(app *model.AppState) http.Handler {
|
func serveRelease(app *model.AppState) http.Handler {
|
||||||
|
@ -60,7 +61,7 @@ func serveRelease(app *model.AppState) http.Handler {
|
||||||
Release *model.Release
|
Release *model.Release
|
||||||
}
|
}
|
||||||
|
|
||||||
err = releaseTemplate.Execute(w, ReleaseResponse{
|
err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Release: release,
|
Release: release,
|
||||||
})
|
})
|
||||||
|
@ -74,7 +75,7 @@ func serveRelease(app *model.AppState) http.Handler {
|
||||||
func serveEditCredits(release *model.Release) http.Handler {
|
func serveEditCredits(release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := editCreditsTemplate.Execute(w, release)
|
err := templates.EditCreditsTemplate.Execute(w, release)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering 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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -97,7 +98,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = addCreditTemplate.Execute(w, response{
|
err = templates.AddCreditTemplate.Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
})
|
})
|
||||||
|
@ -123,7 +124,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = newCreditTemplate.Execute(w, artist)
|
err = templates.NewCreditTemplate.Execute(w, artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering 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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -134,7 +135,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
func serveEditLinks(release *model.Release) http.Handler {
|
func serveEditLinks(release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := editLinksTemplate.Execute(w, release)
|
err := templates.EditLinksTemplate.Execute(w, release)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering 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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -151,7 +152,7 @@ func serveEditTracks(release *model.Release) http.Handler {
|
||||||
Add func(a int, b int) int
|
Add func(a int, b int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
err := editTracksTemplate.Execute(w, editTracksData{
|
err := templates.EditTracksTemplate.Execute(w, editTracksData{
|
||||||
Release: release,
|
Release: release,
|
||||||
Add: func(a, b int) int { return a + b },
|
Add: func(a, b int) int { return a + b },
|
||||||
})
|
})
|
||||||
|
@ -177,7 +178,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = addTrackTemplate.Execute(w, response{
|
err = templates.AddTrackTemplate.Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
})
|
})
|
||||||
|
@ -204,7 +205,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = newTrackTemplate.Execute(w, track)
|
err = templates.NewTrackTemplate.Execute(w, track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering 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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
@ -1,12 +1,13 @@
|
||||||
package admin
|
package music
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/admin/templates"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveTrack(app *model.AppState) http.Handler {
|
func serveTrack(app *model.AppState) http.Handler {
|
||||||
|
@ -39,7 +40,7 @@ func serveTrack(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err = trackTemplate.Execute(w, TrackResponse{
|
err = templates.TrackTemplate.Execute(w, TrackResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Track: track,
|
Track: track,
|
||||||
Releases: releases,
|
Releases: releases,
|
|
@ -27,7 +27,10 @@ nav {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #808080;
|
border: 1px solid #808080;
|
||||||
}
|
}
|
||||||
nav .icon {
|
.nav-item.icon {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-item.icon img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
nav .title {
|
nav .title {
|
||||||
|
@ -92,6 +95,10 @@ code {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 .5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
|
@ -1,82 +1 @@
|
||||||
@import url("/admin/static/release-list-item.css");
|
@import url("/admin/static/release-list-item.css");
|
||||||
|
|
||||||
.artist {
|
|
||||||
margin-bottom: .5em;
|
|
||||||
padding: .5em;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5em;
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
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: 8px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
const newReleaseBtn = document.getElementById("create-release");
|
|
||||||
const newArtistBtn = document.getElementById("create-artist");
|
|
||||||
const newTrackBtn = document.getElementById("create-track");
|
|
||||||
|
|
||||||
newReleaseBtn.addEventListener("click", event => {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = prompt("Enter an ID for this release:");
|
|
||||||
if (id == null || id == "") return;
|
|
||||||
|
|
||||||
fetch("/api/v1/music", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({id})
|
|
||||||
}).then(res => {
|
|
||||||
if (res.ok) location = "/admin/release/" + id;
|
|
||||||
else {
|
|
||||||
res.text().then(err => {
|
|
||||||
alert("Request failed: " + err);
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
alert("Failed to create release. Check the console for details.");
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
newArtistBtn.addEventListener("click", event => {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = prompt("Enter an ID for this artist:");
|
|
||||||
if (id == null || id == "") return;
|
|
||||||
|
|
||||||
fetch("/api/v1/artist", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({id})
|
|
||||||
}).then(res => {
|
|
||||||
res.text().then(text => {
|
|
||||||
if (res.ok) {
|
|
||||||
location = "/admin/artist/" + id;
|
|
||||||
} else {
|
|
||||||
alert("Request failed: " + text);
|
|
||||||
console.error(text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}).catch(err => {
|
|
||||||
alert("Failed to create artist. Check the console for details.");
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
newTrackBtn.addEventListener("click", event => {
|
|
||||||
event.preventDefault();
|
|
||||||
const title = prompt("Enter an title for this track:");
|
|
||||||
if (title == null || title == "") return;
|
|
||||||
|
|
||||||
fetch("/api/v1/track", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({title})
|
|
||||||
}).then(res => {
|
|
||||||
res.text().then(text => {
|
|
||||||
if (res.ok) {
|
|
||||||
location = "/admin/track/" + text;
|
|
||||||
} else {
|
|
||||||
alert("Request failed: " + text);
|
|
||||||
console.error(text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}).catch(err => {
|
|
||||||
alert("Failed to create track. Check the console for details.");
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
82
admin/static/music.css
Normal file
82
admin/static/music.css
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
@import url("/admin/static/release-list-item.css");
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
padding: .5em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5em;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
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: 8px;
|
||||||
|
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;
|
||||||
|
}
|
74
admin/static/music.js
Normal file
74
admin/static/music.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
const newReleaseBtn = document.getElementById("create-release");
|
||||||
|
const newArtistBtn = document.getElementById("create-artist");
|
||||||
|
const newTrackBtn = document.getElementById("create-track");
|
||||||
|
|
||||||
|
newReleaseBtn.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = prompt("Enter an ID for this release:");
|
||||||
|
if (id == null || id == "") return;
|
||||||
|
|
||||||
|
fetch("/api/v1/music", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({id})
|
||||||
|
}).then(res => {
|
||||||
|
if (res.ok) location = "/admin/music/release/" + id;
|
||||||
|
else {
|
||||||
|
res.text().then(err => {
|
||||||
|
alert("Request failed: " + err);
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
alert("Failed to create release. Check the console for details.");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newArtistBtn.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = prompt("Enter an ID for this artist:");
|
||||||
|
if (id == null || id == "") return;
|
||||||
|
|
||||||
|
fetch("/api/v1/artist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({id})
|
||||||
|
}).then(res => {
|
||||||
|
res.text().then(text => {
|
||||||
|
if (res.ok) {
|
||||||
|
location = "/admin/music/artist/" + id;
|
||||||
|
} else {
|
||||||
|
alert("Request failed: " + text);
|
||||||
|
console.error(text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).catch(err => {
|
||||||
|
alert("Failed to create artist. Check the console for details.");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newTrackBtn.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const title = prompt("Enter an title for this track:");
|
||||||
|
if (title == null || title == "") return;
|
||||||
|
|
||||||
|
fetch("/api/v1/track", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({title})
|
||||||
|
}).then(res => {
|
||||||
|
res.text().then(text => {
|
||||||
|
if (res.ok) {
|
||||||
|
location = "/admin/music/track/" + text;
|
||||||
|
} else {
|
||||||
|
alert("Request failed: " + text);
|
||||||
|
console.error(text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).catch(err => {
|
||||||
|
alert("Failed to create track. Check the console for details.");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,125 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/log"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var indexTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
|
||||||
filepath.Join("admin", "views", "index.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var loginTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "login.html"),
|
|
||||||
))
|
|
||||||
var loginTOTPTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "login-totp.html"),
|
|
||||||
))
|
|
||||||
var registerTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "register.html"),
|
|
||||||
))
|
|
||||||
var logoutTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "logout.html"),
|
|
||||||
))
|
|
||||||
var accountTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-account.html"),
|
|
||||||
))
|
|
||||||
var totpSetupTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "totp-setup.html"),
|
|
||||||
))
|
|
||||||
var totpConfirmTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "totp-confirm.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
|
|
||||||
"parseLevel": func(level log.LogLevel) string {
|
|
||||||
switch level {
|
|
||||||
case log.LEVEL_INFO:
|
|
||||||
return "INFO"
|
|
||||||
case log.LEVEL_WARN:
|
|
||||||
return "WARN"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d?", level)
|
|
||||||
},
|
|
||||||
"titleCase": func(logType string) string {
|
|
||||||
runes := []rune(logType)
|
|
||||||
for i, r := range runes {
|
|
||||||
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
|
|
||||||
runes[i] = r + ('A' - 'a')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return string(runes)
|
|
||||||
},
|
|
||||||
"lower": func(str string) string { return strings.ToLower(str) },
|
|
||||||
"prettyTime": func(t time.Time) string {
|
|
||||||
// return t.Format("2006-01-02 15:04:05")
|
|
||||||
// return t.Format("15:04:05, 2 Jan 2006")
|
|
||||||
return t.Format("02 Jan 2006, 15:04:05")
|
|
||||||
},
|
|
||||||
}).ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "logs.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var releaseTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-release.html"),
|
|
||||||
))
|
|
||||||
var artistTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-artist.html"),
|
|
||||||
))
|
|
||||||
var trackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("view", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-track.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var editCreditsTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "credits", "editcredits.html"),
|
|
||||||
))
|
|
||||||
var addCreditTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "credits", "addcredit.html"),
|
|
||||||
))
|
|
||||||
var newCreditTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "credits", "newcredit.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var editLinksTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "links", "editlinks.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var editTracksTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "edittracks.html"),
|
|
||||||
))
|
|
||||||
var addTrackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "addtrack.html"),
|
|
||||||
))
|
|
||||||
var newTrackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "newtrack.html"),
|
|
||||||
))
|
|
13
admin/templates/index.go
Normal file
13
admin/templates/index.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var IndexTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||||
|
filepath.Join("admin", "views", "index.html"),
|
||||||
|
))
|
42
admin/templates/login.go
Normal file
42
admin/templates/login.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LoginTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "login.html"),
|
||||||
|
))
|
||||||
|
var LoginTOTPTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "login-totp.html"),
|
||||||
|
))
|
||||||
|
var RegisterTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "register.html"),
|
||||||
|
))
|
||||||
|
var LogoutTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "logout.html"),
|
||||||
|
))
|
||||||
|
var AccountTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "edit-account.html"),
|
||||||
|
))
|
||||||
|
var TotpSetupTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "totp-setup.html"),
|
||||||
|
))
|
||||||
|
var TotpConfirmTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "totp-confirm.html"),
|
||||||
|
))
|
41
admin/templates/logs.go
Normal file
41
admin/templates/logs.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"arimelody-web/log"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LogsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
|
||||||
|
"parseLevel": func(level log.LogLevel) string {
|
||||||
|
switch level {
|
||||||
|
case log.LEVEL_INFO:
|
||||||
|
return "INFO"
|
||||||
|
case log.LEVEL_WARN:
|
||||||
|
return "WARN"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d?", level)
|
||||||
|
},
|
||||||
|
"titleCase": func(logType string) string {
|
||||||
|
runes := []rune(logType)
|
||||||
|
for i, r := range runes {
|
||||||
|
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
|
||||||
|
runes[i] = r + ('A' - 'a')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
},
|
||||||
|
"lower": func(str string) string { return strings.ToLower(str) },
|
||||||
|
"prettyTime": func(t time.Time) string {
|
||||||
|
// return t.Format("2006-01-02 15:04:05")
|
||||||
|
// return t.Format("15:04:05, 2 Jan 2006")
|
||||||
|
return t.Format("02 Jan 2006, 15:04:05")
|
||||||
|
},
|
||||||
|
}).ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "logs.html"),
|
||||||
|
))
|
54
admin/templates/music.go
Normal file
54
admin/templates/music.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MusicTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||||
|
filepath.Join("admin", "views", "music.html"),
|
||||||
|
))
|
||||||
|
|
||||||
|
var ReleaseTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "edit-release.html"),
|
||||||
|
))
|
||||||
|
var ArtistTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "views", "edit-artist.html"),
|
||||||
|
))
|
||||||
|
var TrackTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
filepath.Join("view", "prideflag.html"),
|
||||||
|
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||||
|
filepath.Join("admin", "views", "edit-track.html"),
|
||||||
|
))
|
||||||
|
|
||||||
|
var EditCreditsTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "credits", "editcredits.html"),
|
||||||
|
))
|
||||||
|
var AddCreditTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "credits", "addcredit.html"),
|
||||||
|
))
|
||||||
|
var NewCreditTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "credits", "newcredit.html"),
|
||||||
|
))
|
||||||
|
|
||||||
|
var EditLinksTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "links", "editlinks.html"),
|
||||||
|
))
|
||||||
|
|
||||||
|
var EditTracksTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "tracks", "edittracks.html"),
|
||||||
|
))
|
||||||
|
var AddTrackTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "tracks", "addtrack.html"),
|
||||||
|
))
|
||||||
|
var NewTrackTemplate = template.Must(template.ParseFiles(
|
||||||
|
filepath.Join("admin", "components", "tracks", "newtrack.html"),
|
||||||
|
))
|
|
@ -38,7 +38,7 @@
|
||||||
<div class="credit">
|
<div class="credit">
|
||||||
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
||||||
<div class="credit-info">
|
<div class="credit-info">
|
||||||
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
<h3 class="credit-name"><a href="/admin/music/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||||
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||||
<p class="artist-role">
|
<p class="artist-role">
|
||||||
Role: {{.Role}}
|
Role: {{.Role}}
|
||||||
|
|
|
@ -100,8 +100,8 @@
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h2>Credits ({{len .Release.Credits}})</h2>
|
<h2>Credits ({{len .Release.Credits}})</h2>
|
||||||
<a class="button edit"
|
<a class="button edit"
|
||||||
href="/admin/release/{{.Release.ID}}/editcredits"
|
href="/admin/music/release/{{.Release.ID}}/editcredits"
|
||||||
hx-get="/admin/release/{{.Release.ID}}/editcredits"
|
hx-get="/admin/music/release/{{.Release.ID}}/editcredits"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Edit</a>
|
>Edit</a>
|
||||||
|
@ -111,7 +111,7 @@
|
||||||
<div class="credit">
|
<div class="credit">
|
||||||
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||||
<div class="credit-info">
|
<div class="credit-info">
|
||||||
<p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
|
<p class="artist-name"><a href="/admin/music/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
|
||||||
<p class="artist-role">
|
<p class="artist-role">
|
||||||
{{.Role}}
|
{{.Role}}
|
||||||
{{if .Primary}}
|
{{if .Primary}}
|
||||||
|
@ -129,8 +129,8 @@
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h2>Links ({{len .Release.Links}})</h2>
|
<h2>Links ({{len .Release.Links}})</h2>
|
||||||
<a class="button edit"
|
<a class="button edit"
|
||||||
href="/admin/release/{{.Release.ID}}/editlinks"
|
href="/admin/music/release/{{.Release.ID}}/editlinks"
|
||||||
hx-get="/admin/release/{{.Release.ID}}/editlinks"
|
hx-get="/admin/music/release/{{.Release.ID}}/editlinks"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Edit</a>
|
>Edit</a>
|
||||||
|
@ -144,8 +144,8 @@
|
||||||
<div class="card-title" id="tracks">
|
<div class="card-title" id="tracks">
|
||||||
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||||
<a class="button edit"
|
<a class="button edit"
|
||||||
href="/admin/release/{{.Release.ID}}/edittracks"
|
href="/admin/music/release/{{.Release.ID}}/edittracks"
|
||||||
hx-get="/admin/release/{{.Release.ID}}/edittracks"
|
hx-get="/admin/music/release/{{.Release.ID}}/edittracks"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Edit</a>
|
>Edit</a>
|
||||||
|
@ -155,7 +155,7 @@
|
||||||
<div class="track" data-id="{{$track.ID}}">
|
<div class="track" data-id="{{$track.ID}}">
|
||||||
<h2 class="track-title">
|
<h2 class="track-title">
|
||||||
<span class="track-number">{{.Add $i 1}}</span>
|
<span class="track-number">{{.Add $i 1}}</span>
|
||||||
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
|
<a href="/admin/music/track/{{$track.ID}}">{{$track.Title}}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<h3>Description</h3>
|
<h3>Description</h3>
|
||||||
|
|
|
@ -6,65 +6,19 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
|
<h1>Admin Dashboard</h1>
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h1>Releases</h1>
|
<h2>Music</h2>
|
||||||
<a class="button new" id="create-release">Create New</a>
|
<a class="button" href="/admin/music/">Browse All</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card releases">
|
<div class="card" id="music">
|
||||||
{{range .Releases}}
|
{{if .LatestRelease}}
|
||||||
{{block "release" .}}{{end}}
|
<h3>Latest Release</h3>
|
||||||
{{end}}
|
{{block "release" .LatestRelease}}{{end}}
|
||||||
{{if not .Releases}}
|
{{else}}
|
||||||
<p>There are no releases.</p>
|
<p>There are no releases.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-title">
|
|
||||||
<h1>Artists</h1>
|
|
||||||
<a class="button new" id="create-artist">Create New</a>
|
|
||||||
</div>
|
|
||||||
<div class="card artists">
|
|
||||||
{{range $Artist := .Artists}}
|
|
||||||
<div class="artist">
|
|
||||||
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
|
||||||
<a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if not .Artists}}
|
|
||||||
<p>There are no artists.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-title">
|
|
||||||
<h1>Tracks</h1>
|
|
||||||
<a class="button new" id="create-track">Create New</a>
|
|
||||||
</div>
|
|
||||||
<div class="card tracks">
|
|
||||||
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
|
|
||||||
<br>
|
|
||||||
{{range $Track := .Tracks}}
|
|
||||||
<div class="track">
|
|
||||||
<h2 class="track-title">
|
|
||||||
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
|
|
||||||
</h2>
|
|
||||||
{{if $Track.Description}}
|
|
||||||
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
|
|
||||||
{{else}}
|
|
||||||
<p class="track-description empty">No description provided.</p>
|
|
||||||
{{end}}
|
|
||||||
{{if $Track.Lyrics}}
|
|
||||||
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
|
|
||||||
{{else}}
|
|
||||||
<p class="track-lyrics empty">There are no lyrics.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if not .Artists}}
|
|
||||||
<p>There are no artists.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/admin/static/admin.js"></script>
|
<script type="module" src="/admin/static/admin.js"></script>
|
||||||
|
|
|
@ -16,14 +16,19 @@
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<img src="/img/favicon.png" alt="" class="icon">
|
<div class="nav-item icon" title="return to arimelody.space">
|
||||||
<div class="nav-item">
|
<a href="/"><img src="/img/favicon.png" alt=""/></a>
|
||||||
<a href="/">arimelody.me</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin">home</a>
|
<a href="/admin">home</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .Session.Account}}
|
{{if .Session.Account}}
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/admin/music/">music</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/admin/blog">blog</a>
|
||||||
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin/logs">logs</a>
|
<a href="/admin/logs">logs</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +38,7 @@
|
||||||
|
|
||||||
{{if .Session.Account}}
|
{{if .Session.Account}}
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
<a href="/admin/account/">account ({{.Session.Account.Username}})</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin/logout" id="logout">log out</a>
|
<a href="/admin/logout" id="logout">log out</a>
|
||||||
|
|
73
admin/views/music.html
Normal file
73
admin/views/music.html
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
{{define "head"}}
|
||||||
|
<title>Music - ari melody 💫</title>
|
||||||
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/music.css">
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<main>
|
||||||
|
<h1>Music</h1>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Releases</h2>
|
||||||
|
<a class="button new" id="create-release">Create New</a>
|
||||||
|
</div>
|
||||||
|
<div class="card releases">
|
||||||
|
{{range .Releases}}
|
||||||
|
{{block "release" .}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if not .Releases}}
|
||||||
|
<p>There are no releases.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Artists</h2>
|
||||||
|
<a class="button new" id="create-artist">Create New</a>
|
||||||
|
</div>
|
||||||
|
<div class="card artists">
|
||||||
|
{{range $Artist := .Artists}}
|
||||||
|
<div class="artist">
|
||||||
|
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||||
|
<a href="/admin/music/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Artists}}
|
||||||
|
<p>There are no artists.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Tracks</h2>
|
||||||
|
<a class="button new" id="create-track">Create New</a>
|
||||||
|
</div>
|
||||||
|
<div class="card tracks">
|
||||||
|
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
|
||||||
|
<br>
|
||||||
|
{{range $Track := .Tracks}}
|
||||||
|
<div class="track">
|
||||||
|
<h2 class="track-title">
|
||||||
|
<a href="/admin/music/track/{{$Track.ID}}">{{$Track.Title}}</a>
|
||||||
|
</h2>
|
||||||
|
{{if $Track.Description}}
|
||||||
|
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="track-description empty">No description provided.</p>
|
||||||
|
{{end}}
|
||||||
|
{{if $Track.Lyrics}}
|
||||||
|
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="track-lyrics empty">There are no lyrics.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Artists}}
|
||||||
|
<p>There are no artists.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/admin/static/admin.js"></script>
|
||||||
|
<script type="module" src="/admin/static/music.js"></script>
|
||||||
|
{{end}}
|
|
@ -1,12 +1,13 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
||||||
|
@ -101,6 +102,47 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
||||||
return releases, nil
|
return releases, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetLatestRelease(db *sqlx.DB) (*model.Release, error) {
|
||||||
|
var release = model.Release{}
|
||||||
|
|
||||||
|
err := db.Get(&release, "SELECT * FROM musicrelease WHERE visible=true ORDER BY release_date LIMIT 1")
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get credits
|
||||||
|
credits, err := GetReleaseCredits(db, release.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||||
|
}
|
||||||
|
for _, credit := range credits {
|
||||||
|
release.Credits = append(release.Credits, credit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get tracks
|
||||||
|
tracks, err := GetReleaseTracks(db, release.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||||
|
}
|
||||||
|
for _, track := range tracks {
|
||||||
|
release.Tracks = append(release.Tracks, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get links
|
||||||
|
links, err := GetReleaseLinks(db, release.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
release.Links = append(release.Links, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &release, nil
|
||||||
|
}
|
||||||
|
|
||||||
func CreateRelease(db *sqlx.DB, release *model.Release) error {
|
func CreateRelease(db *sqlx.DB, release *model.Release) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
"INSERT INTO musicrelease "+
|
"INSERT INTO musicrelease "+
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
main {
|
main {
|
||||||
width: min(calc(100% - 4rem), 720px);
|
width: min(calc(100% - 4rem), 720px);
|
||||||
min-height: calc(100vh - 10.3rem);
|
min-height: calc(100vh - 10.3em);
|
||||||
margin: 0 auto 2rem auto;
|
margin: 0 auto 2rem auto;
|
||||||
padding-top: 4rem;
|
padding-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue