Blog Posts ({{.TotalPosts}} total) + Create New +
{{.Year}}
+ {{range .Posts}} + {{block "blogpost" .}}{{end}} + {{end}} +There are no blog posts.
+ {{end}} +diff --git a/admin/accounthttp.go b/admin/account/accounthttp.go
similarity index 86%
rename from admin/accounthttp.go
rename to admin/account/accounthttp.go
index 113a17a..4595cd4 100644
--- a/admin/accounthttp.go
+++ b/admin/account/accounthttp.go
@@ -1,4 +1,4 @@
-package admin
+package account
import (
"database/sql"
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
+ "arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
@@ -15,17 +16,21 @@ import (
"golang.org/x/crypto/bcrypt"
)
-func accountHandler(app *model.AppState) http.Handler {
- mux := http.NewServeMux()
+func Handler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux := http.NewServeMux()
- mux.Handle("/account/totp-setup", totpSetupHandler(app))
- mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
- mux.Handle("/account/totp-delete", totpDeleteHandler(app))
+ mux.Handle("/account/", accountIndexHandler(app))
- mux.Handle("/account/password", changePasswordHandler(app))
- mux.Handle("/account/delete", deleteAccountHandler(app))
+ mux.Handle("/account/totp-setup", totpSetupHandler(app))
+ mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
+ mux.Handle("/account/totp-delete", totpDeleteHandler(app))
- return mux
+ mux.Handle("/account/password", changePasswordHandler(app))
+ mux.Handle("/account/delete", deleteAccountHandler(app))
+
+ mux.ServeHTTP(w, r)
+ })
}
func accountIndexHandler(app *model.AppState) http.Handler {
@@ -45,7 +50,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
}
accountResponse struct {
- adminPageData
+ core.AdminPageData
TOTPs []TOTP
}
)
@@ -66,7 +71,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTPs: totps,
})
if err != nil {
@@ -93,7 +98,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -103,7 +108,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -112,7 +117,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -120,7 +125,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@@ -148,7 +153,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -156,7 +161,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -170,7 +175,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
}
type totpConfirmData struct {
- adminPageData
+ core.AdminPageData
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
@@ -181,7 +186,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if r.Method == http.MethodGet {
session := r.Context().Value("session").(*model.Session)
- err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
+ err := templates.TOTPSetupTemplate.Execute(w, core.AdminPageData{ Path: "/account", Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -219,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
@@ -235,7 +240,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
}
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@@ -271,7 +276,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@@ -291,7 +296,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@@ -307,7 +312,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -315,7 +320,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@@ -343,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@@ -355,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -363,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go
new file mode 100644
index 0000000..7805638
--- /dev/null
+++ b/admin/auth/authhttp.go
@@ -0,0 +1,379 @@
+package auth
+
+import (
+ "arimelody-web/admin/core"
+ "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)
+
+ render := func() {
+ err := templates.LoginTemplate.Execute(w, core.AdminPageData{ 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
+ }
+
+ render := func() {
+ err := templates.LoginTOTPTemplate.Execute(w, core.AdminPageData{ 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
+}
diff --git a/admin/blog/blog.go b/admin/blog/blog.go
new file mode 100644
index 0000000..c397f26
--- /dev/null
+++ b/admin/blog/blog.go
@@ -0,0 +1,133 @@
+package blog
+
+import (
+ "arimelody-web/admin/core"
+ "arimelody-web/admin/templates"
+ "arimelody-web/controller"
+ "arimelody-web/model"
+ "fmt"
+ "net/http"
+ "os"
+ "slices"
+)
+
+func Handler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux := http.NewServeMux()
+
+ mux.Handle("/blogs/{id}", serveBlogPost(app))
+ mux.Handle("/blogs/", serveBlogIndex(app))
+
+ mux.ServeHTTP(w, r)
+ })
+}
+
+type (
+ blogPostCollection struct {
+ Year int
+ Posts []*model.BlogPost
+ }
+)
+
+func (c *blogPostCollection) Clone() blogPostCollection {
+ return blogPostCollection{
+ Year: c.Year,
+ Posts: slices.Clone(c.Posts),
+ }
+}
+
+func serveBlogIndex(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
+ posts, err := controller.GetBlogPosts(app.DB, false, -1, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ collections := []*blogPostCollection{}
+ collection := blogPostCollection{
+ Posts: []*model.BlogPost{},
+ Year: -1,
+ }
+ for i, post := range posts {
+ if i == 0 {
+ collection.Year = post.PublishDate.Year()
+ }
+
+ if post.PublishDate.Year() != collection.Year {
+ clone := collection.Clone()
+ collections = append(collections, &clone)
+ collection = blogPostCollection{
+ Year: post.PublishDate.Year(),
+ Posts: []*model.BlogPost{},
+ }
+ }
+
+ collection.Posts = append(collection.Posts, post)
+
+ if i == len(posts) - 1 {
+ collections = append(collections, &collection)
+ }
+ }
+
+ type blogsData struct {
+ core.AdminPageData
+ TotalPosts int
+ Collections []*blogPostCollection
+ }
+
+ err = templates.BlogsTemplate.Execute(w, blogsData{
+ AdminPageData: core.AdminPageData{
+ Path: r.URL.Path,
+ Session: session,
+ },
+ TotalPosts: len(posts),
+ Collections: collections,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Error rendering admin blog index: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
+}
+
+func serveBlogPost(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
+ blogID := r.PathValue("id")
+
+ post, err := controller.GetBlogPost(app.DB, blogID)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog %s: %v\n", blogID, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ if post == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ type blogPostData struct {
+ core.AdminPageData
+ Post *model.BlogPost
+ }
+
+ err = templates.EditBlogTemplate.Execute(w, blogPostData{
+ AdminPageData: core.AdminPageData{
+ Path: r.URL.Path,
+ Session: session,
+ },
+ Post: post,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Error rendering admin edit page for blog %s: %v\n", blogID, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
+}
diff --git a/admin/core/funcs.go b/admin/core/funcs.go
new file mode 100644
index 0000000..c471f8a
--- /dev/null
+++ b/admin/core/funcs.go
@@ -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))
+ })
+}
diff --git a/admin/core/structs.go b/admin/core/structs.go
new file mode 100644
index 0000000..1a4639f
--- /dev/null
+++ b/admin/core/structs.go
@@ -0,0 +1,8 @@
+package core
+
+import "arimelody-web/model"
+
+type AdminPageData struct {
+ Path string
+ Session *model.Session
+}
diff --git a/admin/http.go b/admin/http.go
index 2a6b4ae..e913558 100644
--- a/admin/http.go
+++ b/admin/http.go
@@ -1,59 +1,35 @@
package admin
import (
- "context"
- "database/sql"
"fmt"
"net/http"
"os"
- "strings"
- "time"
+ "arimelody-web/admin/account"
+ "arimelody-web/admin/auth"
+ "arimelody-web/admin/blog"
+ "arimelody-web/admin/core"
+ "arimelody-web/admin/logs"
+ "arimelody-web/admin/music"
"arimelody-web/admin/templates"
"arimelody-web/controller"
- "arimelody-web/log"
"arimelody-web/model"
"arimelody-web/view"
-
- "golang.org/x/crypto/bcrypt"
)
-type adminPageData struct {
- Path string
- Session *model.Session
-}
-
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
- mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
+ mux.Handle("/register", auth.RegisterAccountHandler(app))
+ mux.Handle("/login", auth.LoginHandler(app))
+ mux.Handle("/totp", auth.LoginTOTPHandler(app))
+ mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
- w.Write([]byte(""))
- }))
+ mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
+ mux.Handle("/music/", core.RequireAccount(music.Handler(app)))
+ mux.Handle("/blogs/", core.RequireAccount(blog.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(accountHandler(app)))
-
- mux.Handle("/logs", requireAccount(logsHandler(app)))
-
- mux.Handle("/releases", requireAccount(serveReleases(app)))
- mux.Handle("/releases/", requireAccount(serveReleases(app)))
- mux.Handle("/artists", requireAccount(serveArtists(app)))
- mux.Handle("/artists/", requireAccount(serveArtists(app)))
- mux.Handle("/tracks", requireAccount(serveTracks(app)))
- mux.Handle("/tracks/", requireAccount(serveTracks(app)))
+ mux.Handle("/account/", core.RequireAccount(account.Handler(app)))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/admin.css" {
@@ -64,18 +40,18 @@ func Handler(app *model.AppState) http.Handler {
http.ServeFile(w, r, "./admin/static/admin.js")
return
}
- requireAccount(
+ core.RequireAccount(
http.StripPrefix("/static",
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
}))
- mux.Handle("/", requireAccount(AdminIndexHandler(app)))
+ mux.Handle("/", core.RequireAccount(adminIndexHandler(app)))
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@@ -86,413 +62,93 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releaseCount, err := controller.GetReleaseCount(app.DB, false)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %v\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)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artistCount, err := controller.GetArtistCount(app.DB)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %v\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)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
trackCount, err := controller.GetTrackCount(app.DB)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ type BlogPost struct {
+ *model.BlogPost
+ Author *model.Account
+ }
+ blogPosts, err := controller.GetBlogPosts(app.DB, false, 1, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog posts: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ var latestBlogPost *model.BlogPost = nil
+ if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] }
+ blogCount, err := controller.GetBlogPostCount(app.DB, false)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
- adminPageData
+ core.AdminPageData
Releases []*model.Release
ReleaseCount int
Artists []*model.Artist
ArtistCount int
Tracks []*model.Track
TrackCount int
+ LatestBlogPost *model.BlogPost
+ BlogCount int
}
err = templates.IndexTemplate.Execute(w, IndexData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Releases: releases,
ReleaseCount: releaseCount,
Artists: artists,
ArtistCount: artistCount,
Tracks: tracks,
TrackCount: trackCount,
+ LatestBlogPost: latestBlogPost,
+ BlogCount: blogCount,
})
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: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
-func registerAccountHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- if session.Account != nil {
- // user is already logged in
- http.Redirect(w, r, "/admin", http.StatusFound)
- return
- }
-
- render := func() {
- err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Printf("WARN: Error rendering create account page: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- }
-
- if r.Method == http.MethodGet {
- render()
- return
- }
-
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- type RegisterRequest struct {
- Username string `json:"username"`
- Email string `json:"email"`
- Password string `json:"password"`
- Invite string `json:"invite"`
- }
- credentials := RegisterRequest{
- Username: r.Form.Get("username"),
- Email: r.Form.Get("email"),
- Password: r.Form.Get("password"),
- Invite: r.Form.Get("invite"),
- }
-
- // make sure invite code exists in DB
- invite, err := controller.GetInvite(app.DB, credentials.Invite)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- if invite == nil || time.Now().After(invite.ExpiresAt) {
- if invite != nil {
- err := controller.DeleteInvite(app.DB, invite.Code)
- if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
- }
- controller.SetSessionError(app.DB, session, "Invalid invite code.")
- render()
- return
- }
-
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- account := model.Account{
- Username: credentials.Username,
- Password: string(hashedPassword),
- Email: sql.NullString{ String: credentials.Email, Valid: true },
- AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
- }
- err = controller.CreateAccount(app.DB, &account)
- if err != nil {
- if strings.HasPrefix(err.Error(), "pq: duplicate key") {
- controller.SetSessionError(app.DB, session, "An account with that username already exists.")
- render()
- return
- }
- fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
-
- err = controller.DeleteInvite(app.DB, invite.Code)
- if err != nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
- }
-
- // registration success!
- controller.SetSessionAccount(app.DB, session, &account)
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func loginHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet && r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- session := r.Context().Value("session").(*model.Session)
-
- render := func() {
- err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }
-
- if r.Method == http.MethodGet {
- if session.Account != nil {
- // user is already logged in
- http.Redirect(w, r, "/admin", http.StatusFound)
- return
- }
- render()
- return
- }
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- if !r.Form.Has("username") || !r.Form.Has("password") {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- username := r.FormValue("username")
- password := r.FormValue("password")
-
- account, err := controller.GetAccountByUsername(app.DB, username)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- render()
- return
- }
- if account == nil {
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- render()
- return
- }
- if account.Locked {
- controller.SetSessionError(app.DB, session, "This account is locked.")
- render()
- return
- }
-
- err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
- if err != nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
- if locked := handleFailedLogin(app, account, r); locked {
- controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
- } else {
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- }
- render()
- return
- }
-
- totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- if len(totps) > 0 {
- err = controller.SetSessionAttemptAccount(app.DB, session, account)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin/totp", http.StatusFound)
- return
- }
-
- // login success!
- // TODO: log login activity to user
- app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
-
- err = controller.SetSessionAccount(app.DB, session, account)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func loginTOTPHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- if session.AttemptAccount == nil {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- render := func() {
- err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }
-
- if r.Method == http.MethodGet {
- render()
- return
- }
-
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- r.ParseForm()
-
- if !r.Form.Has("totp") {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- totpCode := r.FormValue("totp")
-
- if len(totpCode) != controller.TOTP_CODE_LENGTH {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
- controller.SetSessionError(app.DB, session, "Invalid TOTP.")
- render()
- return
- }
-
- totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- if totpMethod == nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
- if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
- controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
- controller.SetSessionAttemptAccount(app.DB, session, nil)
- http.Redirect(w, r, "/admin", http.StatusFound)
- } else {
- controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
- }
- render()
- return
- }
-
- app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
-
- err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- err = controller.SetSessionAttemptAccount(app.DB, session, nil)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func logoutHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.NotFound(w, r)
- return
- }
-
- session := r.Context().Value("session").(*model.Session)
- err := controller.DeleteSession(app.DB, session.Token)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: model.COOKIE_TOKEN,
- Expires: time.Now(),
- Path: "/",
- })
-
- err = templates.LogoutTemplate.Execute(w, nil)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- })
-}
-
-func requireAccount(next http.Handler) http.HandlerFunc {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
- if session.Account == nil {
- // TODO: include context in redirect
- http.Redirect(w, r, "/admin/login", http.StatusFound)
- return
- }
- next.ServeHTTP(w, r)
- })
-}
-
/*
//go:embed "static"
var staticFS embed.FS
@@ -513,63 +169,3 @@ func staticHandler() http.Handler {
})
}
*/
-
-func enforceSession(app *model.AppState, next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session, err := controller.GetSessionFromRequest(app, r)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- if session == nil {
- // create a new session
- session, err = controller.CreateSession(app.DB, r.UserAgent())
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: model.COOKIE_TOKEN,
- Value: session.Token,
- Expires: session.ExpiresAt,
- Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
- HttpOnly: true,
- Path: "/",
- })
- }
-
- ctx := context.WithValue(r.Context(), "session", session)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
-}
-
-func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
- locked, err := controller.IncrementAccountFails(app.DB, account.ID)
- if err != nil {
- fmt.Fprintf(
- os.Stderr,
- "WARN: Failed to increment login failures for \"%s\": %v\n",
- account.Username,
- err,
- )
- app.Log.Warn(
- log.TYPE_ACCOUNT,
- "Failed to increment login failures for \"%s\"",
- account.Username,
- )
- }
- if locked {
- app.Log.Warn(
- log.TYPE_ACCOUNT,
- "Account \"%s\" was locked: %d failed login attempts (IP: %s)",
- account.Username,
- model.MAX_LOGIN_FAIL_ATTEMPTS,
- controller.ResolveIP(app, r),
- )
- }
- return locked
-}
diff --git a/admin/logshttp.go b/admin/logs/logshttp.go
similarity index 90%
rename from admin/logshttp.go
rename to admin/logs/logshttp.go
index a6d8e40..270721e 100644
--- a/admin/logshttp.go
+++ b/admin/logs/logshttp.go
@@ -1,6 +1,7 @@
-package admin
+package logs
import (
+ "arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
@@ -10,7 +11,7 @@ import (
"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) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
@@ -51,12 +52,12 @@ func logsHandler(app *model.AppState) http.Handler {
}
type LogsResponse struct {
- adminPageData
+ core.AdminPageData
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Logs: logs,
})
if err != nil {
diff --git a/admin/artisthttp.go b/admin/music/artisthttp.go
similarity index 70%
rename from admin/artisthttp.go
rename to admin/music/artisthttp.go
index f151ddd..12855c6 100644
--- a/admin/artisthttp.go
+++ b/admin/music/artisthttp.go
@@ -1,10 +1,11 @@
-package admin
+package music
import (
"fmt"
"net/http"
- "strings"
+ "os"
+ "arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@@ -14,32 +15,24 @@ func serveArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/")
- artistID := slices[0]
-
- if len(artistID) > 0 {
- serveArtist(app, artistID).ServeHTTP(w, r)
- return
- }
-
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
- fmt.Printf("WARN: Failed to fetch artists: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistsResponse struct {
- adminPageData
+ core.AdminPageData
Artists []*model.Artist
}
err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Artists: artists,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artists page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -55,31 +48,31 @@ func serveArtist(app *model.AppState, artistID string) http.Handler {
http.NotFound(w, r)
return
}
- fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil {
- fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist credits for %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistResponse struct {
- adminPageData
+ core.AdminPageData
Artist *model.Artist
Credits []*model.Credit
}
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Artist: artist,
Credits: credits,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go
new file mode 100644
index 0000000..562d83f
--- /dev/null
+++ b/admin/music/musichttp.go
@@ -0,0 +1,32 @@
+package music
+
+import (
+ "arimelody-web/model"
+ "net/http"
+)
+
+func Handler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/music/releases/{id}/", func(w http.ResponseWriter, r *http.Request) {
+ serveEditRelease(app, r.PathValue("id")).ServeHTTP(w, r)
+ })
+ mux.HandleFunc("/music/releases/{id}", func(w http.ResponseWriter, r *http.Request) {
+ serveRelease(app, r.PathValue("id")).ServeHTTP(w, r)
+ })
+ mux.Handle("/music/releases/", serveReleases(app))
+
+ mux.HandleFunc("/music/artists/{id}", func(w http.ResponseWriter, r *http.Request) {
+ serveArtist(app, r.PathValue("id")).ServeHTTP(w, r)
+ })
+ mux.Handle("/music/artists/", serveArtists(app))
+
+ mux.HandleFunc("/music/tracks/{id}", func(w http.ResponseWriter, r *http.Request) {
+ serveTrack(app, r.PathValue("id")).ServeHTTP(w, r)
+ })
+ mux.Handle("/music/tracks/", serveTracks(app))
+
+ mux.ServeHTTP(w, r)
+ })
+}
diff --git a/admin/releasehttp.go b/admin/music/releasehttp.go
similarity index 66%
rename from admin/releasehttp.go
rename to admin/music/releasehttp.go
index 7cca841..8ab0123 100644
--- a/admin/releasehttp.go
+++ b/admin/music/releasehttp.go
@@ -1,11 +1,11 @@
-package admin
+package music
import (
"fmt"
"net/http"
"os"
- "strings"
+ "arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@@ -15,115 +15,101 @@ func serveReleases(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
- releaseID := slices[0]
-
- var action string = ""
- if len(slices) > 1 {
- action = slices[1]
- }
-
- if len(releaseID) > 0 {
- serveRelease(app, releaseID, action).ServeHTTP(w, r)
- return
- }
-
type ReleasesData struct {
- adminPageData
+ core.AdminPageData
Releases []*model.Release
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
- adminPageData: adminPageData{
+ AdminPageData: core.AdminPageData{
Path: r.URL.Path,
Session: session,
},
Releases: releases,
})
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
-func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
+func serveRelease(app *model.AppState, releaseID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
- if strings.Contains(err.Error(), "no rows") {
- http.NotFound(w, r)
- return
- }
- fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
-
- if len(action) > 0 {
- switch action {
- case "editcredits":
- serveEditCredits(release).ServeHTTP(w, r)
- return
- case "addcredit":
- serveAddCredit(app, release).ServeHTTP(w, r)
- return
- case "newcredit":
- serveNewCredit(app).ServeHTTP(w, r)
- return
- case "editlinks":
- serveEditLinks(release).ServeHTTP(w, r)
- return
- case "edittracks":
- serveEditTracks(release).ServeHTTP(w, r)
- return
- case "addtrack":
- serveAddTrack(app, release).ServeHTTP(w, r)
- return
- case "newtrack":
- serveNewTrack(app).ServeHTTP(w, r)
- return
- }
+ if release == nil {
http.NotFound(w, r)
return
}
+ session := r.Context().Value("session").(*model.Session)
+
type ReleaseResponse struct {
- adminPageData
+ core.AdminPageData
Release *model.Release
}
- for i, track := range release.Tracks {
- track.Number = i + 1
- }
+ for i, track := range release.Tracks { track.Number = i + 1 }
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Release: release,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin release page for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
+func serveEditRelease(app *model.AppState, releaseID string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ release, err := controller.GetRelease(app.DB, releaseID, true)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ if release == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ mux := http.NewServeMux()
+
+ mux.Handle("GET /music/releases/{id}/editcredits", serveEditCredits(release))
+ mux.Handle("GET /music/releases/{id}/addcredit", serveAddCredit(app, release))
+ mux.Handle("GET /music/releases/{id}/newcredit/{artistID}", serveNewCredit(app))
+
+ mux.Handle("GET /music/releases/{id}/editlinks", serveEditLinks(release))
+
+ mux.Handle("GET /music/releases/{id}/edittracks", serveEditTracks(release))
+ mux.Handle("GET /music/releases/{id}/addtrack", serveAddTrack(app, release))
+ mux.Handle("GET /music/releases/{id}/newtrack/{trackID}", serveNewTrack(app))
+
+ mux.ServeHTTP(w, r)
+ })
+}
+
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil {
- fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit credits component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -133,7 +119,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -149,7 +135,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
Artists: artists,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve add credits component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -157,10 +143,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- artistID := strings.Split(r.URL.Path, "/")[3]
+ artistID := r.PathValue("artistID")
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -172,7 +158,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil {
- fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve new credit component for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -183,7 +169,7 @@ func serveEditLinks(release *model.Release) http.Handler {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
if err != nil {
- fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit links component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -195,9 +181,11 @@ func serveEditTracks(release *model.Release) http.Handler {
type editTracksData struct { Release *model.Release }
+ for i, track := range release.Tracks { track.Number = i + 1 }
+
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
if err != nil {
- fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit tracks component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -207,7 +195,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -223,7 +211,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
Tracks: tracks,
})
if err != nil {
- fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to add tracks component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -231,10 +219,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- trackID := strings.Split(r.URL.Path, "/")[3]
+ trackID := r.PathValue("trackID")
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -246,7 +234,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
if err != nil {
- fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve new track component for %s: %v\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
diff --git a/admin/trackhttp.go b/admin/music/trackhttp.go
similarity index 69%
rename from admin/trackhttp.go
rename to admin/music/trackhttp.go
index bcb5220..942b3e9 100644
--- a/admin/trackhttp.go
+++ b/admin/music/trackhttp.go
@@ -1,10 +1,11 @@
-package admin
+package music
import (
"fmt"
"net/http"
- "strings"
+ "os"
+ "arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@@ -14,32 +15,24 @@ func serveTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/")
- trackID := slices[0]
-
- if len(trackID) > 0 {
- serveTrack(app, trackID).ServeHTTP(w, r)
- return
- }
-
tracks, err := controller.GetAllTracks(app.DB)
if err != nil {
- fmt.Printf("WARN: Failed to fetch tracks: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TracksResponse struct {
- adminPageData
+ core.AdminPageData
Tracks []*model.Track
}
err = templates.TracksTemplate.Execute(w, TracksResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Tracks: tracks,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin tracks page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -51,7 +44,7 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
- fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -62,24 +55,24 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil {
- fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases for track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TrackResponse struct {
- adminPageData
+ core.AdminPageData
Track *model.Track
Releases []*model.Release
}
err = templates.EditTrackTemplate.Execute(w, TrackResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Track: track,
Releases: releases,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
diff --git a/admin/static/admin.css b/admin/static/admin.css
index 8f983c7..d80e477 100644
--- a/admin/static/admin.css
+++ b/admin/static/admin.css
@@ -111,7 +111,7 @@ body {
font-family: "Inter", sans-serif;
font-size: 16px;
color: var(--fg-0);
- background: var(--bg-0);
+ background-color: var(--bg-0);
transition: background .1s ease-out, color .1s ease-out;
}
@@ -252,12 +252,6 @@ a {
transition: color .1s ease-out, background-color .1s ease-out;
}
-/*
-a:hover {
- text-decoration: underline;
-}
-*/
-
img.icon {
height: .8em;
transition: filter .1s ease-out;
@@ -283,7 +277,7 @@ code {
.card {
flex-basis: 40em;
padding: 1em;
- background: var(--bg-1);
+ background-color: var(--bg-1);
border-radius: 16px;
box-shadow: var(--shadow-lg);
@@ -361,7 +355,7 @@ a.delete:not(.button) {
font-size: inherit;
color: inherit;
- background: var(--bg-2);
+ background-color: var(--bg-2);
border: none;
border-radius: 10em;
box-shadow: var(--shadow-sm);
@@ -380,27 +374,27 @@ button:active, .button:active {
.button.new, button.new {
color: var(--col-on-new);
- background: var(--col-new);
+ background-color: var(--col-new);
}
.button.save, button.save {
color: var(--col-on-save);
- background: var(--col-save);
+ background-color: var(--col-save);
}
.button.delete, button.delete {
color: var(--col-on-delete);
- background: var(--col-delete);
+ background-color: var(--col-delete);
}
.button:hover, button:hover {
color: var(--bg-3);
- background: var(--fg-3);
+ background-color: var(--fg-3);
}
.button:active, button:active {
color: var(--bg-2);
- background: var(--fg-0);
+ background-color: var(--fg-0);
}
.button[disabled], button[disabled] {
color: var(--fg-0) !important;
- background: var(--bg-3) !important;
+ background-color: var(--bg-3) !important;
opacity: .5;
cursor: default !important;
}
@@ -436,6 +430,39 @@ input[disabled] {
cursor: not-allowed;
}
+
+
+.actions {
+ margin-top: .5em;
+ display: flex;
+ flex-direction: row;
+ gap: .5em;
+ user-select: none;
+ color: var(--fg-3);
+}
+.actions a,
+.actions button {
+ padding: .3em .5em;
+ display: inline-block;
+
+ border-radius: 4px;
+ background-color: var(--bg-3);
+ box-shadow: var(--shadow-sm);
+
+ transition-property: color, background, transform;
+ transition-duration: .1s;
+ transition-timing-function: ease-out;
+}
+.actions a:hover,
+.actions button:hover {
+ background-color: var(--bg-0);
+ color: var(--fg-3);
+ text-decoration: none;
+ transform: scale(1.05);
+}
+
+
+
@media screen and (max-width: 720px) {
main {
padding-top: 0;
diff --git a/admin/static/artists.css b/admin/static/artists.css
index 516a998..904bc70 100644
--- a/admin/static/artists.css
+++ b/admin/static/artists.css
@@ -2,18 +2,22 @@
padding: .5em;
color: var(--fg-3);
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
text-align: center;
cursor: pointer;
- transition: background .1s ease-out, color .1s ease-out;
+
+ transition-property: background, color, transform;
+ transition-duration: .1s;
+ transition-timing-function: ease-out;
}
.artist:hover {
- background: var(--bg-1);
+ background-color: var(--bg-1);
text-decoration: hover;
+ transform: scale(1.1);
}
.artist .artist-avatar {
diff --git a/admin/static/artists.js b/admin/static/artists.js
index 29eab22..3a0e326 100644
--- a/admin/static/artists.js
+++ b/admin/static/artists.js
@@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => {
document.querySelectorAll(".artists-group .artist").forEach(el => {
hijackClickEvent(el, el.querySelector("a.artist-name"))
});
+
+ const newArtistBtn = document.getElementById("create-artist");
+ if (newArtistBtn) 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/artists/" + id;
+ } else {
+ alert(text);
+ console.error(text);
+ }
+ })
+ }).catch(err => {
+ alert("Failed to create artist. Check the console for details.");
+ console.error(err);
+ });
+ });
});
diff --git a/admin/static/blog.css b/admin/static/blog.css
new file mode 100644
index 0000000..da3929e
--- /dev/null
+++ b/admin/static/blog.css
@@ -0,0 +1,62 @@
+.blog-collection {
+ margin-bottom: 1em;
+ display: flex;
+ flex-direction: column;
+ gap: .5em;
+}
+
+.blog-collection h2 {
+ margin: 0 0 0 1em;
+ font-size: 1em;
+ text-transform: uppercase;
+ font-weight: 600;
+ color: var(--fg-0);
+}
+
+.blogpost {
+ padding: 1em;
+ display: block;
+ border-radius: 8px;
+ background-color: var(--bg-2);
+ box-shadow: var(--shadow-md);
+}
+
+.blogpost .title {
+ margin: 0;
+ font-size: 1.5em;
+}
+
+.blogpost .title small {
+ display: inline-block;
+ font-size: .6em;
+ transform: translateY(-0.1em);
+ color: var(--fg-0);
+}
+
+.blogpost .description {
+ margin: .5em 0 .6em 0;
+ color: var(--fg-1);
+}
+
+.blogpost .meta {
+ margin: 0;
+ font-size: .8em;
+ color: var(--fg-0);
+}
+
+.blogpost .meta .author {
+ color: var(--fg-1);
+}
+
+.blogpost .meta .author img {
+ width: 1.3em;
+ height: 1.3em;
+ margin-right: .2em;
+ display: inline-block;
+ transform: translate(0, 4px);
+ border-radius: 4px;
+}
+
+.blogpost a:hover {
+ text-decoration: underline;
+}
diff --git a/admin/static/blog.js b/admin/static/blog.js
new file mode 100644
index 0000000..3ce9111
--- /dev/null
+++ b/admin/static/blog.js
@@ -0,0 +1,25 @@
+document.addEventListener('readystatechange', () => {
+ const newBlogBtn = document.getElementById("create-post");
+ if (newBlogBtn) newBlogBtn.addEventListener("click", event => {
+ event.preventDefault();
+ const id = prompt("Enter an ID for this blog post:");
+ if (id == null || id == "") return;
+
+ fetch("/api/v1/blog", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({id})
+ }).then(res => {
+ if (res.ok) location = "/admin/blogs/" + id;
+ else {
+ res.text().then(err => {
+ alert(err);
+ console.error(err);
+ });
+ }
+ }).catch(err => {
+ alert("Failed to create release. Check the console for details.");
+ console.error(err);
+ });
+ });
+});
diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css
index c43d6e9..8e89cbe 100644
--- a/admin/static/edit-account.css
+++ b/admin/static/edit-account.css
@@ -33,7 +33,7 @@ form#delete-account input {
justify-content: space-between;
color: var(--fg-3);
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
}
diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css
index 7bf146b..37b016c 100644
--- a/admin/static/edit-artist.css
+++ b/admin/static/edit-artist.css
@@ -6,7 +6,7 @@
gap: 1.2em;
border-radius: 16px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@@ -50,26 +50,11 @@ input[type="text"] {
font-family: inherit;
font-weight: inherit;
color: inherit;
- background: var(--bg-0);
+ background-color: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
}
-input[type="text"]:hover {
- border-color: #80808080;
-}
-input[type="text"]:active,
-input[type="text"]:focus {
- border-color: #808080;
-}
-
-.artist-actions {
- margin-top: auto;
- display: flex;
- gap: .5em;
- flex-direction: row;
- justify-content: right;
-}
.card-header a.button {
text-decoration: none;
@@ -84,7 +69,7 @@ input[type="text"]:focus {
align-items: center;
border-radius: 16px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
@@ -92,7 +77,7 @@ input[type="text"]:focus {
}
.credit:hover {
- background: var(--bg-1);
+ background-color: var(--bg-1);
}
.release-artwork {
diff --git a/admin/static/edit-blog.css b/admin/static/edit-blog.css
new file mode 100644
index 0000000..788d7fb
--- /dev/null
+++ b/admin/static/edit-blog.css
@@ -0,0 +1,140 @@
+input[type="text"] {
+ padding: .3em .5em;
+ font-size: inherit;
+ font-family: inherit;
+ border: none;
+ border-radius: 4px;
+ outline: none;
+ color: var(--fg-3);
+ background-color: var(--bg-2);
+ box-shadow: var(--shadow-sm);
+
+ transition: background-color .1s ease-out, color .1s ease-out;
+}
+
+#blogpost {
+ margin-bottom: 1em;
+ padding: 1.5em;
+
+ border-radius: 8px;
+ background-color: var(--bg-1);
+ box-shadow: var(--shadow-lg);
+
+ transition: background-color .1s ease-out, color .1s ease-out;
+}
+
+#blogpost label {
+ margin: 1.2em 0 .2em .1em;
+ display: block;
+ font-size: .8em;
+ text-transform: uppercase;
+ font-weight: 600;
+}
+#blogpost label:first-of-type {
+ margin-top: 0;
+}
+
+#blogpost button#set-current-date {
+ margin: 0 .5em;
+ padding: .4em .8em;
+}
+
+#blogpost h2 {
+ margin: 0;
+ font-size: 2em;
+}
+
+#blogpost #title {
+ width: 100%;
+ margin: 0 -.2em;
+ padding: 0 .2em;
+ resize: none;
+ font-family: inherit;
+ font-size: inherit;
+ font-weight: bold;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--fg-3);
+ outline: none;
+ cursor: pointer;
+
+ transition: background-color .1s ease-out, color .1s ease-out, border-color .1s ease-out;
+
+ /*position: relative; outline: none;*/
+ white-space: pre-wrap; overflow-wrap: break-word;
+}
+
+#blogpost #title:hover {
+ background-color: var(--bg-3);
+ border-color: var(--fg-0);
+}
+
+#blogpost #title:active,
+#blogpost #title:focus {
+ background-color: var(--bg-3);
+}
+
+#blogpost #publish-date {
+ padding: .4em .5em;
+ font-family: inherit;
+ font-size: inherit;
+ border-radius: 4px;
+ border: none;
+ background-color: var(--bg-2);
+ color: var(--fg-3);
+ box-shadow: var(--shadow-sm);
+
+ transition: background-color .1s ease-out, color .1s ease-out;
+}
+
+#blogpost textarea {
+ width: calc(100% - 2em);
+ margin: 0;
+ padding: 1em;
+ display: block;
+ border: none;
+ border-radius: 4px;
+ background-color: var(--bg-2);
+ color: var(--fg-3);
+ box-shadow: var(--shadow-md);
+ resize: vertical;
+ outline: none;
+
+ transition: background-color .1s ease-out, color .1s ease-out;
+}
+
+#blogpost #description {
+ font-family: inherit;
+}
+
+#blogpost select {
+ padding: .5em .8em;
+ font-size: inherit;
+ border: none;
+ border-radius: 10em;
+ color: var(--fg-3);
+ background-color: var(--bg-2);
+ box-shadow: var(--shadow-sm);
+
+ transition: background-color .1s ease-out, color .1s ease-out;
+}
+
+#blogpost .social-post-details {
+ margin: 1em 0 1em 0;
+ display: flex;
+ gap: 1em;
+}
+
+#blogpost .blog-actions {
+ margin-top: 1em;
+}
+
+@media (prefers-color-scheme: dark) {
+ input[type="text"],
+ #blogpost #publish-date,
+ #blogpost textarea,
+ #blogpost select {
+ background-color: var(--bg-0);
+ }
+}
diff --git a/admin/static/edit-blog.js b/admin/static/edit-blog.js
new file mode 100644
index 0000000..2c5911f
--- /dev/null
+++ b/admin/static/edit-blog.js
@@ -0,0 +1,83 @@
+const blogID = document.getElementById("blogpost").dataset.id;
+const titleInput = document.getElementById("title");
+const publishDateInput = document.getElementById("publish-date");
+const setCurrentDateBtn = document.getElementById("set-current-date");
+const descInput = document.getElementById("description");
+const mdInput = document.getElementById("markdown");
+const blueskyActorInput = document.getElementById("bluesky-actor");
+const blueskyRecordInput = document.getElementById("bluesky-record");
+const fediverseAccountInput = document.getElementById("fediverse-account");
+const fediverseStatusInput = document.getElementById("fediverse-status");
+const visInput = document.getElementById("visibility");
+const saveBtn = document.getElementById("save");
+const deleteBtn = document.getElementById("delete");
+
+setCurrentDateBtn.addEventListener("click", () => {
+ let now = new Date;
+ now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
+ publishDateInput.value = now.toISOString().slice(0, 16);
+ saveBtn.disabled = false;
+});
+
+saveBtn.addEventListener("click", () => {
+ fetch("/api/v1/blog/" + blogID, {
+ method: "PUT",
+ body: JSON.stringify({
+ title: titleInput.innerText,
+ publish_date: publishDateInput.value + ":00Z",
+ description: descInput.value,
+ markdown: mdInput.value,
+ bluesky: {
+ actor: blueskyActorInput.value,
+ record: blueskyRecordInput.value,
+ },
+ fediverse: {
+ account: fediverseAccountInput.value,
+ status: fediverseStatusInput.value,
+ },
+ visible: visInput.value === "true",
+ }),
+ headers: { "Content-Type": "application/json" }
+ }).then(res => {
+ if (!res.ok) {
+ res.text().then(error => {
+ console.error(error);
+ alert("Failed to update blog post: " + error);
+ });
+ return;
+ }
+
+ location = location;
+ });
+});
+
+deleteBtn.addEventListener("click", () => {
+ if (blogID != prompt(
+ "You are about to permanently delete " + blogID + ". " +
+ "This action is irreversible. " +
+ "Please enter \"" + blogID + "\" to continue.")) return;
+ fetch("/api/v1/blog/" + blogID, {
+ method: "DELETE",
+ }).then(res => {
+ if (!res.ok) {
+ res.text().then(error => {
+ console.error(error);
+ alert("Failed to delete blog post: " + error);
+ });
+ return;
+ }
+
+ location = "/admin";
+ });
+});
+
+[titleInput, publishDateInput, descInput, mdInput, visInput,
+ blueskyActorInput, blueskyRecordInput,
+ fediverseAccountInput, fediverseStatusInput].forEach(input => {
+ input.addEventListener("change", () => {
+ saveBtn.disabled = false;
+ });
+ input.addEventListener("keypress", () => {
+ saveBtn.disabled = false;
+ });
+ });
diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css
index 434b487..bbd9fdc 100644
--- a/admin/static/edit-release.css
+++ b/admin/static/edit-release.css
@@ -12,7 +12,7 @@ input[type="text"] {
gap: 1.2em;
border-radius: 8px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@@ -25,6 +25,8 @@ input[type="text"] {
.release-artwork img {
width: 100%;
aspect-ratio: 1;
+ border-radius: 8px;
+ box-shadow: var(--shadow-md);
}
.release-artwork img:hover {
outline: 1px solid #808080;
@@ -33,7 +35,10 @@ input[type="text"] {
.release-artwork #remove-artwork {
margin-top: .5em;
padding: .3em .6em;
- background: var(--bg-3);
+ background-color: var(--bg-3);
+}
+#remove-artwork:hover {
+ background-color: var(--fg-3);
}
.release-info {
@@ -62,13 +67,13 @@ input[type="text"] {
}
#title:hover {
- background: var(--bg-3);
+ background-color: var(--bg-3);
border-color: var(--fg-0);
}
#title:active,
#title:focus {
- background: var(--bg-3);
+ background-color: var(--bg-3);
}
.release-title small {
@@ -93,7 +98,7 @@ input[type="text"] {
.release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover {
- background: var(--bg-3);
+ background-color: var(--bg-3);
cursor: pointer;
}
.release-info table td select,
@@ -127,7 +132,7 @@ input[type="text"] {
.release-actions button,
.release-actions .button {
color: var(--fg-2);
- background: var(--bg-3);
+ background-color: var(--bg-3);
}
dialog {
@@ -234,7 +239,7 @@ dialog div.dialog-actions {
gap: 1em;
border-radius: 8px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@@ -280,7 +285,7 @@ dialog div.dialog-actions {
border: none;
border-radius: 4px;
color: var(--fg-2);
- background: var(--bg-0);
+ background-color: var(--bg-0);
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
@@ -299,6 +304,7 @@ dialog div.dialog-actions {
#editcredits .credit .delete {
margin-right: .5em;
cursor: pointer;
+ overflow: visible;
}
#editcredits .credit .delete:hover {
text-decoration: underline;
@@ -315,14 +321,17 @@ dialog div.dialog-actions {
display: flex;
gap: .5em;
cursor: pointer;
+ background-color: var(--bg-2);
}
#addcredit ul li.new-artist:nth-child(even) {
background: #f0f0f0;
+ background-color: var(--bg-1);
}
#addcredit ul li.new-artist:hover {
background: #e0e0e0;
+ background-color: var(--bg-2);
}
#addcredit .new-artist .artist-id {
@@ -375,6 +384,8 @@ dialog div.dialog-actions {
#editlinks tr {
display: flex;
+ background-color: var(--bg-1);
+ transition: background-color .1s ease-out;
}
#editlinks th {
@@ -385,7 +396,7 @@ dialog div.dialog-actions {
}
#editlinks tr:nth-child(odd) {
- background: #f8f8f8;
+ background-color: var(--bg-2);
}
#editlinks tr th,
@@ -416,6 +427,11 @@ dialog div.dialog-actions {
width: 1em;
pointer-events: none;
}
+@media (prefers-color-scheme: dark) {
+ #editlinks tr .grabber img {
+ filter: invert();
+ }
+}
#editlinks tr .link-name {
width: 8em;
}
@@ -454,6 +470,7 @@ dialog div.dialog-actions {
}
#edittracks .track {
+ background-color: var(--bg-1);
transition: transform .2s ease-out, opacity .2s;
}
@@ -476,7 +493,7 @@ dialog div.dialog-actions {
}
#edittracks .track:nth-child(even) {
- background: #f0f0f0;
+ background-color: var(--bg-0);
}
#edittracks .track-number {
@@ -492,24 +509,23 @@ dialog div.dialog-actions {
#addtrack ul {
padding: 0;
list-style: none;
- background: #f8f8f8;
}
#addtrack ul li.new-track {
padding: .5em;
display: flex;
gap: .5em;
- background-color: var(--bg-0);
+ background-color: var(--bg-1);
cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out;
}
#addtrack ul li.new-track:nth-child(even) {
- background: color-mix(in srgb, var(--bg-0) 95%, #fff);
+ background-color: var(--bg-0);
}
#addtrack ul li.new-track:hover {
- background: color-mix(in srgb, var(--bg-0) 90%, #fff);
+ background-color: var(--bg-2);
}
@media only screen and (max-width: 1105px) {
diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css
index f292ca5..4824124 100644
--- a/admin/static/edit-track.css
+++ b/admin/static/edit-track.css
@@ -8,7 +8,7 @@
gap: 1.2em;
border-radius: 16px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@@ -45,25 +45,13 @@
font-weight: inherit;
font-family: inherit;
font-size: inherit;
- background: var(--bg-0);
+ background-color: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
color: inherit;
}
-.track-info input[type="text"]:hover,
-.track-info textarea:hover {
- border-color: #80808080;
-}
-
-.track-info input[type="text"]:active,
-.track-info textarea:active,
-.track-info input[type="text"]:focus,
-.track-info textarea:focus {
- border-color: #808080;
-}
-
.track-actions {
margin-top: 1em;
display: flex;
diff --git a/admin/static/index.css b/admin/static/index.css
new file mode 100644
index 0000000..0e5121f
--- /dev/null
+++ b/admin/static/index.css
@@ -0,0 +1 @@
+@import url("/admin/static/release-list-item.css");
diff --git a/admin/static/logs.css b/admin/static/logs.css
index 8da60d0..2412a2b 100644
--- a/admin/static/logs.css
+++ b/admin/static/logs.css
@@ -8,7 +8,7 @@ form#search-form {
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@@ -23,7 +23,7 @@ div#search {
border: none;
border-radius: 16px;
color: var(--fg-1);
- background: var(--bg-0);
+ background-color: var(--bg-0);
box-shadow: var(--shadow-sm);
}
@@ -100,8 +100,8 @@ td.log-content {
#logs .log.warn {
color: var(--col-on-warn);
- background: var(--col-warn);
+ background-color: var(--col-warn);
}
#logs .log.warn:hover {
- background: var(--col-warn-hover);
+ background-color: var(--col-warn-hover);
}
diff --git a/admin/static/music.css b/admin/static/music.css
new file mode 100644
index 0000000..9fcd731
--- /dev/null
+++ b/admin/static/music.css
@@ -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;
+}
diff --git a/admin/static/index.js b/admin/static/music.js
similarity index 92%
rename from admin/static/index.js
rename to admin/static/music.js
index 60bdfd0..8bb192f 100644
--- a/admin/static/index.js
+++ b/admin/static/music.js
@@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
- if (res.ok) location = "/admin/releases/" + id;
+ if (res.ok) location = "/admin/music/releases/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
@@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
- location = "/admin/artists/" + id;
+ location = "/admin/music/artists/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
@@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
- location = "/admin/tracks/" + text;
+ location = "/admin/music/tracks/" + text;
} else {
alert("Request failed: " + text);
console.error(text);
diff --git a/admin/static/releases.css b/admin/static/releases.css
index 0694875..7b7a483 100644
--- a/admin/static/releases.css
+++ b/admin/static/releases.css
@@ -6,7 +6,7 @@
gap: 1em;
border-radius: 16px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@@ -54,27 +54,3 @@
flex-wrap: wrap;
gap: .5em;
}
-
-.release .release-actions {
- margin-top: .5em;
- user-select: none;
- color: var(--fg-3);
-}
-
-.release .release-actions a {
- margin-right: .3em;
- padding: .3em .5em;
- display: inline-block;
-
- border-radius: 4px;
- background: var(--bg-3);
- box-shadow: var(--shadow-sm);
-
- transition: color .1s ease-out, background .1s ease-out;
-}
-
-.release .release-actions a:hover {
- background: var(--bg-0);
- color: var(--fg-3);
- text-decoration: none;
-}
diff --git a/admin/static/releases.js b/admin/static/releases.js
new file mode 100644
index 0000000..a606fde
--- /dev/null
+++ b/admin/static/releases.js
@@ -0,0 +1,25 @@
+document.addEventListener('readystatechange', () => {
+ const newReleaseBtn = document.getElementById("create-release");
+ if (newReleaseBtn) 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/releases/" + id;
+ else {
+ res.text().then(err => {
+ alert(err);
+ console.error(err);
+ });
+ }
+ }).catch(err => {
+ alert("Failed to create release. Check the console for details.");
+ console.error(err);
+ });
+ });
+});
diff --git a/admin/static/tracks.css b/admin/static/tracks.css
index c36c1b1..3ea4f06 100644
--- a/admin/static/tracks.css
+++ b/admin/static/tracks.css
@@ -12,7 +12,7 @@
gap: .5em;
border-radius: 16px;
- background: var(--bg-2);
+ background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@@ -44,11 +44,6 @@
opacity: .5;
}
-#tracks .track-album.empty {
- color: #ff2020;
- opacity: 1;
-}
-
#tracks .track-description {
font-style: italic;
}
@@ -67,61 +62,4 @@
margin: 0;
display: flex;
flex-direction: row;
- /*
- justify-content: space-between;
- */
}
-
-/*
-.track {
- margin-bottom: 1em;
- padding: 1em;
- display: flex;
- flex-direction: column;
- gap: .5em;
-
- border-radius: 8px;
- background-color: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- transition: color .1s ease-out, background-color .1s ease-out;
-}
-
-.track p {
- margin: 0;
-}
-
-.track-id {
- width: fit-content;
- font-family: "Monaspace Argon", monospace;
- font-size: .8em;
- font-style: italic;
- line-height: 1em;
- user-select: all;
-}
-
-.track-album {
- margin-left: auto;
- font-style: italic;
- font-size: .75em;
- opacity: .5;
-}
-
-.track-album.empty {
- color: #ff2020;
- opacity: 1;
-}
-
-.track-description {
- font-style: italic;
-}
-
-.track-lyrics {
- max-height: 10em;
- overflow-y: scroll;
-}
-
-.track .empty {
- opacity: 0.75;
-}
-*/
diff --git a/admin/static/tracks.js b/admin/static/tracks.js
new file mode 100644
index 0000000..3cf7867
--- /dev/null
+++ b/admin/static/tracks.js
@@ -0,0 +1,24 @@
+const newTrackBtn = document.getElementById("create-track");
+if (newTrackBtn) 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/tracks/" + text;
+ } else {
+ alert(text);
+ console.error(text);
+ }
+ })
+ }).catch(err => {
+ alert("Failed to create track. Check the console for details.");
+ console.error(err);
+ });
+});
diff --git a/admin/templates/html/blogs.html b/admin/templates/html/blogs.html
new file mode 100644
index 0000000..143d848
--- /dev/null
+++ b/admin/templates/html/blogs.html
@@ -0,0 +1,33 @@
+{{define "head"}}
+
There are no blog posts.
+ {{end}} +{{.PrintArtists true true}}
{{.ReleaseType}} - ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})
- diff --git a/admin/templates/html/components/track/addtrack.html b/admin/templates/html/components/track/addtrack.html index 6a2360b..002b1d4 100644 --- a/admin/templates/html/components/track/addtrack.html +++ b/admin/templates/html/components/track/addtrack.html @@ -8,7 +8,7 @@