merge feature/accountsettings into dev

This commit is contained in:
ari melody 2025-01-26 20:09:55 +00:00
commit d2ac66a81a
Signed by: ari
GPG key ID: CF99829C92678188
46 changed files with 1594 additions and 816 deletions

View file

@ -3,8 +3,8 @@ package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"strings"
"time" "time"
"arimelody-web/controller" "arimelody-web/controller"
@ -13,29 +13,58 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type TemplateData struct { func accountHandler(app *model.AppState) http.Handler {
Account *model.Account mux := http.NewServeMux()
Message string
Token string mux.Handle("/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
mux.Handle("/password", changePasswordHandler(app))
mux.Handle("/delete", deleteAccountHandler(app))
return mux
} }
func AccountHandler(app *model.AppState) http.Handler { func accountIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := r.Context().Value("account").(*model.Account) session := r.Context().Value("session").(*model.Session)
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
type AccountResponse struct { type (
Account *model.Account TOTP struct {
TOTPs []model.TOTP model.TOTP
CreatedAtString string
}
accountResponse struct {
Session *model.Session
TOTPs []TOTP
}
)
totps := []TOTP{}
for _, totp := range dbTOTPs {
totps = append(totps, TOTP{
TOTP: totp,
CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"),
})
} }
err = pages["account"].Execute(w, AccountResponse{ sessionMessage := session.Message
Account: account, sessionError := session.Error
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
session.Message = sessionMessage
session.Error = sessionError
err = accountTemplate.Execute(w, accountResponse{
Session: session,
TOTPs: totps, TOTPs: totps,
}) })
if err != nil { if err != nil {
@ -45,299 +74,307 @@ func AccountHandler(app *model.AppState) http.Handler {
}) })
} }
func LoginHandler(app *model.AppState) http.Handler { func changePasswordHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method != http.MethodPost {
account, err := controller.GetAccountByRequest(app.DB, r) http.NotFound(w, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
err = pages["login"].Execute(w, TemplateData{})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
return return
} }
type LoginResponse struct { session := r.Context().Value("session").(*model.Session)
Account *model.Account
Token string controller.SetSessionMessage(app.DB, session, "")
Message string controller.SetSessionError(app.DB, session, "")
r.ParseForm()
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
} }
render := func(data LoginResponse) { newPassword := r.Form.Get("new-password")
err := pages["login"].Execute(w, data)
if err != nil { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
return controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
} http.Redirect(w, r, "/admin/account", http.StatusFound)
return
} }
session.Account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, session.Account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func deleteAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.NotFound(w, r); http.NotFound(w, r)
return return
} }
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
render(LoginResponse{ Message: "Malformed request." }) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
type LoginRequest struct { if !r.Form.Has("password") || !r.Form.Has("totp") {
Username string `json:"username"` http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
Password string `json:"password"` return
TOTP string `json:"totp"`
}
credentials := LoginRequest{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"),
} }
account, err := controller.GetAccount(app.DB, credentials.Username) session := r.Context().Value("session").(*model.Session)
// check password
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
fmt.Printf(
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp"))
if err != nil { if err != nil {
render(LoginResponse{ Message: "Invalid username or password" }) fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return return
} }
if account == nil { if totpMethod == nil {
render(LoginResponse{ Message: "Invalid username or password" }) fmt.Printf(
return "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
} }
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) err = controller.DeleteAccount(app.DB, session.Account.ID)
if err != nil { if err != nil {
render(LoginResponse{ Message: "Invalid username or password" }) fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return return
} }
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) fmt.Printf(
if err != nil { "[%s] INFO: Account \"%s\" deleted by user request.\n",
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) time.Now().Format(time.UnixDate),
render(LoginResponse{ Message: "Something went wrong. Please try again." }) session.Account.Username,
return )
}
if len(totps) > 0 {
success := false
for _, totp := range totps {
check := controller.GenerateTOTP(totp.Secret, 0)
if check == credentials.TOTP {
success = true
break
}
}
if !success {
render(LoginResponse{ Message: "Invalid TOTP" })
return
}
} else {
// TODO: user should be prompted to add 2FA method
}
// login success! controller.SetSessionAccount(app.DB, session, nil)
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) controller.SetSessionError(app.DB, session, "")
if err != nil { controller.SetSessionMessage(app.DB, session, "Account deleted successfully.")
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) http.Redirect(w, r, "/admin/login", http.StatusFound)
render(LoginResponse{ Message: "Something went wrong. Please try again." })
return
}
cookie := http.Cookie{}
cookie.Name = model.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(app.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
render(LoginResponse{ Account: account, Token: token.Token })
}) })
} }
func LogoutHandler(app *model.AppState) http.Handler { func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
type totpSetupData struct {
Session *model.Session
}
session := r.Context().Value("session").(*model.Session)
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type totpSetupData struct {
Session *model.Session
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: session.Account.ID,
Name: name,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := totpSetupTemplate.Execute(w, totpSetupData{ 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)
}
return
}
qrBase64Image, err := controller.GenerateQRCode(
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
if err != nil {
fmt.Printf("WARN: Failed to generate TOTP setup QR code: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := totpSetupTemplate.Execute(w, totpSetupData{ 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)
}
return
}
err = totpConfirmTemplate.Execute(w, totpSetupData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func totpConfirmHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type totpConfirmData struct {
Session *model.Session
TOTP *model.TOTP
}
session := r.Context().Value("session").(*model.Session)
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
code := r.FormValue("totp")
if len(code) != controller.TOTP_CODE_LENGTH {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
if code != confirmCode {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.")
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
})
return
}
}
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
tokenStr := controller.GetTokenFromRequest(app.DB, r) if len(r.URL.Path) < 2 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
if len(tokenStr) > 0 { return
err := controller.DeleteToken(app.DB, tokenStr)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
} }
name := r.URL.Path[1:]
cookie := http.Cookie{} session := r.Context().Value("session").(*model.Session)
cookie.Name = model.COOKIE_TOKEN
cookie.Value = ""
cookie.Expires = time.Now()
if strings.HasPrefix(app.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/admin/login", http.StatusFound)
})
}
func createAccountHandler(app *model.AppState) http.Handler { totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to fetch account: %s\n", err) fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return return
} }
if checkAccount != nil { if totp == nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type CreateAccountResponse struct {
Account *model.Account
Message string
}
render := func(data CreateAccountResponse) {
err := pages["create-account"].Execute(w, data)
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render(CreateAccountResponse{})
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
err = r.ParseForm() err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
if err != nil { if err != nil {
render(CreateAccountResponse{ fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
Message: "Malformed data.", controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
}) http.Redirect(w, r, "/admin/account", http.StatusFound)
return return
} }
type RegisterRequest struct { controller.SetSessionError(app.DB, session, "")
Username string `json:"username"` controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
Email string `json:"email"` http.Redirect(w, r, "/admin/account", http.StatusFound)
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 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)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
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) }
}
render(CreateAccountResponse{
Message: "Invalid invite code.",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
render(CreateAccountResponse{
Message: "An account with that username already exists.",
})
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
// registration success!
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
// gracefully redirect user to login page
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
cookie := http.Cookie{}
cookie.Name = model.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(app.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: &account,
Token: token.Token,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}) })
} }

View file

@ -32,15 +32,15 @@ func serveArtist(app *model.AppState) http.Handler {
} }
type ArtistResponse struct { type ArtistResponse struct {
Account *model.Account Session *model.Session
Artist *model.Artist Artist *model.Artist
Credits []*model.Credit Credits []*model.Credit
} }
account := r.Context().Value("account").(*model.Account) session := r.Context().Value("session").(*model.Session)
err = pages["artist"].Execute(w, ArtistResponse{ err = artistTemplate.Execute(w, ArtistResponse{
Account: account, Session: session,
Artist: artist, Artist: artist,
Credits: credits, Credits: credits,
}) })

View file

@ -3,20 +3,20 @@
<h2>Editing: Tracks</h2> <h2>Editing: Tracks</h2>
<a id="add-track" <a id="add-track"
class="button new" class="button new"
href="/admin/release/{{.ID}}/addtrack" href="/admin/release/{{.Release.ID}}/addtrack"
hx-get="/admin/release/{{.ID}}/addtrack" hx-get="/admin/release/{{.Release.ID}}/addtrack"
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
>Add</a> >Add</a>
</header> </header>
<form action="/api/v1/music/{{.ID}}/tracks"> <form action="/api/v1/music/{{.Release.ID}}/tracks">
<ul> <ul>
{{range $i, $track := .Tracks}} {{range $i, $track := .Release.Tracks}}
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true"> <li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
<div> <div>
<p class="track-name"> <p class="track-name">
<span class="track-number">{{$track.Add $i 1}}</span> <span class="track-number">{{.Add $i 1}}</span>
{{$track.Title}} {{$track.Title}}
</p> </p>
<a class="delete">Delete</a> <a class="delete">Delete</a>

View file

@ -2,40 +2,62 @@ package admin
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
"golang.org/x/crypto/bcrypt"
) )
func Handler(app *model.AppState) http.Handler { func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/login", LoginHandler(app)) mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/register", createAccountHandler(app)) qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
mux.Handle("/logout", RequireAccount(app, LogoutHandler(app))) if err != nil {
mux.Handle("/account", RequireAccount(app, AccountHandler(app))) 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("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
}))
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("/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("/", requireAccount(app, AdminIndexHandler(app)))
mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 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 != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
account, err := controller.GetAccountByRequest(app.DB, r) session := r.Context().Value("session").(*model.Session)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
}
if account == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true) releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil { if err != nil {
@ -52,52 +74,328 @@ func Handler(app *model.AppState) http.Handler {
} }
tracks, err := controller.GetOrphanTracks(app.DB) 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) fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
type IndexData struct { type IndexData struct {
Account *model.Account Session *model.Session
Releases []*model.Release Releases []*model.Release
Artists []*model.Artist Artists []*model.Artist
Tracks []*model.Track Tracks []*model.Track
} }
err = pages["index"].Execute(w, IndexData{ err = indexTemplate.Execute(w, IndexData{
Account: account, Session: session,
Releases: releases, Releases: releases,
Artists: artists, Artists: artists,
Tracks: tracks, Tracks: tracks,
}) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
})) })
return mux
} }
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) { 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 { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 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 return
} }
if account == nil { 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 // TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound) http.Redirect(w, r, "/admin/login", http.StatusFound)
return return
} }
next.ServeHTTP(w, r)
ctx := context.WithValue(r.Context(), "account", account)
next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@ -121,3 +419,53 @@ func staticHandler() http.Handler {
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
}) })
} }
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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))
})
}

View file

@ -14,7 +14,7 @@ func serveRelease(app *model.AppState) http.Handler {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0] 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) release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil { if err != nil {
@ -56,12 +56,12 @@ func serveRelease(app *model.AppState) http.Handler {
} }
type ReleaseResponse struct { type ReleaseResponse struct {
Account *model.Account Session *model.Session
Release *model.Release Release *model.Release
} }
err = pages["release"].Execute(w, ReleaseResponse{ err = releaseTemplate.Execute(w, ReleaseResponse{
Account: account, Session: session,
Release: release, Release: release,
}) })
if err != nil { if err != nil {
@ -74,7 +74,7 @@ func serveRelease(app *model.AppState) http.Handler {
func serveEditCredits(release *model.Release) http.Handler { func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := components["editcredits"].Execute(w, release) err := editCreditsTemplate.Execute(w, release)
if err != nil { if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err = components["addcredit"].Execute(w, response{ err = addCreditTemplate.Execute(w, response{
ReleaseID: release.ID, ReleaseID: release.ID,
Artists: artists, Artists: artists,
}) })
@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err = components["newcredit"].Execute(w, artist) err = newCreditTemplate.Execute(w, artist)
if err != nil { if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -134,7 +134,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler { func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := components["editlinks"].Execute(w, release) err := editLinksTemplate.Execute(w, release)
if err != nil { if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -145,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler {
func serveEditTracks(release *model.Release) http.Handler { func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := 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 { if err != nil {
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 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") w.Header().Set("Content-Type", "text/html")
err = components["addtrack"].Execute(w, response{ err = addTrackTemplate.Execute(w, response{
ReleaseID: release.ID, ReleaseID: release.ID,
Tracks: tracks, Tracks: tracks,
}) })
@ -195,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err = components["newtrack"].Execute(w, track) err = newTrackTemplate.Execute(w, track)
if err != nil { if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -24,7 +24,7 @@ nav {
justify-content: left; justify-content: left;
background: #f8f8f8; background: #f8f8f8;
border-radius: .5em; border-radius: 4px;
border: 1px solid #808080; border: 1px solid #808080;
} }
nav .icon { nav .icon {
@ -85,6 +85,15 @@ a img.icon {
height: .8em; height: .8em;
} }
code {
background: #303030;
color: #f0f0f0;
padding: .23em .3em;
border-radius: 4px;
}
.card { .card {
margin-bottom: 1em; margin-bottom: 1em;
} }
@ -93,13 +102,6 @@ a img.icon {
margin: 0 0 .5em 0; margin: 0 0 .5em 0;
} }
/*
.card h3,
.card p {
margin: 0;
}
*/
.card-title { .card-title {
margin-bottom: 1em; margin-bottom: 1em;
display: flex; display: flex;
@ -127,20 +129,34 @@ a img.icon {
#message,
#error { #error {
background: #ffa9b8; margin: 0 0 1em 0;
border: 1px solid #dc5959;
padding: 1em; padding: 1em;
border-radius: 4px; 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 { button, .button {
padding: .5em .8em; padding: .5em .8em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
border-radius: .5em; border-radius: 4px;
border: 1px solid #a0a0a0; border: 1px solid #a0a0a0;
background: #f0f0f0; background: #f0f0f0;
color: inherit; color: inherit;
@ -154,35 +170,59 @@ button:active, .button:active {
border-color: #808080; border-color: #808080;
} }
button { .button, button {
color: inherit; color: inherit;
} }
button.new { .button.new, button.new {
background: #c4ff6a; background: #c4ff6a;
border-color: #84b141; border-color: #84b141;
} }
button.save { .button.save, button.save {
background: #6fd7ff; background: #6fd7ff;
border-color: #6f9eb0; border-color: #6f9eb0;
} }
button.delete { .button.delete, button.delete {
background: #ff7171; background: #ff7171;
border-color: #7d3535; border-color: #7d3535;
} }
button:hover { .button:hover, button:hover {
background: #fff; background: #fff;
border-color: #d0d0d0; border-color: #d0d0d0;
} }
button:active { .button:active, button:active {
background: #d0d0d0; background: #d0d0d0;
border-color: #808080; border-color: #808080;
} }
button[disabled] { .button[disabled], button[disabled] {
background: #d0d0d0 !important; background: #d0d0d0 !important;
border-color: #808080 !important; border-color: #808080 !important;
opacity: .5; opacity: .5;
cursor: not-allowed !important; 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;
} }

View file

@ -1,28 +1,18 @@
@import url("/admin/static/index.css"); @import url("/admin/static/index.css");
form#change-password { div.card {
width: 100%; margin-bottom: 2rem;
display: flex;
flex-direction: column;
align-items: start;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
} }
label { label {
width: 100%; width: auto;
margin: 1rem 0 .5rem 0; margin: 0;
display: block; display: flex;
color: #10101080; align-items: center;
color: inherit;
} }
input { input {
width: 100%; width: min(20rem, calc(100% - 1rem));
margin: .5rem 0; margin: .5rem 0;
padding: .3rem .5rem; padding: .3rem .5rem;
display: block; display: block;
@ -33,18 +23,11 @@ input {
color: inherit; color: inherit;
} }
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
.mfa-device { .mfa-device {
padding: .75em; padding: .75em;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
border-radius: .5em; border-radius: 8px;
margin-bottom: .5em; margin-bottom: .5em;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -9,7 +9,7 @@ h1 {
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }

View file

@ -11,7 +11,7 @@ input[type="text"] {
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -160,7 +160,7 @@ dialog div.dialog-actions {
align-items: center; align-items: center;
gap: 1em; gap: 1em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -170,7 +170,7 @@ dialog div.dialog-actions {
} }
.card.credits .credit .artist-avatar { .card.credits .credit .artist-avatar {
border-radius: .5em; border-radius: 8px;
} }
.card.credits .credit .artist-name { .card.credits .credit .artist-name {
@ -196,7 +196,7 @@ dialog div.dialog-actions {
align-items: center; align-items: center;
gap: 1em; gap: 1em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -215,7 +215,7 @@ dialog div.dialog-actions {
} }
#editcredits .credit .artist-avatar { #editcredits .credit .artist-avatar {
border-radius: .5em; border-radius: 8px;
} }
#editcredits .credit .credit-info { #editcredits .credit .credit-info {
@ -228,12 +228,14 @@ dialog div.dialog-actions {
} }
#editcredits .credit .credit-info .credit-attribute label { #editcredits .credit .credit-info .credit-attribute label {
width: auto;
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
} }
#editcredits .credit .credit-info .credit-attribute input[type="text"] { #editcredits .credit .credit-info .credit-attribute input[type="text"] {
margin-left: .25em; margin: 0 0 0 .25em;
padding: .2em .4em; padding: .2em .4em;
flex-grow: 1; flex-grow: 1;
font-family: inherit; font-family: inherit;
@ -241,6 +243,9 @@ dialog div.dialog-actions {
border-radius: 4px; border-radius: 4px;
color: inherit; color: inherit;
} }
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
}
#editcredits .credit .artist-name { #editcredits .credit .artist-name {
font-weight: bold; font-weight: bold;
@ -369,8 +374,10 @@ dialog div.dialog-actions {
#editlinks td input[type="text"] { #editlinks td input[type="text"] {
width: calc(100% - .6em); width: calc(100% - .6em);
height: 100%; height: 100%;
margin: 0;
padding: 0 .3em; padding: 0 .3em;
border: none; border: none;
border-radius: 0;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
background: none; background: none;
@ -393,7 +400,7 @@ dialog div.dialog-actions {
flex-direction: column; flex-direction: column;
gap: .5em; gap: .5em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }

View file

@ -11,7 +11,7 @@ h1 {
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }

View file

@ -1,23 +1,5 @@
@import url("/admin/static/release-list-item.css"); @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 { .artist {
margin-bottom: .5em; margin-bottom: .5em;
padding: .5em; padding: .5em;
@ -26,7 +8,7 @@
align-items: center; align-items: center;
gap: .5em; gap: .5em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -49,7 +31,7 @@
flex-direction: column; flex-direction: column;
gap: .5em; gap: .5em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }

View file

@ -5,7 +5,7 @@
flex-direction: row; flex-direction: row;
gap: 1em; gap: 1em;
border-radius: .5em; border-radius: 8px;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -50,7 +50,7 @@
padding: .5em; padding: .5em;
display: block; display: block;
border-radius: .5em; border-radius: 8px;
text-decoration: none; text-decoration: none;
color: #f0f0f0; color: #f0f0f0;
background: #303030; background: #303030;
@ -73,7 +73,7 @@
padding: .3em .5em; padding: .3em .5em;
display: inline-block; display: inline-block;
border-radius: .3em; border-radius: 4px;
background: #e0e0e0; background: #e0e0e0;
transition: color .1s, background .1s; transition: color .1s, background .1s;

View file

@ -1,65 +1,90 @@
package admin package admin
import ( import (
"html/template" "html/template"
"path/filepath" "path/filepath"
) )
var pages = map[string]*template.Template{ var indexTemplate = template.Must(template.ParseFiles(
"index": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"),
filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"), filepath.Join("admin", "views", "index.html"),
filepath.Join("admin", "views", "index.html"), ))
)),
"login": template.Must(template.ParseFiles( var loginTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"), filepath.Join("admin", "views", "login.html"),
)), ))
"create-account": template.Must(template.ParseFiles( var loginTOTPTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "create-account.html"), filepath.Join("admin", "views", "login-totp.html"),
)), ))
"logout": template.Must(template.ParseFiles( var registerTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"), filepath.Join("admin", "views", "register.html"),
)), ))
"account": template.Must(template.ParseFiles( var logoutTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.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( var releaseTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"), filepath.Join("admin", "views", "edit-release.html"),
)), ))
"artist": template.Must(template.ParseFiles( var artistTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"), filepath.Join("admin", "views", "edit-artist.html"),
)), ))
"track": template.Must(template.ParseFiles( var trackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"), filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "edit-track.html"), filepath.Join("admin", "views", "edit-track.html"),
)), ))
}
var components = map[string]*template.Template{ var editCreditsTemplate = template.Must(template.ParseFiles(
"editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))), 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 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"))), var editTracksTemplate = template.Must(template.ParseFiles(
"addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))), filepath.Join("admin", "components", "tracks", "edittracks.html"),
"newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.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"),
))

View file

@ -32,15 +32,15 @@ func serveTrack(app *model.AppState) http.Handler {
} }
type TrackResponse struct { type TrackResponse struct {
Account *model.Account Session *model.Session
Track *model.Track Track *model.Track
Releases []*model.Release Releases []*model.Release
} }
account := r.Context().Value("account").(*model.Account) session := r.Context().Value("session").(*model.Session)
err = pages["track"].Execute(w, TrackResponse{ err = trackTemplate.Execute(w, TrackResponse{
Account: account, Session: session,
Track: track, Track: track,
Releases: releases, Releases: releases,
}) })

View file

@ -6,23 +6,27 @@
{{define "content"}} {{define "content"}}
<main> <main>
<h1>Account Settings ({{.Account.Username}})</h1> {{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title"> <div class="card-title">
<h2>Change Password</h2> <h2>Change Password</h2>
</div> </div>
<div class="card"> <div class="card">
<form action="/api/v1/change-password" method="POST" id="change-password"> <form action="/admin/account/password" method="POST" id="change-password">
<div> <label for="current-password">Current Password</label>
<label for="current-password">Current Password</label> <input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
<input type="password" name="current-password" value="" autocomplete="current-password">
<label for="new-password">Password</label> <label for="new-password">New Password</label>
<input type="password" name="new-password" value="" autocomplete="new-password"> <input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label> <label for="confirm-password">Confirm Password</label>
<input type="password" name="confirm-password" value="" autocomplete="new-password"> <input type="password" id="confirm-password" value="" autocomplete="new-password" required>
</div>
<button type="submit" class="save">Change Password</button> <button type="submit" class="save">Change Password</button>
</form> </form>
@ -36,11 +40,11 @@
{{range .TOTPs}} {{range .TOTPs}}
<div class="mfa-device"> <div class="mfa-device">
<div> <div>
<p class="mfa-device-name">{{.Name}}</p> <p class="mfa-device-name">{{.TOTP.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAt}}</p> <p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div> </div>
<div> <div>
<a class="delete">Delete</a> <a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
</div> </div>
</div> </div>
{{end}} {{end}}
@ -48,7 +52,10 @@
<p>You have no MFA devices.</p> <p>You have no MFA devices.</p>
{{end}} {{end}}
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button> <div>
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div>
</div> </div>
<div class="card-title"> <div class="card-title">
@ -58,9 +65,17 @@
<p> <p>
Clicking the button below will delete your account. Clicking the button below will delete your account.
This action is <strong>irreversible</strong>. This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision. You will need to enter your password and TOTP below.
</p> </p>
<button class="delete" id="delete">Delete Account</button> <form action="/admin/account/delete" method="POST">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<button type="submit" class="delete">Delete Account</button>
</form>
</div> </div>
</main> </main>

View file

@ -36,13 +36,13 @@
{{if .Credits}} {{if .Credits}}
{{range .Credits}} {{range .Credits}}
<div class="credit"> <div class="credit">
<img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info"> <div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3> <h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p> <p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role"> <p class="artist-role">
Role: {{.Artist.Role}} Role: {{.Role}}
{{if .Artist.Primary}} {{if .Primary}}
<small>(Primary)</small> <small>(Primary)</small>
{{end}} {{end}}
</p> </p>

View file

@ -9,7 +9,7 @@
<div class="card-title"> <div class="card-title">
<h1>Releases</h1> <h1>Releases</h1>
<a class="create-btn" id="create-release">Create New</a> <a class="button new" id="create-release">Create New</a>
</div> </div>
<div class="card releases"> <div class="card releases">
{{range .Releases}} {{range .Releases}}
@ -22,7 +22,7 @@
<div class="card-title"> <div class="card-title">
<h1>Artists</h1> <h1>Artists</h1>
<a class="create-btn" id="create-artist">Create New</a> <a class="button new" id="create-artist">Create New</a>
</div> </div>
<div class="card artists"> <div class="card artists">
{{range $Artist := .Artists}} {{range $Artist := .Artists}}
@ -38,7 +38,7 @@
<div class="card-title"> <div class="card-title">
<h1>Tracks</h1> <h1>Tracks</h1>
<a class="create-btn" id="create-track">Create New</a> <a class="button new" id="create-track">Create New</a>
</div> </div>
<div class="card tracks"> <div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p> <p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>

View file

@ -24,9 +24,12 @@
<a href="/admin">home</a> <a href="/admin">home</a>
</div> </div>
<div class="flex-fill"></div> <div class="flex-fill"></div>
{{if .Account}} {{if .Session.Account}}
<div class="nav-item"> <div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a> <a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>
</div> </div>
{{else}} {{else}}
<div class="nav-item"> <div class="nav-item">

View file

@ -0,0 +1,42 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
form#login-totp {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
<form action="/admin/login" method="POST" id="login-totp">
<h1>Two-Factor Authentication</h1>
<div>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
<input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="password" value="{{.Password}}">
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -3,15 +3,7 @@
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css"> <link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
p a { form#login {
color: #2a67c8;
}
a.discord {
color: #5865F2;
}
form {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -26,61 +18,33 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input { input {
width: 100%; width: calc(100% - 1rem - 2px);
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;
} }
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
{{if .Message}} {{if .Session.Message.Valid}}
<p id="error">{{.Message}}</p> <p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}} {{end}}
{{if .Token}}
<meta http-equiv="refresh" content="0;url=/admin/" />
<p>
Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> soon.
</p>
{{else}}
<form action="/admin/login" method="POST" id="login"> <form action="/admin/login" method="POST" id="login">
<h1>Log In</h1>
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username"> <input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password"> <input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code">
</div> </div>
<button type="submit" class="save">Login</button> <button type="submit" class="save">Login</button>
</form> </form>
{{end}}
</main> </main>
{{end}} {{end}}

View file

@ -12,13 +12,10 @@ p a {
{{define "content"}} {{define "content"}}
<main> <main>
<meta http-equiv="refresh" content="5;url=/" /> <meta http-equiv="refresh" content="0;url=/admin/login" />
<p> <p>
Logged out successfully. Logged out successfully.
You should be redirected to <a href="/">/</a> in 5 seconds. You should be redirected to <a href="/admin/login">/admin/login</a> shortly.
<script>
localStorage.removeItem("arime-token");
</script>
</p> </p>
</main> </main>

View file

@ -11,7 +11,7 @@ a.discord {
color: #5865F2; color: #5865F2;
} }
form { form#register {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -26,45 +26,33 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input { input {
width: 100%; width: calc(100% - 1rem - 2px);
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
} }
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
{{if .Message}} {{if .Session.Error.Valid}}
<p id="error">{{.Message}}</p> <p id="error">{{html .Session.Error.String}}</p>
{{end}} {{end}}
<form action="/admin/register" method="POST" id="create-account"> <form action="/admin/register" method="POST" id="register">
<h1>Create Account</h1>
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" value=""> <input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="email">Email</label> <label for="email">Email</label>
<input type="text" name="email" value=""> <input type="text" name="email" value="" autocomplete="email" required>
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" value=""> <input type="password" name="password" value="" autocomplete="new-password" required>
<label for="invite">Invite Code</label> <label for="invite">Invite Code</label>
<input type="text" name="invite" value=""> <input type="text" name="invite" value="" autocomplete="off" required>
</div> </div>
<button type="submit" class="new">Create Account</button> <button type="submit" class="new">Create Account</button>

View file

@ -0,0 +1,41 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
.qr-code {
border: 1px solid #8888;
}
code {
user-select: all;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
<p>
Scan the QR code above into your authentication app or password manager,
then enter your 2FA code below.
</p>
<p>
If the QR code does not work, you may also enter this secret code:
</p>
<p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-setup" method="POST" id="totp-setup">
<label for="totp-name">TOTP Device Name:</label>
<input type="text" name="totp-name" value="" autocomplete="off" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -1,11 +1,13 @@
package api package api
import ( import (
"context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"arimelody-web/admin"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
@ -36,10 +38,10 @@ func Handler(app *model.AppState) http.Handler {
ServeArtist(app, artist).ServeHTTP(w, r) ServeArtist(app, artist).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/artist/{id} (admin) // PUT /api/v1/artist/{id} (admin)
admin.RequireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin) // DELETE /api/v1/artist/{id} (admin)
admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -51,7 +53,7 @@ func Handler(app *model.AppState) http.Handler {
ServeAllArtists(app).ServeHTTP(w, r) ServeAllArtists(app).ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/artist (admin) // POST /api/v1/artist (admin)
admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r) requireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -78,10 +80,10 @@ func Handler(app *model.AppState) http.Handler {
ServeRelease(app, release).ServeHTTP(w, r) ServeRelease(app, release).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/music/{id} (admin) // PUT /api/v1/music/{id} (admin)
admin.RequireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin) // DELETE /api/v1/music/{id} (admin)
admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -93,7 +95,7 @@ func Handler(app *model.AppState) http.Handler {
ServeCatalog(app).ServeHTTP(w, r) ServeCatalog(app).ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/music (admin) // POST /api/v1/music (admin)
admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r) requireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -117,13 +119,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track/{id} (admin) // GET /api/v1/track/{id} (admin)
admin.RequireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/track/{id} (admin) // PUT /api/v1/track/{id} (admin)
admin.RequireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin) // DELETE /api/v1/track/{id} (admin)
admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -132,10 +134,10 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track (admin) // GET /api/v1/track (admin)
admin.RequireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/track (admin) // POST /api/v1/track (admin)
admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r) requireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -143,3 +145,51 @@ func Handler(app *model.AppState) http.Handler {
return mux return mux
} }
func requireAccount(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := getSession(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to get session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session.Account == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
var token string
// check cookies first
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err))
}
if sessionCookie != nil {
token = sessionCookie.Value
} else {
// check Authorization header
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if token == "" { return nil, nil }
// fetch existing session
session, err := controller.GetSession(app.DB, token)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err))
}
if session != nil {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
return session, nil
}

View file

@ -51,13 +51,8 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
} }
) )
account, err := controller.GetAccountByRequest(app.DB, r) session := r.Context().Value("session").(*model.Session)
if err != nil { show_hidden_releases := session != nil && session.Account != nil
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
show_hidden_releases := account != nil
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
if err != nil { if err != nil {

View file

@ -19,13 +19,8 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
privileged := false privileged := false
if !release.Visible { if !release.Visible {
account, err := controller.GetAccountByRequest(app.DB, r) session := r.Context().Value("session").(*model.Session)
if err != nil { if session != nil && session.Account != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
// TODO: check privilege on release // TODO: check privilege on release
privileged = true privileged = true
} }
@ -145,16 +140,11 @@ func ServeCatalog(app *model.AppState) http.Handler {
} }
catalog := []Release{} catalog := []Release{}
account, err := controller.GetAccountByRequest(app.DB, r) session := r.Context().Value("session").(*model.Session)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, release := range releases { for _, release := range releases {
if !release.Visible { if !release.Visible {
privileged := false privileged := false
if account != nil { if session != nil && session.Account != nil {
// TODO: check privilege on release // TODO: check privilege on release
privileged = true privileged = true
} }

View file

@ -2,8 +2,6 @@ package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
@ -21,7 +19,21 @@ func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
return accounts, nil return accounts, nil
} }
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) {
var account = model.Account{} var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
@ -49,12 +61,12 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
return &account, nil return &account, nil
} }
func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
if token == "" { return nil, nil } if sessionToken == "" { return nil, nil }
account := model.Account{} account := model.Account{}
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return nil, nil return nil, nil
@ -65,7 +77,7 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
return &account, nil return &account, nil
} }
func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { func GetSessionFromRequest(db *sqlx.DB, r *http.Request) string {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 { if len(tokenStr) > 0 {
return tokenStr return tokenStr
@ -78,29 +90,6 @@ func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
return cookie.Value return cookie.Value
} }
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
tokenStr := GetTokenFromRequest(db, r)
token, err := GetToken(db, tokenStr)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, errors.New("GetToken: " + err.Error())
}
// does user-agent match the token?
if r.UserAgent() != token.UserAgent {
// invalidate the token
DeleteToken(db, tokenStr)
fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent())
// TODO: log unauthorised activity to the user
return nil, errors.New("User agent mismatch")
}
return GetAccountByToken(db, tokenStr)
}
func CreateAccount(db *sqlx.DB, account *model.Account) error { func CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get( err := db.Get(
&account.ID, &account.ID,
@ -119,7 +108,7 @@ func CreateAccount(db *sqlx.DB, account *model.Account) error {
func UpdateAccount(db *sqlx.DB, account *model.Account) error { func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec( _, err := db.Exec(
"UPDATE account " + "UPDATE account " +
"SET username=$2, password=$3, email=$4, avatar_url=$5) " + "SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1", "WHERE id=$1",
account.ID, account.ID,
account.Username, account.Username,
@ -131,7 +120,7 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
return err return err
} }
func DeleteAccount(db *sqlx.DB, username string) error { func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE username=$1", username) _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err return err
} }

View file

@ -19,6 +19,7 @@ func GetConfig() model.Config {
config := model.Config{ config := model.Config{
BaseUrl: "https://arimelody.me", BaseUrl: "https://arimelody.me",
Host: "0.0.0.0",
Port: 8080, Port: 8080,
DB: model.DBConfig{ DB: model.DBConfig{
Host: "127.0.0.1", Host: "127.0.0.1",
@ -55,6 +56,7 @@ func handleConfigOverrides(config *model.Config) error {
var err error var err error
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }
if env, has := os.LookupEnv("ARIMELODY_HOST"); has { config.Host = env }
if env, has := os.LookupEnv("ARIMELODY_PORT"); has { if env, has := os.LookupEnv("ARIMELODY_PORT"); has {
config.Port, err = strconv.ParseInt(env, 10, 0) config.Port, err = strconv.ParseInt(env, 10, 0)
if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) } if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }

120
controller/qr.go Normal file
View file

@ -0,0 +1,120 @@
package controller
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"github.com/skip2/go-qrcode"
)
func GenerateQRCode(data string) (string, error) {
imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256)
if err != nil {
return "", err
}
base64Img := base64.StdEncoding.EncodeToString(imgBytes)
return base64Img, nil
}
// vvv DEPRECATED vvv
const margin = 4
type QRCodeECCLevel int64
const (
LOW QRCodeECCLevel = iota
MEDIUM
QUARTILE
HIGH
)
func noDepsGenerateQRCode() (string, error) {
version := 1
size := 0
size = 21 + version * 4
if version > 10 {
return "", errors.New(fmt.Sprintf("QR version %d not supported", version))
}
img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2))
// fill white
for y := range size + margin * 2 {
for x := range size + margin * 2 {
img.Set(x, y, color.White)
}
}
// draw alignment squares
drawLargeAlignmentSquare(margin, margin, img)
drawLargeAlignmentSquare(margin, margin + size - 7, img)
drawLargeAlignmentSquare(margin + size - 7, margin, img)
drawSmallAlignmentSquare(size - 5, size - 5, img)
/*
if version > 4 {
space := version * 3 - 2
end := size / space
for y := range size / space + 1 {
for x := range size / space + 1 {
if x == 0 && y == 0 { continue }
if x == 0 && y == end { continue }
if x == end && y == 0 { continue }
if x == end && y == end { continue }
drawSmallAlignmentSquare(
x * space + margin + 4,
y * space + margin + 4,
img,
)
}
}
}
*/
// draw timing bits
for i := margin + 6; i < size - 4; i++ {
if (i % 2 == 0) {
img.Set(i, margin + 6, color.Black)
img.Set(margin + 6, i, color.Black)
}
}
img.Set(margin + 8, size - 4, color.Black)
var imgBuf bytes.Buffer
err := png.Encode(&imgBuf, img)
if err != nil {
return "", err
}
base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes())
return "data:image/png;base64," + base64Img, nil
}
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 7 {
for xi := range 7 {
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
img.Set(x + xi, y + yi, color.Black)
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
}
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 5 {
for xi := range 5 {
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
img.Set(x + 2, y + 2, color.Black)
}

130
controller/session.go Normal file
View file

@ -0,0 +1,130 @@
package controller
import (
"database/sql"
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
session := model.Session{
Token: string(tokenString),
UserAgent: userAgent,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24),
}
_, err := db.Exec("INSERT INTO session " +
"(token, user_agent, created_at, expires_at) VALUES " +
"($1, $2, $3, $4)",
session.Token,
session.UserAgent,
session.CreatedAt,
session.ExpiresAt,
)
if err != nil {
return nil, err
}
return &session, nil
}
// func WriteSession(db *sqlx.DB, session *model.Session) error {
// _, err := db.Exec(
// "UPDATE session " +
// "SET account=$2,message=$3,error=$4 " +
// "WHERE token=$1",
// session.Token,
// session.Account.ID,
// session.Message,
// session.Error,
// )
// return err
// }
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
var err error
session.Account = account
if account == nil {
_, err = db.Exec("UPDATE session SET account=NULL WHERE token=$1", session.Token)
} else {
_, err = db.Exec("UPDATE session SET account=$2 WHERE token=$1", session.Token, account.ID)
}
return err
}
func SetSessionMessage(db *sqlx.DB, session *model.Session, message string) error {
var err error
if message == "" {
if !session.Message.Valid { return nil }
session.Message = sql.NullString{ }
_, err = db.Exec("UPDATE session SET message=NULL WHERE token=$1", session.Token)
} else {
session.Message = sql.NullString{ String: message, Valid: true }
_, err = db.Exec("UPDATE session SET message=$2 WHERE token=$1", session.Token, message)
}
return err
}
func SetSessionError(db *sqlx.DB, session *model.Session, message string) error {
var err error
if message == "" {
if !session.Error.Valid { return nil }
session.Error = sql.NullString{ }
_, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token)
} else {
session.Error = sql.NullString{ String: message, Valid: true }
_, err = db.Exec("UPDATE session SET error=$2 WHERE token=$1", session.Token, message)
}
return err
}
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
type dbSession struct {
model.Session
AccountID sql.NullString `db:"account"`
}
session := dbSession{}
err := db.Get(
&session,
"SELECT * FROM session WHERE token=$1",
token,
)
if err != nil {
return nil, err
}
if session.AccountID.Valid {
session.Account, err = GetAccountByID(db, session.AccountID.String)
if err != nil {
return nil, err
}
}
return &session.Session, err
}
// func GetAllSessionsForAccount(db *sqlx.DB, accountID string) ([]model.Session, error) {
// sessions := []model.Session{}
// err := db.Select(&sessions, "SELECT * FROM session WHERE account=$1 AND expires_at>current_timestamp", accountID)
// return sessions, err
// }
func DeleteAllSessionsForAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM session WHERE account=$1", accountID)
return err
}
func DeleteSession(db *sqlx.DB, token string) error {
_, err := db.Exec("DELETE FROM session WHERE token=$1", token)
return err
}

View file

@ -1,61 +0,0 @@
package controller
import (
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 32
func CreateToken(db *sqlx.DB, accountID string, userAgent string) (*model.Token, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
token := model.Token{
Token: string(tokenString),
AccountID: accountID,
UserAgent: userAgent,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24),
}
_, err := db.Exec("INSERT INTO token " +
"(token, account, user_agent, created_at, expires_at) VALUES " +
"($1, $2, $3, $4, $5)",
token.Token,
token.AccountID,
token.UserAgent,
token.CreatedAt,
token.ExpiresAt,
)
if err != nil {
return nil, err
}
return &token, nil
}
func GetToken(db *sqlx.DB, token_str string) (*model.Token, error) {
token := model.Token{}
err := db.Get(&token, "SELECT * FROM token WHERE token=$1", token_str)
return &token, err
}
func GetAllTokensForAccount(db *sqlx.DB, accountID string) ([]model.Token, error) {
tokens := []model.Token{}
err := db.Select(&tokens, "SELECT * FROM token WHERE account=$1 AND expires_at>current_timestamp", accountID)
return tokens, err
}
func DeleteAllTokensForAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM token WHERE account=$1", accountID)
return err
}
func DeleteToken(db *sqlx.DB, token string) error {
_, err := db.Exec("DELETE FROM token WHERE token=$1", token)
return err
}

View file

@ -18,8 +18,8 @@ import (
) )
const TOTP_SECRET_LENGTH = 32 const TOTP_SECRET_LENGTH = 32
const TIME_STEP int64 = 30 const TOTP_TIME_STEP int64 = 30
const CODE_LENGTH = 6 const TOTP_CODE_LENGTH = 6
func GenerateTOTP(secret string, timeStepOffset int) string { func GenerateTOTP(secret string, timeStepOffset int) string {
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
@ -27,7 +27,7 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
} }
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8) counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter)) binary.BigEndian.PutUint64(counterBytes, uint64(counter))
@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
offset := hash[len(hash) - 1] & 0x0f offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(CODE_LENGTH)) code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
} }
func GenerateTOTPSecret(length int) string { func GenerateTOTPSecret(length int) string {
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
query := url.Query() query := url.Query()
query.Set("secret", secret) query.Set("secret", secret)
query.Set("issuer", "arimelody.me") query.Set("issuer", "arimelody.me")
query.Set("algorithm", "SHA1") // query.Set("algorithm", "SHA1")
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) // query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
query.Set("period", fmt.Sprintf("%d", TIME_STEP)) // query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
url.RawQuery = query.Encode() url.RawQuery = query.Encode()
return url.String() return url.String()
@ -89,14 +89,36 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
return totps, nil return totps, nil
} }
func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) {
totps, err := GetTOTPsForAccount(db, accountID)
if err != nil {
return nil, err
}
for _, method := range totps {
check := GenerateTOTP(method.Secret, 0)
if check == totp {
return &method, nil
}
// try again with offset- maybe user input the code late?
check = GenerateTOTP(method.Secret, 1)
if check == totp {
return &method, nil
}
}
// user failed all TOTP checks
// note: this state will still occur even if the account has no TOTP methods.
return nil, nil
}
func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
totp := model.TOTP{} totp := model.TOTP{}
err := db.Get( err := db.Get(
&totp, &totp,
"SELECT * FROM totp " + "SELECT * FROM totp " +
"WHERE account=$1", "WHERE account=$1 AND name=$2",
accountID, accountID,
name,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {

6
go.mod
View file

@ -8,4 +8,8 @@ require (
) )
require golang.org/x/crypto v0.27.0 // indirect require golang.org/x/crypto v0.27.0 // indirect
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)

6
go.sum
View file

@ -8,7 +8,9 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=

107
main.go
View file

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"math"
"math/rand" "math/rand"
"net/http" "net/http"
"os" "os"
@ -22,6 +23,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
) )
// used for database migrations // used for database migrations
@ -87,19 +89,19 @@ func main() {
} }
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccount(app.DB, username) account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP { totp := model.TOTP {
AccountID: account.ID, AccountID: account.ID,
Name: totpName, Name: totpName,
@ -108,7 +110,11 @@ func main() {
err = controller.CreateTOTP(app.DB, &totp) err = controller.CreateTOTP(app.DB, &totp)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) if strings.HasPrefix(err.Error(), "pq: duplicate key") {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -124,20 +130,20 @@ func main() {
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
account, err := controller.GetAccount(app.DB, username) account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
err = controller.DeleteTOTP(app.DB, account.ID, totpName) err = controller.DeleteTOTP(app.DB, account.ID, totpName)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -151,20 +157,20 @@ func main() {
} }
username := os.Args[2] username := os.Args[2]
account, err := controller.GetAccount(app.DB, username) account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -184,25 +190,25 @@ func main() {
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
account, err := controller.GetAccount(app.DB, username) account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
totp, err := controller.GetTOTP(app.DB, account.ID, totpName) totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1) os.Exit(1)
} }
if totp == nil { if totp == nil {
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1) os.Exit(1)
} }
@ -214,18 +220,22 @@ func main() {
fmt.Printf("Creating invite...\n") fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) fmt.Printf(
"Here you go! This code expires in %d hours: %s\n",
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
invite.Code,
)
return return
case "purgeInvites": case "purgeInvites":
fmt.Printf("Deleting all invites...\n") fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(app.DB) err := controller.DeleteAllInvites(app.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -235,11 +245,13 @@ func main() {
case "listAccounts": case "listAccounts":
accounts, err := controller.GetAllAccounts(app.DB) accounts, err := controller.GetAllAccounts(app.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err)
os.Exit(1) os.Exit(1)
} }
for _, account := range accounts { for _, account := range accounts {
email := "<none>"
if account.Email.Valid { email = account.Email.String }
fmt.Printf( fmt.Printf(
"User: %s\n" + "User: %s\n" +
"\tID: %s\n" + "\tID: %s\n" +
@ -247,12 +259,45 @@ func main() {
"\tCreated: %s\n", "\tCreated: %s\n",
account.Username, account.Username,
account.ID, account.ID,
account.Email, email,
account.CreatedAt, account.CreatedAt,
) )
} }
return return
case "changePassword":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n")
os.Exit(1)
}
username := os.Args[2]
password := os.Args[3]
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, account)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
case "deleteAccount": case "deleteAccount":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
@ -261,14 +306,14 @@ func main() {
username := os.Args[2] username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username) fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccount(app.DB, username) account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
@ -279,9 +324,9 @@ func main() {
return return
} }
err = controller.DeleteAccount(app.DB, username) err = controller.DeleteAccount(app.DB, account.ID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -338,9 +383,9 @@ func main() {
// start the web server! // start the web server!
mux := createServeMux(&app) mux := createServeMux(&app)
fmt.Printf("Now serving at %s:%d\n", app.Config.BaseUrl, app.Config.Port) fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
log.Fatal( log.Fatal(
http.ListenAndServe(fmt.Sprintf(":%d", app.Config.Port), http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
HTTPLog(DefaultHeaders(mux)), HTTPLog(DefaultHeaders(mux)),
)) ))
} }
@ -359,7 +404,7 @@ func createServeMux(app *model.AppState) *http.ServeMux {
} }
if r.URL.Path == "/" || r.URL.Path == "/index.html" { if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.Pages["index"].Execute(w, nil) err := templates.IndexTemplate.Execute(w, nil)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }

View file

@ -1,17 +1,20 @@
package model package model
import "time" import (
"database/sql"
"time"
)
const COOKIE_TOKEN string = "AM_TOKEN" const COOKIE_TOKEN string = "AM_SESSION"
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"` Password string `json:"password" db:"password"`
Email string `json:"email" db:"email"` Email sql.NullString `json:"email" db:"email"`
AvatarURL string `json:"avatar_url" db:"avatar_url"` AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
Privileges []AccountPrivilege `json:"privileges"` Privileges []AccountPrivilege `json:"privileges"`
} }

View file

@ -19,6 +19,7 @@ type (
Config struct { Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"` DataDirectory string `toml:"data_dir"`
DB DBConfig `toml:"db"` DB DBConfig `toml:"db"`

View file

@ -1,11 +1,17 @@
package model package model
import "time" import (
"database/sql"
"time"
)
type Token struct { type Session struct {
Token string `json:"token" db:"token"` Token string `json:"token" db:"token"`
AccountID string `json:"-" db:"account"`
UserAgent string `json:"user_agent" db:"user_agent"` UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"` ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
Account *Account `json:"-" db:"account"`
Message sql.NullString `json:"-" db:"message"`
Error sql.NullString `json:"-" db:"error"`
} }

View file

@ -12,6 +12,8 @@ type (
Description string `json:"description"` Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"` Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"` PreviewURL string `json:"previewURL" db:"preview_url"`
Number int
} }
) )

View file

@ -1,11 +1,11 @@
footer { footer {
border-top: 1px solid #888; border-top: 1px solid #8888;
} }
#footer { #footer {
width: min(calc(100% - 4rem), 720px); width: min(calc(100% - 4rem), 720px);
margin: auto; margin: auto;
padding: 2rem 0; padding: 2rem 0;
color: #aaa; color: #aaa;
} }

View file

@ -91,7 +91,7 @@ hr {
text-align: center; text-align: center;
line-height: 0px; line-height: 0px;
border-width: 1px 0 0 0; border-width: 1px 0 0 0;
border-color: #888f; border-color: #888;
margin: 1.5em 0; margin: 1.5em 0;
overflow: visible; overflow: visible;
} }

View file

@ -1,24 +1,22 @@
CREATE SCHEMA IF NOT EXISTS arimelody;
-- --
-- Tables -- Tables
-- --
-- Accounts -- Accounts
CREATE TABLE arimelody.account ( CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(), id UUID DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password text NOT NULL, password TEXT NOT NULL,
email text, email TEXT,
avatar_url text, avatar_url TEXT,
created_at TIMESTAMP DEFAULT current_timestamp created_at TIMESTAMP DEFAULT current_timestamp
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege -- Privilege
CREATE TABLE arimelody.privilege ( CREATE TABLE arimelody.privilege (
account uuid NOT NULL, account UUID NOT NULL,
privilege text NOT NULL privilege TEXT NOT NULL
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
@ -30,15 +28,17 @@ CREATE TABLE arimelody.invite (
); );
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Tokens -- Session
CREATE TABLE arimelody.token ( CREATE TABLE arimelody.session (
token TEXT, token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL, user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL expires_at TIMESTAMP DEFAULT NULL,
account UUID,
message TEXT,
error TEXT
); );
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs -- TOTPs
CREATE TABLE arimelody.totp ( CREATE TABLE arimelody.totp (
@ -118,7 +118,7 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
-- --
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,36 +1,22 @@
--
-- Migration
--
-- Move existing tables to new schema
ALTER TABLE public.artist SET SCHEMA arimelody;
ALTER TABLE public.musicrelease SET SCHEMA arimelody;
ALTER TABLE public.musiclink SET SCHEMA arimelody;
ALTER TABLE public.musiccredit SET SCHEMA arimelody;
ALTER TABLE public.musictrack SET SCHEMA arimelody;
ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody;
-- --
-- New items -- New items
-- --
-- Acounts -- Accounts
CREATE TABLE arimelody.account ( CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(), id UUID DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password text NOT NULL, password TEXT NOT NULL,
email text, email TEXT,
avatar_url text, avatar_url TEXT,
created_at TIMESTAMP DEFAULT current_timestamp created_at TIMESTAMP DEFAULT current_timestamp
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege -- Privilege
CREATE TABLE arimelody.privilege ( CREATE TABLE arimelody.privilege (
account uuid NOT NULL, account UUID NOT NULL,
privilege text NOT NULL privilege TEXT NOT NULL
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
@ -42,15 +28,17 @@ CREATE TABLE arimelody.invite (
); );
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Tokens -- Session
CREATE TABLE arimelody.token ( CREATE TABLE arimelody.session (
token TEXT, token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL, user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL expires_at TIMESTAMP DEFAULT NULL,
account UUID,
message TEXT,
error TEXT
); );
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs -- TOTPs
CREATE TABLE arimelody.totp ( CREATE TABLE arimelody.totp (

View file

@ -5,29 +5,24 @@ import (
"path/filepath" "path/filepath"
) )
var Pages = map[string]*template.Template{ var IndexTemplate = template.Must(template.ParseFiles(
"index": template.Must(template.ParseFiles( filepath.Join("views", "layout.html"),
filepath.Join("views", "layout.html"), filepath.Join("views", "header.html"),
filepath.Join("views", "header.html"), filepath.Join("views", "footer.html"),
filepath.Join("views", "footer.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "index.html"),
filepath.Join("views", "index.html"), ))
)), var MusicTemplate = template.Must(template.ParseFiles(
"music": template.Must(template.ParseFiles( filepath.Join("views", "layout.html"),
filepath.Join("views", "layout.html"), filepath.Join("views", "header.html"),
filepath.Join("views", "header.html"), filepath.Join("views", "footer.html"),
filepath.Join("views", "footer.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "music.html"),
filepath.Join("views", "music.html"), ))
)), var MusicGatewayTemplate = template.Must(template.ParseFiles(
"music-gateway": template.Must(template.ParseFiles( filepath.Join("views", "layout.html"),
filepath.Join("views", "layout.html"), filepath.Join("views", "header.html"),
filepath.Join("views", "header.html"), filepath.Join("views", "footer.html"),
filepath.Join("views", "footer.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "music-gateway.html"),
filepath.Join("views", "music-gateway.html"), ))
)),
}
var Components = map[string]*template.Template{
}

View file

@ -3,7 +3,6 @@ package view
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
@ -48,7 +47,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
} }
} }
err = templates.Pages["music"].Execute(w, releases) err = templates.MusicTemplate.Execute(w, releases)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -60,13 +59,8 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
privileged := false privileged := false
if !release.Visible { if !release.Visible {
account, err := controller.GetAccountByRequest(app.DB, r) session := r.Context().Value("session").(*model.Session)
if err != nil { if session != nil && session.Account != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
// TODO: check privilege on release // TODO: check privilege on release
privileged = true privileged = true
} }
@ -85,7 +79,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
response.Links = release.Links response.Links = release.Links
} }
err := templates.Pages["music-gateway"].Execute(w, response) err := templates.MusicGatewayTemplate.Execute(w, response)
if err != nil { if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)