- {{$track.Add $i 1}}
+ {{.Add $i 1}}
{{$track.Title}}
Delete
diff --git a/admin/http.go b/admin/http.go
index b44cfa9..6fd8f59 100644
--- a/admin/http.go
+++ b/admin/http.go
@@ -2,40 +2,62 @@ package admin
import (
"context"
+ "database/sql"
"fmt"
"net/http"
"os"
"path/filepath"
+ "strings"
+ "time"
"arimelody-web/controller"
"arimelody-web/model"
+
+ "golang.org/x/crypto/bcrypt"
)
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
- mux.Handle("/login", LoginHandler(app))
- mux.Handle("/register", createAccountHandler(app))
- mux.Handle("/logout", RequireAccount(app, LogoutHandler(app)))
- mux.Handle("/account", RequireAccount(app, AccountHandler(app)))
+ mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ w.Write([]byte("

"))
+ }))
+
+ mux.Handle("/login", loginHandler(app))
+ mux.Handle("/logout", requireAccount(app, logoutHandler(app)))
+
+ mux.Handle("/register", registerAccountHandler(app))
+
+ mux.Handle("/account", requireAccount(app, accountIndexHandler(app)))
+ mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app))))
+
+ mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app))))
+ mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
+ mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app))))
+
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
- mux.Handle("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app))))
- mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
- mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
- mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
+ mux.Handle("/", requireAccount(app, AdminIndexHandler(app)))
+
+ // response wrapper to make sure a session cookie exists
+ return enforceSession(app, mux)
+}
+
+func AdminIndexHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
- account, err := controller.GetAccountByRequest(app.DB, r)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
- }
- if account == nil {
- http.Redirect(w, r, "/admin/login", http.StatusFound)
- return
- }
+ session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
@@ -52,52 +74,328 @@ func Handler(app *model.AppState) http.Handler {
}
tracks, err := controller.GetOrphanTracks(app.DB)
- if err != nil {
+ if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
type IndexData struct {
- Account *model.Account
+ Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
- err = pages["index"].Execute(w, IndexData{
- Account: account,
+ err = indexTemplate.Execute(w, IndexData{
+ Session: session,
Releases: releases,
Artists: artists,
Tracks: tracks,
})
- if err != nil {
+ if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }))
-
- return mux
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
}
-func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
+func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- account, err := controller.GetAccountByRequest(app.DB, r)
+ session := r.Context().Value("session").(*model.Session)
+
+ if session.Account != nil {
+ // user is already logged in
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ return
+ }
+
+ type registerData struct {
+ Session *model.Session
+ }
+
+ render := func() {
+ err := registerTemplate.Execute(w, registerData{ Session: session })
+ if err != nil {
+ fmt.Printf("WARN: Error rendering create account page: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ }
+
+ if r.Method == http.MethodGet {
+ render()
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+
+ err := r.ParseForm()
if err != nil {
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ type RegisterRequest struct {
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Invite string `json:"invite"`
+ }
+ credentials := RegisterRequest{
+ Username: r.Form.Get("username"),
+ Email: r.Form.Get("email"),
+ Password: r.Form.Get("password"),
+ Invite: r.Form.Get("invite"),
+ }
+
+ // make sure invite code exists in DB
+ invite, err := controller.GetInvite(app.DB, credentials.Invite)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ if invite == nil || time.Now().After(invite.ExpiresAt) {
+ if invite != nil {
+ err := controller.DeleteInvite(app.DB, invite.Code)
+ if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
+ }
+ controller.SetSessionError(app.DB, session, "Invalid invite code.")
+ render()
+ return
+ }
+
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+
+ account := model.Account{
+ Username: credentials.Username,
+ Password: string(hashedPassword),
+ Email: sql.NullString{ String: credentials.Email, Valid: true },
+ AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
+ }
+ err = controller.CreateAccount(app.DB, &account)
+ if err != nil {
+ if strings.HasPrefix(err.Error(), "pq: duplicate key") {
+ controller.SetSessionError(app.DB, session, "An account with that username already exists.")
+ render()
+ return
+ }
+ fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+
+ fmt.Printf(
+ "[%s]: Account registered: %s (%s)\n",
+ time.Now().Format(time.UnixDate),
+ account.Username,
+ account.ID,
+ )
+
+ err = controller.DeleteInvite(app.DB, invite.Code)
+ if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
+
+ // registration success!
+ controller.SetSessionAccount(app.DB, session, &account)
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ })
+}
+
+func loginHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+
+ type loginData struct {
+ Session *model.Session
+ }
+
+ render := func() {
+ err := loginTemplate.Execute(w, loginData{ Session: session })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if r.Method == http.MethodGet {
+ if session.Account != nil {
+ // user is already logged in
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ return
+ }
+
+ render()
+ return
+ }
+
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ type LoginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ TOTP string `json:"totp"`
+ }
+ credentials := LoginRequest{
+ Username: r.Form.Get("username"),
+ Password: r.Form.Get("password"),
+ TOTP: r.Form.Get("totp"),
+ }
+
+ account, err := controller.GetAccountByUsername(app.DB, credentials.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
+ }
+
+ err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
+ if err != nil {
+ fmt.Printf(
+ "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n",
+ time.Now().Format(time.UnixDate),
+ account.Username,
+ )
+ controller.SetSessionError(app.DB, session, "Invalid username or password.")
+ render()
+ return
+ }
+
+ var totpMethod *model.TOTP
+ if len(credentials.TOTP) == 0 {
+ // check if user has TOTP
+ 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 {
+ type loginTOTPData struct {
+ Session *model.Session
+ Username string
+ Password string
+ }
+ err = loginTOTPTemplate.Execute(w, loginTOTPData{
+ Session: session,
+ Username: credentials.Username,
+ Password: credentials.Password,
+ })
+ 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
+ }
+ }
+ } else {
+ totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP)
+ 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 totpMethod == nil {
+ controller.SetSessionError(app.DB, session, "Invalid TOTP.")
+ render()
+ return
+ }
+ }
+
+ if totpMethod != nil {
+ fmt.Printf(
+ "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
+ time.Now().Format(time.UnixDate),
+ account.Username,
+ totpMethod.Name,
+ )
+ } else {
+ fmt.Printf(
+ "[%s] INFO: Account \"%s\" logged in\n",
+ time.Now().Format(time.UnixDate),
+ account.Username,
+ )
+ }
+
+ // TODO: log login activity to user
+
+ // login 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 logoutHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+ err := controller.DeleteSession(app.DB, session.Token)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: model.COOKIE_TOKEN,
+ Expires: time.Now(),
+ Path: "/",
+ })
+
+ err = logoutTemplate.Execute(w, nil)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
+}
+
+func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+ if session.Account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
-
- ctx := context.WithValue(r.Context(), "account", account)
-
- next.ServeHTTP(w, r.WithContext(ctx))
+ next.ServeHTTP(w, r)
})
}
@@ -121,3 +419,53 @@ func staticHandler() http.Handler {
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}
+
+func enforceSession(app *model.AppState, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
+ if err != nil && err != http.ErrNoCookie {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ var session *model.Session
+
+ if sessionCookie != nil {
+ // fetch existing session
+ session, err = controller.GetSession(app.DB, sessionCookie.Value)
+
+ if err != nil && !strings.Contains(err.Error(), "no rows") {
+ 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 {
+ // TODO: consider running security checks here (i.e. user agent mismatches)
+ }
+ }
+
+ 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/releasehttp.go b/admin/releasehttp.go
index 503166b..be5052b 100644
--- a/admin/releasehttp.go
+++ b/admin/releasehttp.go
@@ -14,7 +14,7 @@ func serveRelease(app *model.AppState) http.Handler {
slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
- account := r.Context().Value("account").(*model.Account)
+ session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
@@ -56,12 +56,12 @@ func serveRelease(app *model.AppState) http.Handler {
}
type ReleaseResponse struct {
- Account *model.Account
+ Session *model.Session
Release *model.Release
}
- err = pages["release"].Execute(w, ReleaseResponse{
- Account: account,
+ err = releaseTemplate.Execute(w, ReleaseResponse{
+ Session: session,
Release: release,
})
if err != nil {
@@ -74,7 +74,7 @@ func serveRelease(app *model.AppState) http.Handler {
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
- err := components["editcredits"].Execute(w, release)
+ err := editCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
- err = components["addcredit"].Execute(w, response{
+ err = addCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
@@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
- err = components["newcredit"].Execute(w, artist)
+ err = newCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -134,7 +134,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
- err := components["editlinks"].Execute(w, release)
+ err := editLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -145,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler {
func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
- err := components["edittracks"].Execute(w, release)
+
+ type editTracksData struct {
+ Release *model.Release
+ Add func(a int, b int) int
+ }
+
+ err := editTracksTemplate.Execute(w, editTracksData{
+ Release: release,
+ Add: func(a, b int) int { return a + b },
+ })
if err != nil {
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -168,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
- err = components["addtrack"].Execute(w, response{
+ err = addTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
@@ -195,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
- err = components["newtrack"].Execute(w, track)
+ err = newTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
diff --git a/admin/static/admin.css b/admin/static/admin.css
index 32f69bb..877b5da 100644
--- a/admin/static/admin.css
+++ b/admin/static/admin.css
@@ -24,7 +24,7 @@ nav {
justify-content: left;
background: #f8f8f8;
- border-radius: .5em;
+ border-radius: 4px;
border: 1px solid #808080;
}
nav .icon {
@@ -85,6 +85,15 @@ a img.icon {
height: .8em;
}
+code {
+ background: #303030;
+ color: #f0f0f0;
+ padding: .23em .3em;
+ border-radius: 4px;
+}
+
+
+
.card {
margin-bottom: 1em;
}
@@ -93,13 +102,6 @@ a img.icon {
margin: 0 0 .5em 0;
}
-/*
-.card h3,
-.card p {
- margin: 0;
-}
-*/
-
.card-title {
margin-bottom: 1em;
display: flex;
@@ -127,20 +129,34 @@ a img.icon {
+#message,
#error {
- background: #ffa9b8;
- border: 1px solid #dc5959;
+ margin: 0 0 1em 0;
padding: 1em;
border-radius: 4px;
+ background: #ffffff;
+ border: 1px solid #888;
+}
+#message {
+ background: #a9dfff;
+ border-color: #599fdc;
+}
+#error {
+ background: #ffa9b8;
+ border-color: #dc5959;
}
+a.delete:not(.button) {
+ color: #d22828;
+}
+
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
- border-radius: .5em;
+ border-radius: 4px;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
@@ -154,35 +170,59 @@ button:active, .button:active {
border-color: #808080;
}
-button {
+.button, button {
color: inherit;
}
-button.new {
+.button.new, button.new {
background: #c4ff6a;
border-color: #84b141;
}
-button.save {
+.button.save, button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
-button.delete {
+.button.delete, button.delete {
background: #ff7171;
border-color: #7d3535;
}
-button:hover {
+.button:hover, button:hover {
background: #fff;
border-color: #d0d0d0;
}
-button:active {
+.button:active, button:active {
background: #d0d0d0;
border-color: #808080;
}
-button[disabled] {
+.button[disabled], button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
-a.delete {
- color: #d22828;
+
+
+
+form {
+ width: 100%;
+ display: block;
+}
+form label {
+ width: 100%;
+ margin: 1rem 0 .5rem 0;
+ display: block;
+ color: #10101080;
+}
+form input {
+ margin: .5rem 0;
+ padding: .3rem .5rem;
+ display: block;
+ border-radius: 4px;
+ border: 1px solid #808080;
+ font-size: inherit;
+ font-family: inherit;
+ color: inherit;
+}
+input[disabled] {
+ opacity: .5;
+ cursor: not-allowed;
}
diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css
index 625db13..7a4d34a 100644
--- a/admin/static/edit-account.css
+++ b/admin/static/edit-account.css
@@ -1,28 +1,18 @@
@import url("/admin/static/index.css");
-form#change-password {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: start;
-}
-
-form div {
- width: 20rem;
-}
-
-form button {
- margin-top: 1rem;
+div.card {
+ margin-bottom: 2rem;
}
label {
- width: 100%;
- margin: 1rem 0 .5rem 0;
- display: block;
- color: #10101080;
+ width: auto;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ color: inherit;
}
input {
- width: 100%;
+ width: min(20rem, calc(100% - 1rem));
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
@@ -33,18 +23,11 @@ input {
color: inherit;
}
-#error {
- background: #ffa9b8;
- border: 1px solid #dc5959;
- padding: 1em;
- border-radius: 4px;
-}
-
.mfa-device {
padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
- border-radius: .5em;
+ border-radius: 8px;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css
index 793b989..5627e64 100644
--- a/admin/static/edit-artist.css
+++ b/admin/static/edit-artist.css
@@ -9,7 +9,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css
index 10eada3..aa70e34 100644
--- a/admin/static/edit-release.css
+++ b/admin/static/edit-release.css
@@ -11,7 +11,7 @@ input[type="text"] {
flex-direction: row;
gap: 1.2em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@@ -160,7 +160,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@@ -170,7 +170,7 @@ dialog div.dialog-actions {
}
.card.credits .credit .artist-avatar {
- border-radius: .5em;
+ border-radius: 8px;
}
.card.credits .credit .artist-name {
@@ -196,7 +196,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@@ -215,7 +215,7 @@ dialog div.dialog-actions {
}
#editcredits .credit .artist-avatar {
- border-radius: .5em;
+ border-radius: 8px;
}
#editcredits .credit .credit-info {
@@ -228,12 +228,14 @@ dialog div.dialog-actions {
}
#editcredits .credit .credit-info .credit-attribute label {
+ width: auto;
+ margin: 0;
display: flex;
align-items: center;
}
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
- margin-left: .25em;
+ margin: 0 0 0 .25em;
padding: .2em .4em;
flex-grow: 1;
font-family: inherit;
@@ -241,6 +243,9 @@ dialog div.dialog-actions {
border-radius: 4px;
color: inherit;
}
+#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
+ margin: 0 .3em;
+}
#editcredits .credit .artist-name {
font-weight: bold;
@@ -369,8 +374,10 @@ dialog div.dialog-actions {
#editlinks td input[type="text"] {
width: calc(100% - .6em);
height: 100%;
+ margin: 0;
padding: 0 .3em;
border: none;
+ border-radius: 0;
outline: none;
cursor: pointer;
background: none;
@@ -393,7 +400,7 @@ dialog div.dialog-actions {
flex-direction: column;
gap: .5em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css
index 8a05089..600b680 100644
--- a/admin/static/edit-track.css
+++ b/admin/static/edit-track.css
@@ -11,7 +11,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
diff --git a/admin/static/index.css b/admin/static/index.css
index 9d38940..9fcd731 100644
--- a/admin/static/index.css
+++ b/admin/static/index.css
@@ -1,23 +1,5 @@
@import url("/admin/static/release-list-item.css");
-.create-btn {
- background: #c4ff6a;
- padding: .5em .8em;
- border-radius: .5em;
- border: 1px solid #84b141;
- text-decoration: none;
-}
-.create-btn:hover {
- background: #fff;
- border-color: #d0d0d0;
- text-decoration: inherit;
-}
-.create-btn:active {
- background: #d0d0d0;
- border-color: #808080;
- text-decoration: inherit;
-}
-
.artist {
margin-bottom: .5em;
padding: .5em;
@@ -26,7 +8,7 @@
align-items: center;
gap: .5em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@@ -49,7 +31,7 @@
flex-direction: column;
gap: .5em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css
index ee67de7..638eac0 100644
--- a/admin/static/release-list-item.css
+++ b/admin/static/release-list-item.css
@@ -5,7 +5,7 @@
flex-direction: row;
gap: 1em;
- border-radius: .5em;
+ border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@@ -50,7 +50,7 @@
padding: .5em;
display: block;
- border-radius: .5em;
+ border-radius: 8px;
text-decoration: none;
color: #f0f0f0;
background: #303030;
@@ -73,7 +73,7 @@
padding: .3em .5em;
display: inline-block;
- border-radius: .3em;
+ border-radius: 4px;
background: #e0e0e0;
transition: color .1s, background .1s;
diff --git a/admin/templates.go b/admin/templates.go
index 1fa7a65..49c118b 100644
--- a/admin/templates.go
+++ b/admin/templates.go
@@ -1,65 +1,90 @@
package admin
import (
- "html/template"
- "path/filepath"
+ "html/template"
+ "path/filepath"
)
-var pages = map[string]*template.Template{
- "index": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "components", "release", "release-list-item.html"),
- filepath.Join("admin", "views", "index.html"),
- )),
+var indexTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "components", "release", "release-list-item.html"),
+ filepath.Join("admin", "views", "index.html"),
+))
- "login": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "login.html"),
- )),
- "create-account": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "create-account.html"),
- )),
- "logout": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "logout.html"),
- )),
- "account": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "edit-account.html"),
- )),
+var loginTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "login.html"),
+))
+var loginTOTPTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "login-totp.html"),
+))
+var registerTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "register.html"),
+))
+var logoutTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "logout.html"),
+))
+var accountTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-account.html"),
+))
+var totpSetupTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "totp-setup.html"),
+))
+var totpConfirmTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "totp-confirm.html"),
+))
- "release": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "edit-release.html"),
- )),
- "artist": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "views", "edit-artist.html"),
- )),
- "track": template.Must(template.ParseFiles(
- filepath.Join("admin", "views", "layout.html"),
- filepath.Join("views", "prideflag.html"),
- filepath.Join("admin", "components", "release", "release-list-item.html"),
- filepath.Join("admin", "views", "edit-track.html"),
- )),
-}
+var releaseTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-release.html"),
+))
+var artistTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-artist.html"),
+))
+var trackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "components", "release", "release-list-item.html"),
+ filepath.Join("admin", "views", "edit-track.html"),
+))
-var components = map[string]*template.Template{
- "editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))),
- "addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))),
- "newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))),
+var editCreditsTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "editcredits.html"),
+))
+var addCreditTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "addcredit.html"),
+))
+var newCreditTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "newcredit.html"),
+))
- "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
+var editLinksTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "links", "editlinks.html"),
+))
- "edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))),
- "addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))),
- "newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))),
-}
+var editTracksTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "edittracks.html"),
+))
+var addTrackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "addtrack.html"),
+))
+var newTrackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "newtrack.html"),
+))
diff --git a/admin/trackhttp.go b/admin/trackhttp.go
index fa49b53..a92f81a 100644
--- a/admin/trackhttp.go
+++ b/admin/trackhttp.go
@@ -32,15 +32,15 @@ func serveTrack(app *model.AppState) http.Handler {
}
type TrackResponse struct {
- Account *model.Account
+ Session *model.Session
Track *model.Track
Releases []*model.Release
}
- account := r.Context().Value("account").(*model.Account)
+ session := r.Context().Value("session").(*model.Session)
- err = pages["track"].Execute(w, TrackResponse{
- Account: account,
+ err = trackTemplate.Execute(w, TrackResponse{
+ Session: session,
Track: track,
Releases: releases,
})
diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html
index 4d89052..6c17088 100644
--- a/admin/views/edit-account.html
+++ b/admin/views/edit-account.html
@@ -6,23 +6,27 @@
{{define "content"}}
- Account Settings ({{.Account.Username}})
+ {{if .Session.Message.Valid}}
+ {{html .Session.Message.String}}
+ {{end}}
+ {{if .Session.Error.Valid}}
+ {{html .Session.Error.String}}
+ {{end}}
+ Account Settings ({{.Session.Account.Username}})
Change Password
-
@@ -36,11 +40,11 @@
{{range .TOTPs}}
-
{{.Name}}
-
Added: {{.CreatedAt}}
+
{{.TOTP.Name}}
+
Added: {{.CreatedAtString}}
{{end}}
@@ -48,7 +52,10 @@
You have no MFA devices.
{{end}}
-
+
diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html
index ccb3a45..b0cfb41 100644
--- a/admin/views/edit-artist.html
+++ b/admin/views/edit-artist.html
@@ -36,13 +36,13 @@
{{if .Credits}}
{{range .Credits}}
-

+
-
-
{{.Artist.Release.PrintArtists true true}}
+
+
{{.Release.PrintArtists true true}}
- Role: {{.Artist.Role}}
- {{if .Artist.Primary}}
+ Role: {{.Role}}
+ {{if .Primary}}
(Primary)
{{end}}
diff --git a/admin/views/index.html b/admin/views/index.html
index 8f42e0e..2b9c897 100644
--- a/admin/views/index.html
+++ b/admin/views/index.html
@@ -9,7 +9,7 @@
{{range .Releases}}
@@ -22,7 +22,7 @@
{{range $Artist := .Artists}}
@@ -38,7 +38,7 @@
"Orphaned" tracks that have not yet been bound to a release.
diff --git a/admin/views/layout.html b/admin/views/layout.html
index 0a33b72..8c34c8e 100644
--- a/admin/views/layout.html
+++ b/admin/views/layout.html
@@ -24,9 +24,12 @@
home
- {{if .Account}}
+ {{if .Session.Account}}
+
{{else}}
diff --git a/admin/views/login-totp.html b/admin/views/login-totp.html
new file mode 100644
index 0000000..d959e3c
--- /dev/null
+++ b/admin/views/login-totp.html
@@ -0,0 +1,42 @@
+{{define "head"}}
+
Login - ari melody 💫
+
+
+
+{{end}}
+
+{{define "content"}}
+
+
+
+{{end}}
diff --git a/admin/views/login.html b/admin/views/login.html
index 7744e91..fbb7294 100644
--- a/admin/views/login.html
+++ b/admin/views/login.html
@@ -3,15 +3,7 @@
{{end}}
{{define "content"}}
- {{if .Message}}
- {{.Message}}
+ {{if .Session.Message.Valid}}
+ {{html .Session.Message.String}}
+ {{end}}
+ {{if .Session.Error.Valid}}
+ {{html .Session.Error.String}}
{{end}}
- {{if .Token}}
-
-
-
- Logged in successfully.
- You should be redirected to /admin soon.
-
-
- {{else}}
-
-
- {{end}}
{{end}}
diff --git a/admin/views/logout.html b/admin/views/logout.html
index 1999377..f127fd6 100644
--- a/admin/views/logout.html
+++ b/admin/views/logout.html
@@ -12,13 +12,10 @@ p a {
{{define "content"}}
-
+
Logged out successfully.
- You should be redirected to / in 5 seconds.
-
+ You should be redirected to /admin/login shortly.
diff --git a/admin/views/create-account.html b/admin/views/register.html
similarity index 55%
rename from admin/views/create-account.html
rename to admin/views/register.html
index 8d59c0f..37f0947 100644
--- a/admin/views/create-account.html
+++ b/admin/views/register.html
@@ -11,7 +11,7 @@ a.discord {
color: #5865F2;
}
-form {
+form#register {
width: 100%;
display: flex;
flex-direction: column;
@@ -26,45 +26,33 @@ form button {
margin-top: 1rem;
}
-label {
- width: 100%;
- margin: 1rem 0 .5rem 0;
- display: block;
- color: #10101080;
-}
input {
- width: 100%;
- margin: .5rem 0;
- padding: .3rem .5rem;
- display: block;
- border-radius: 4px;
- border: 1px solid #808080;
- font-size: inherit;
- font-family: inherit;
- color: inherit;
+ width: calc(100% - 1rem - 2px);
}
{{end}}
{{define "content"}}
- {{if .Message}}
- {{.Message}}
+ {{if .Session.Error.Valid}}
+ {{html .Session.Error.String}}
{{end}}
-