terrible no good massive refactor commit (oh yeah and built generic sessions for admin panel)
This commit is contained in:
parent
cee99a6932
commit
45f33b8b46
|
@ -3,9 +3,7 @@ package admin
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
|
@ -14,16 +12,11 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginRegisterResponse struct {
|
func accountHandler(app *model.AppState) http.Handler {
|
||||||
Account *model.Account
|
|
||||||
Message string
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
func AccountHandler(app *model.AppState) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/password", changePasswordHandler(app))
|
mux.Handle("/password", changePasswordHandler(app))
|
||||||
|
mux.Handle("/delete", deleteAccountHandler(app))
|
||||||
mux.Handle("/", accountIndexHandler(app))
|
mux.Handle("/", accountIndexHandler(app))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
|
@ -31,26 +24,22 @@ func AccountHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
func accountIndexHandler(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)
|
totps, 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 accountResponse struct {
|
||||||
Account *model.Account
|
Session *model.Session
|
||||||
TOTPs []model.TOTP
|
TOTPs []model.TOTP
|
||||||
Message string
|
|
||||||
Error string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pages["account"].Execute(w, accountResponse{
|
err = pages["account"].Execute(w, accountResponse{
|
||||||
Account: account,
|
Session: session,
|
||||||
TOTPs: totps,
|
TOTPs: totps,
|
||||||
Message: r.URL.Query().Get("message"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render admin account page: %v\n", err)
|
fmt.Printf("WARN: Failed to render admin account page: %v\n", err)
|
||||||
|
@ -59,303 +48,6 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoginHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account != nil {
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pages["login"].Execute(w, loginRegisterResponse{})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginResponse struct {
|
|
||||||
Account *model.Account
|
|
||||||
Token string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func(data LoginResponse) {
|
|
||||||
err := pages["login"].Execute(w, data)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.NotFound(w, r);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
render(LoginResponse{ Message: "Malformed request." })
|
|
||||||
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.GetAccount(app.DB, credentials.Username)
|
|
||||||
if err != nil {
|
|
||||||
render(LoginResponse{ Message: "Invalid username or password" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account == nil {
|
|
||||||
render(LoginResponse{ Message: "Invalid username or password" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
|
||||||
if err != nil {
|
|
||||||
render(LoginResponse{ Message: "Invalid username or password" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
|
||||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(totps) > 0 {
|
|
||||||
success := false
|
|
||||||
for _, totp := range totps {
|
|
||||||
check := controller.GenerateTOTP(totp.Secret, 0)
|
|
||||||
if check == credentials.TOTP {
|
|
||||||
success = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !success {
|
|
||||||
render(LoginResponse{ Message: "Invalid TOTP" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO: user should be prompted to add 2FA method
|
|
||||||
}
|
|
||||||
|
|
||||||
// login success!
|
|
||||||
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
|
||||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie := http.Cookie{}
|
|
||||||
cookie.Name = 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 {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenStr := controller.GetTokenFromRequest(app.DB, r)
|
|
||||||
|
|
||||||
if len(tokenStr) > 0 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie := http.Cookie{}
|
|
||||||
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 {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
checkAccount, err := controller.GetAccountByRequest(app.DB, r)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if checkAccount != nil {
|
|
||||||
// user is already logged in
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
render(CreateaccountResponse{
|
|
||||||
Message: "Malformed data.",
|
|
||||||
})
|
|
||||||
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 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, loginRegisterResponse{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func changePasswordHandler(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.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
@ -363,22 +55,89 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
account := r.Context().Value("account").(*model.Account)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
|
|
||||||
currentPassword := r.Form.Get("current-password")
|
currentPassword := r.Form.Get("current-password")
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(currentPassword)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
|
||||||
http.Redirect(w, r, "/admin/account?error=" + url.PathEscape("Incorrect password."), http.StatusFound)
|
controller.SetSessionMessage(app.DB, session, "Incorrect password.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newPassword := r.Form.Get("new-password")
|
newPassword := r.Form.Get("new-password")
|
||||||
|
|
||||||
http.Redirect(
|
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("Updating password to <code>%s</code>", newPassword))
|
||||||
w, r, "/admin/account?message=" +
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
url.PathEscape(fmt.Sprintf("Updating password to <code>%s</code>", newPassword)),
|
})
|
||||||
http.StatusFound,
|
}
|
||||||
)
|
|
||||||
|
func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Form.Has("password") || !r.Form.Has("totp") {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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("2006-02-01 15:04:05"),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
|
controller.SetSessionMessage(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 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err)
|
||||||
|
controller.SetSessionMessage(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totpMethod == nil {
|
||||||
|
fmt.Printf(
|
||||||
|
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n",
|
||||||
|
time.Now().Format("2006-02-01 15:04:05"),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
|
controller.SetSessionMessage(app.DB, session, "Incorrect TOTP.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = controller.DeleteAccount(app.DB, session.Account.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
||||||
|
controller.SetSessionMessage(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"[%s] INFO: Account \"%s\" deleted by user request.\n",
|
||||||
|
time.Now().Format("2006-02-01 15:04:05"),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.Account = nil
|
||||||
|
controller.SetSessionMessage(app.DB, session, "Account deleted successfully.")
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = pages["artist"].Execute(w, ArtistResponse{
|
||||||
Account: account,
|
Session: session,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
||||||
})
|
})
|
||||||
|
|
354
admin/http.go
354
admin/http.go
|
@ -2,40 +2,51 @@ 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("/login", loginHandler(app))
|
||||||
mux.Handle("/register", createAccountHandler(app))
|
mux.Handle("/logout", RequireAccount(app, logoutHandler(app)))
|
||||||
mux.Handle("/logout", RequireAccount(app, LogoutHandler(app)))
|
|
||||||
mux.Handle("/account/", RequireAccount(app, http.StripPrefix("/account", AccountHandler(app))))
|
mux.Handle("/register", registerAccountHandler(app))
|
||||||
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
|
||||||
|
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("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app))))
|
||||||
mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
|
mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
|
||||||
mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
|
mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
|
||||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
||||||
|
|
||||||
|
mux.Handle("/", RequireAccount(app, AdminIndexHandler(app)))
|
||||||
|
|
||||||
|
// response wrapper to make sure a session cookie exists
|
||||||
|
return enforceSession(app, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
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 {
|
||||||
|
@ -59,14 +70,14 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = pages["index"].Execute(w, IndexData{
|
||||||
Account: account,
|
Session: session,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
|
@ -76,28 +87,259 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return mux
|
func registerAccountHandler(app *model.AppState) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
session.Error = sql.NullString{}
|
||||||
|
session.Message = sql.NullString{}
|
||||||
|
|
||||||
|
if session.Account != nil {
|
||||||
|
// user is already logged in
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
err := pages["register"].Execute(w, 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 {
|
||||||
|
session.Error = sql.NullString{ String: "Malformed data.", Valid: true }
|
||||||
|
render()
|
||||||
|
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)
|
||||||
|
session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true }
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
session.Error = sql.NullString{ String: "Invalid invite code.", Valid: true }
|
||||||
|
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)
|
||||||
|
session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true }
|
||||||
|
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") {
|
||||||
|
session.Error = sql.NullString{ String: "An account with that username already exists.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||||
|
session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true }
|
||||||
|
render()
|
||||||
|
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!
|
||||||
|
controller.SetSessionAccount(app.DB, session, &account)
|
||||||
|
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 := pages["login"].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 {
|
||||||
|
session.Error = sql.NullString{ String: "Malformed data.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// new accounts won't have TOTP methods at first. there should be a
|
||||||
|
// second phase of login that prompts the user for a TOTP *only*
|
||||||
|
// if that account has a TOTP method.
|
||||||
|
// TODO: login phases (username & password -> TOTP)
|
||||||
|
|
||||||
|
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)
|
||||||
|
session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account == nil {
|
||||||
|
session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true }
|
||||||
|
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("2006-02-01 15:04:05"),
|
||||||
|
account.Username,
|
||||||
|
)
|
||||||
|
session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totpMethod == nil {
|
||||||
|
session.Error = sql.NullString{ String: "Invalid TOTP.", Valid: true }
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: log login activity to user
|
||||||
|
fmt.Printf(
|
||||||
|
"[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
|
||||||
|
time.Now().Format("2006-02-01 15:04:05"),
|
||||||
|
account.Username,
|
||||||
|
totpMethod.Name,
|
||||||
|
)
|
||||||
|
|
||||||
|
// login success!
|
||||||
|
controller.SetSessionAccount(app.DB, session, account)
|
||||||
|
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 = pages["logout"].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 {
|
func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
|
||||||
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 err != nil {
|
if session.Account == 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 {
|
|
||||||
// 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 +363,57 @@ 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 {
|
||||||
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
http.Error(w, "Invalid session. Please try clearing your cookies and refresh.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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 = pages["release"].Execute(w, ReleaseResponse{
|
||||||
Account: account,
|
Session: session,
|
||||||
Release: release,
|
Release: release,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -127,20 +127,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 {
|
||||||
|
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 +168,32 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,14 +1,7 @@
|
||||||
@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 {
|
form button {
|
||||||
|
@ -22,7 +15,7 @@ label {
|
||||||
color: #10101080;
|
color: #10101080;
|
||||||
}
|
}
|
||||||
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 +26,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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -393,7 +393,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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -18,10 +18,10 @@ var pages = map[string]*template.Template{
|
||||||
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(
|
"register": 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", "register.html"),
|
||||||
)),
|
)),
|
||||||
"logout": template.Must(template.ParseFiles(
|
"logout": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
|
|
|
@ -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 = pages["track"].Execute(w, TrackResponse{
|
||||||
Account: account,
|
Session: session,
|
||||||
Track: track,
|
Track: track,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,29 +6,27 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
{{if .Message}}
|
{{if .Session.Message.Valid}}
|
||||||
<p id="message">{{.Message}}</p>
|
<p id="message">{{html .Session.Message.String}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Error}}
|
{{if .Session.Error.Valid}}
|
||||||
<p id="error">{{.Error}}</p>
|
<p id="error">{{html .Session.Error.String}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<h1>Account Settings ({{.Account.Username}})</h1>
|
<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="/admin/account/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">
|
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
|
||||||
|
|
||||||
<label for="new-password">Password</label>
|
<label for="new-password">New Password</label>
|
||||||
<input type="password" id="new-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" id="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>
|
||||||
|
@ -64,9 +62,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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -24,9 +24,9 @@
|
||||||
<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/account">account ({{.Account.Username}})</a>
|
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin/logout" id="logout">log out</a>
|
<a href="/admin/logout" id="logout">log out</a>
|
||||||
|
|
|
@ -52,35 +52,26 @@ input[disabled] {
|
||||||
|
|
||||||
{{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">
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
<label for="totp">TOTP</label>
|
||||||
<input type="text" name="totp" value="" autocomplete="one-time-code">
|
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="save">Login</button>
|
<button type="submit" class="save">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{end}}
|
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -48,23 +48,23 @@ input {
|
||||||
|
|
||||||
{{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="create-account">
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
128
controller/session.go
Normal file
128
controller/session.go
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
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 == "" {
|
||||||
|
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 == "" {
|
||||||
|
session.Message = sql.NullString{ }
|
||||||
|
_, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token)
|
||||||
|
} else {
|
||||||
|
session.Message = 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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TOTP_SECRET_LENGTH = 32
|
const TOTP_SECRET_LENGTH = 64
|
||||||
const TIME_STEP int64 = 30
|
const TIME_STEP int64 = 30
|
||||||
const CODE_LENGTH = 6
|
const CODE_LENGTH = 6
|
||||||
|
|
||||||
|
@ -89,6 +89,24 @@ 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 {
|
||||||
|
// user has no TOTP methods
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, method := range totps {
|
||||||
|
check := GenerateTOTP(method.Secret, 0)
|
||||||
|
if check == totp {
|
||||||
|
// return the whole TOTP method as it may be useful for logging
|
||||||
|
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{}
|
||||||
|
|
||||||
|
|
18
main.go
18
main.go
|
@ -89,7 +89,7 @@ func main() {
|
||||||
totpName := os.Args[3]
|
totpName := os.Args[3]
|
||||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
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, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
@ -108,6 +108,10 @@ func main() {
|
||||||
|
|
||||||
err = controller.CreateTOTP(app.DB, &totp)
|
err = controller.CreateTOTP(app.DB, &totp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||||
|
fmt.Fprintf(os.Stderr, "Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
@ -124,7 +128,7 @@ 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, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
@ -151,7 +155,7 @@ 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, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
@ -184,7 +188,7 @@ 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, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
@ -240,6 +244,8 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +253,7 @@ func main() {
|
||||||
"\tCreated: %s\n",
|
"\tCreated: %s\n",
|
||||||
account.Username,
|
account.Username,
|
||||||
account.ID,
|
account.ID,
|
||||||
account.Email,
|
email,
|
||||||
account.CreatedAt,
|
account.CreatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -261,7 +267,7 @@ 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, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
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"`
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
footer {
|
footer {
|
||||||
border-top: 1px solid #888;
|
border-top: 1px solid #8888;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer {
|
#footer {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,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 (session);
|
||||||
|
|
||||||
-- TOTPs
|
-- TOTPs
|
||||||
CREATE TABLE arimelody.totp (
|
CREATE TABLE arimelody.totp (
|
||||||
|
@ -116,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;
|
||||||
|
|
|
@ -1,17 +1,3 @@
|
||||||
--
|
|
||||||
-- 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
|
||||||
--
|
--
|
||||||
|
@ -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 (session);
|
||||||
|
|
||||||
-- TOTPs
|
-- TOTPs
|
||||||
CREATE TABLE arimelody.totp (
|
CREATE TABLE arimelody.totp (
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue