Compare commits

...

10 commits

43 changed files with 1243 additions and 847 deletions

2
.gitignore vendored
View file

@ -7,4 +7,4 @@ uploads/
docker-compose*.yml docker-compose*.yml
!docker-compose.example.yml !docker-compose.example.yml
config*.toml config*.toml
>>>>>>> dev arimelody-web

View file

@ -32,18 +32,17 @@ be overridden with `ARIMELODY_DB_HOST`.
the location of the configuration file can also be overridden with the location of the configuration file can also be overridden with
`ARIMELODY_CONFIG`. `ARIMELODY_CONFIG`.
## command arguments ### command arguments
by default, `arimelody-web` will spin up a web server as usual. instead, by default, `arimelody-web` will spin up a web server as usual. instead,
arguments may be supplied to run administrative actions. the web server doesn't arguments may be supplied to run administrative actions. the web server doesn't
need to be up for this, making this ideal for some offline maintenance. need to be up for this, making this ideal for some offline maintenance.
- `createTOTP <username> <name>`: Creates a timed one-time passcode method.
- `listTOTP <username>`: Lists an account's TOTP methods.
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
- `createInvite`: Creates an invite code to register new accounts. - `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes. - `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`. - `deleteAccount <username>`: Deletes an account with a given `username`.
## database
the server requires a postgres database to run. you can use the
[schema.sql](schema.sql) provided in this repo to generate the required tables.
automatic schema building/migration may come in a future update.

343
admin/accounthttp.go Normal file
View file

@ -0,0 +1,343 @@
package admin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
)
type TemplateData struct {
Account *model.Account
Message string
Token string
}
func AccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := r.Context().Value("account").(*model.Account)
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
type AccountResponse struct {
Account *model.Account
TOTPs []model.TOTP
}
err = pages["account"].Execute(w, AccountResponse{
Account: account,
TOTPs: totps,
})
if err != nil {
fmt.Printf("WARN: Failed to render admin account page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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, 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
}
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, 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

@ -5,16 +5,15 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/controller" "arimelody-web/controller"
) )
func serveArtist() http.Handler { func serveArtist(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) {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0] id := slices[0]
artist, err := controller.GetArtist(global.DB, id) artist, err := controller.GetArtist(app.DB, id)
if err != nil { if err != nil {
if artist == nil { if artist == nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -25,7 +24,7 @@ func serveArtist() http.Handler {
return return
} }
credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -6,40 +6,29 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
) )
type TemplateData struct { func Handler(app *model.AppState) http.Handler {
Account *model.Account
Token string
}
func Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/login", LoginHandler()) mux.Handle("/login", LoginHandler(app))
mux.Handle("/register", createAccountHandler()) mux.Handle("/register", createAccountHandler(app))
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) mux.Handle("/logout", RequireAccount(app, LogoutHandler(app)))
// TODO: /admin/account mux.Handle("/account", RequireAccount(app, AccountHandler(app)))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) mux.Handle("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack()))) mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", 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(global.DB, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
} }
@ -48,21 +37,21 @@ func Handler() http.Handler {
return return
} }
releases, err := controller.GetAllReleases(global.DB, false, 0, true) releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
artists, err := controller.GetAllArtists(global.DB) artists, err := controller.GetAllArtists(app.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
tracks, err := controller.GetOrphanTracks(global.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)
@ -92,9 +81,9 @@ func Handler() http.Handler {
return mux return mux
} }
func RequireAccount(db *sqlx.DB, 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(db, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
@ -112,275 +101,6 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
}) })
} }
func LoginHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
account, err := controller.GetAccountByRequest(global.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, 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
}
if r.Method != http.MethodPost {
http.NotFound(w, r);
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type 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(global.DB, credentials.Username)
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
// TODO: check TOTP
// login success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.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.Printf("Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func LogoutHandler() 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(global.DB, r)
if len(tokenStr) > 0 {
err := controller.DeleteToken(global.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 = global.COOKIE_TOKEN
cookie.Value = ""
cookie.Expires = time.Now()
if strings.HasPrefix(global.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() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(global.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(global.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(global.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(global.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(global.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(global.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 = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.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
}
})
}
func staticHandler() http.Handler { func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))

View file

@ -5,19 +5,18 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
func serveRelease() http.Handler { func serveRelease(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) {
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) account := r.Context().Value("account").(*model.Account)
release, err := controller.GetRelease(global.DB, releaseID, true) release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -34,10 +33,10 @@ func serveRelease() http.Handler {
serveEditCredits(release).ServeHTTP(w, r) serveEditCredits(release).ServeHTTP(w, r)
return return
case "addcredit": case "addcredit":
serveAddCredit(release).ServeHTTP(w, r) serveAddCredit(app, release).ServeHTTP(w, r)
return return
case "newcredit": case "newcredit":
serveNewCredit().ServeHTTP(w, r) serveNewCredit(app).ServeHTTP(w, r)
return return
case "editlinks": case "editlinks":
serveEditLinks(release).ServeHTTP(w, r) serveEditLinks(release).ServeHTTP(w, r)
@ -46,10 +45,10 @@ func serveRelease() http.Handler {
serveEditTracks(release).ServeHTTP(w, r) serveEditTracks(release).ServeHTTP(w, r)
return return
case "addtrack": case "addtrack":
serveAddTrack(release).ServeHTTP(w, r) serveAddTrack(app, release).ServeHTTP(w, r)
return return
case "newtrack": case "newtrack":
serveNewTrack().ServeHTTP(w, r) serveNewTrack(app).ServeHTTP(w, r)
return return
} }
http.NotFound(w, r) http.NotFound(w, r)
@ -83,9 +82,9 @@ func serveEditCredits(release *model.Release) http.Handler {
}) })
} }
func serveAddCredit(release *model.Release) http.Handler { func serveAddCredit(app *model.AppState, 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) {
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID) artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -109,10 +108,10 @@ func serveAddCredit(release *model.Release) http.Handler {
}) })
} }
func serveNewCredit() http.Handler { func serveNewCredit(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) {
artistID := strings.Split(r.URL.Path, "/")[3] artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(global.DB, artistID) artist, err := controller.GetArtist(app.DB, artistID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -154,9 +153,9 @@ func serveEditTracks(release *model.Release) http.Handler {
}) })
} }
func serveAddTrack(release *model.Release) http.Handler { func serveAddTrack(app *model.AppState, 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) {
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID) tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -181,10 +180,10 @@ func serveAddTrack(release *model.Release) http.Handler {
}) })
} }
func serveNewTrack() http.Handler { func serveNewTrack(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) {
trackID := strings.Split(r.URL.Path, "/")[3] trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(global.DB, trackID) track, err := controller.GetTrack(app.DB, trackID)
if err != nil { if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -124,3 +124,65 @@ a img.icon {
font-size: 12px; font-size: 12px;
} }
} }
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.new {
background: #c4ff6a;
border-color: #84b141;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}

View file

@ -0,0 +1,65 @@
@import url("/admin/static/index.css");
form#change-password {
width: 100%;
display: flex;
flex-direction: column;
align-items: start;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
.mfa-device {
padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
border-radius: .5em;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
}
.mfa-device div {
display: flex;
flex-direction: column;
justify-content: center;
}
.mfa-device p {
margin: 0;
}
.mfa-device .mfa-device-name {
font-weight: bold;
}

View file

View file

@ -66,54 +66,6 @@ input[type="text"]:focus {
border-color: #808080; border-color: #808080;
} }
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.artist-actions { .artist-actions {
margin-top: auto; margin-top: auto;
display: flex; display: flex;

View file

@ -109,58 +109,6 @@ input[type="text"] {
padding: 0; padding: 0;
} }
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.new {
background: #c4ff6a;
border-color: #84b141;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.release-actions { .release-actions {
margin-top: auto; margin-top: auto;
display: flex; display: flex;

View file

@ -67,54 +67,6 @@ h1 {
border-color: #808080; border-color: #808080;
} }
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.track-actions { .track-actions {
margin-top: 1em; margin-top: 1em;
display: flex; display: flex;

View file

@ -98,4 +98,3 @@
.track .empty { .track .empty {
opacity: 0.75; opacity: 0.75;
} }

View file

@ -28,6 +28,11 @@ var pages = map[string]*template.Template{
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"), filepath.Join("admin", "views", "logout.html"),
)), )),
"account": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
)),
"release": template.Must(template.ParseFiles( "release": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),

View file

@ -5,16 +5,15 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/controller" "arimelody-web/controller"
) )
func serveTrack() http.Handler { func serveTrack(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) {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0] id := slices[0]
track, err := controller.GetTrack(global.DB, id) track, err := controller.GetTrack(app.DB, id)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -25,7 +24,7 @@ func serveTrack() http.Handler {
return return
} }
releases, err := controller.GetTrackReleases(global.DB, track.ID, true) releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -1,7 +1,7 @@
{{define "head"}} {{define "head"}}
<title>Register - ari melody 💫</title> <title>Register - ari melody 💫</title>
<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">
<style> <style>
p a { p a {
color: #2a67c8; color: #2a67c8;
@ -43,35 +43,6 @@ input {
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
} }
button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button.new {
background: #c4ff6a;
border-color: #84b141;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
</style> </style>
{{end}} {{end}}

View file

@ -0,0 +1,69 @@
{{define "head"}}
<title>Account Settings - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-account.css">
{{end}}
{{define "content"}}
<main>
<h1>Account Settings ({{.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<form action="/api/v1/change-password" method="POST" id="change-password">
<div>
<label for="current-password">Current Password</label>
<input type="password" name="current-password" value="" autocomplete="current-password">
<label for="new-password">Password</label>
<input type="password" name="new-password" value="" autocomplete="new-password">
<label for="confirm-password">Confirm Password</label>
<input type="password" name="confirm-password" value="" autocomplete="new-password">
</div>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices">
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAt}}</p>
</div>
<div>
<a class="delete">Delete</a>
</div>
</div>
{{end}}
{{else}}
<p>You have no MFA devices.</p>
{{end}}
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button>
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Account</button>
</div>
</main>
<script type="module" src="/admin/static/edit-account.js" defer></script>
{{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Editing {{.Artist.Name}} - ari melody 💫</title> <title>Editing {{.Artist.Name}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon"> <link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-artist.css"> <link rel="stylesheet" href="/admin/static/edit-artist.css">
{{end}} {{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Editing {{.Release.Title}} - ari melody 💫</title> <title>Editing {{.Release.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon"> <link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-release.css"> <link rel="stylesheet" href="/admin/static/edit-release.css">
{{end}} {{end}}

View file

@ -1,6 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Editing Track - ari melody 💫</title> <title>Editing Track - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-track.css"> <link rel="stylesheet" href="/admin/static/edit-track.css">
{{end}} {{end}}

View file

@ -1,7 +1,7 @@
{{define "head"}} {{define "head"}}
<title>Login - ari melody 💫</title> <title>Login - ari melody 💫</title>
<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">
<style> <style>
p a { p a {
color: #2a67c8; color: #2a67c8;
@ -47,33 +47,15 @@ input[disabled] {
opacity: .5; opacity: .5;
cursor: not-allowed; cursor: not-allowed;
} }
button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
{{if .Token}} {{if .Token}}
<meta http-equiv="refresh" content="0;url=/admin/" /> <meta http-equiv="refresh" content="0;url=/admin/" />
@ -87,13 +69,13 @@ button:active {
<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=""> <input type="text" name="username" value="" autocomplete="username">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" value=""> <input type="password" name="password" value="" autocomplete="current-password">
<label for="totp">TOTP</label> <label for="totp">TOTP</label>
<input type="text" name="totp" value="" disabled> <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>

View file

@ -3,7 +3,6 @@ package api
import ( import (
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/global"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -14,7 +13,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func handleLogin() http.HandlerFunc { func handleLogin(app *model.AppState) http.HandlerFunc {
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 {
http.NotFound(w, r) http.NotFound(w, r)
@ -33,7 +32,7 @@ func handleLogin() http.HandlerFunc {
return return
} }
account, err := controller.GetAccount(global.DB, credentials.Username) account, err := controller.GetAccount(app.DB, credentials.Username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -50,7 +49,7 @@ func handleLogin() http.HandlerFunc {
return return
} }
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
type LoginResponse struct { type LoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
@ -67,7 +66,7 @@ func handleLogin() http.HandlerFunc {
}) })
} }
func handleAccountRegistration() http.HandlerFunc { func handleAccountRegistration(app *model.AppState) http.HandlerFunc {
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 {
http.NotFound(w, r) http.NotFound(w, r)
@ -89,7 +88,7 @@ func handleAccountRegistration() http.HandlerFunc {
} }
// make sure code exists in DB // make sure code exists in DB
invite, err := controller.GetInvite(global.DB, credentials.Invite) invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -101,7 +100,7 @@ func handleAccountRegistration() http.HandlerFunc {
} }
if time.Now().After(invite.ExpiresAt) { if time.Now().After(invite.ExpiresAt) {
err := controller.DeleteInvite(global.DB, invite.Code) err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
http.Error(w, "Invalid invite code", http.StatusBadRequest) http.Error(w, "Invalid invite code", http.StatusBadRequest)
return return
@ -120,7 +119,7 @@ func handleAccountRegistration() http.HandlerFunc {
Email: credentials.Email, Email: credentials.Email,
AvatarURL: "/img/default-avatar.png", AvatarURL: "/img/default-avatar.png",
} }
err = controller.CreateAccount(global.DB, &account) err = controller.CreateAccount(app.DB, &account)
if err != nil { if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") { if strings.HasPrefix(err.Error(), "pq: duplicate key") {
http.Error(w, "An account with that username already exists", http.StatusBadRequest) http.Error(w, "An account with that username already exists", http.StatusBadRequest)
@ -131,10 +130,10 @@ func handleAccountRegistration() http.HandlerFunc {
return return
} }
err = controller.DeleteInvite(global.DB, invite.Code) err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
type LoginResponse struct { type LoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
@ -151,7 +150,7 @@ func handleAccountRegistration() http.HandlerFunc {
}) })
} }
func handleDeleteAccount() http.HandlerFunc { func handleDeleteAccount(app *model.AppState) http.HandlerFunc {
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 {
http.NotFound(w, r) http.NotFound(w, r)
@ -170,7 +169,7 @@ func handleDeleteAccount() http.HandlerFunc {
return return
} }
account, err := controller.GetAccount(global.DB, credentials.Username) account, err := controller.GetAccount(app.DB, credentials.Username)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
@ -189,7 +188,7 @@ func handleDeleteAccount() http.HandlerFunc {
// TODO: check TOTP // TODO: check TOTP
err = controller.DeleteAccount(global.DB, account.Username) err = controller.DeleteAccount(app.DB, account.Username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -6,24 +6,32 @@ import (
"strings" "strings"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model"
) )
func Handler() http.Handler { func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
// ACCOUNT ENDPOINTS // ACCOUNT ENDPOINTS
/*
// temporarily disabling these
// accounts should really be handled via the frontend rn, and juggling
// two different token bearer methods kinda sucks!!
// i'll look into generating API tokens on the frontend in the future
// TODO: generate API keys on the frontend
mux.Handle("/v1/login", handleLogin()) mux.Handle("/v1/login", handleLogin())
mux.Handle("/v1/register", handleAccountRegistration()) mux.Handle("/v1/register", handleAccountRegistration())
mux.Handle("/v1/delete-account", handleDeleteAccount()) mux.Handle("/v1/delete-account", handleDeleteAccount())
*/
// ARTIST ENDPOINTS // ARTIST ENDPOINTS
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = strings.Split(r.URL.Path[1:], "/")[0] var artistID = strings.Split(r.URL.Path[1:], "/")[0]
artist, err := controller.GetArtist(global.DB, artistID) artist, err := controller.GetArtist(app.DB, artistID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -37,13 +45,13 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/artist/{id} // GET /api/v1/artist/{id}
ServeArtist(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(global.DB, UpdateArtist(artist)).ServeHTTP(w, r) admin.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(global.DB, DeleteArtist(artist)).ServeHTTP(w, r) admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -52,10 +60,10 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/artist // GET /api/v1/artist
ServeAllArtists().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(global.DB, CreateArtist()).ServeHTTP(w, r) admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -65,7 +73,7 @@ func Handler() http.Handler {
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = strings.Split(r.URL.Path[1:], "/")[0] var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
release, err := controller.GetRelease(global.DB, releaseID, true) release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -79,13 +87,13 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/music/{id} // GET /api/v1/music/{id}
ServeRelease(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(global.DB, UpdateRelease(release)).ServeHTTP(w, r) admin.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(global.DB, DeleteRelease(release)).ServeHTTP(w, r) admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -94,10 +102,10 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/music // GET /api/v1/music
ServeCatalog().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(global.DB, CreateRelease()).ServeHTTP(w, r) admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -107,7 +115,7 @@ func Handler() http.Handler {
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = strings.Split(r.URL.Path[1:], "/")[0] var trackID = strings.Split(r.URL.Path[1:], "/")[0]
track, err := controller.GetTrack(global.DB, trackID) track, err := controller.GetTrack(app.DB, trackID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -121,13 +129,13 @@ func Handler() 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(global.DB, ServeTrack(track)).ServeHTTP(w, r) admin.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(global.DB, UpdateTrack(track)).ServeHTTP(w, r) admin.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(global.DB, DeleteTrack(track)).ServeHTTP(w, r) admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -136,10 +144,10 @@ func Handler() 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(global.DB, ServeAllTracks()).ServeHTTP(w, r) admin.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(global.DB, CreateTrack()).ServeHTTP(w, r) admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View file

@ -10,15 +10,14 @@ import (
"strings" "strings"
"time" "time"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
func ServeAllArtists() http.Handler { func ServeAllArtists(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) {
var artists = []*model.Artist{} var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(global.DB) artists, err := controller.GetAllArtists(app.DB)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err) fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -35,7 +34,7 @@ func ServeAllArtists() http.Handler {
}) })
} }
func ServeArtist(artist *model.Artist) http.Handler { func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type ( type (
creditJSON struct { creditJSON struct {
@ -52,7 +51,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
} }
) )
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -60,7 +59,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
} }
show_hidden_releases := account != nil show_hidden_releases := account != nil
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -92,7 +91,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
}) })
} }
func CreateArtist() http.Handler { func CreateArtist(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) {
var artist model.Artist var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist) err := json.NewDecoder(r.Body).Decode(&artist)
@ -107,7 +106,7 @@ func CreateArtist() http.Handler {
} }
if artist.Name == "" { artist.Name = artist.ID } if artist.Name == "" { artist.Name = artist.ID }
err = controller.CreateArtist(global.DB, &artist) err = controller.CreateArtist(app.DB, &artist)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
@ -122,7 +121,7 @@ func CreateArtist() http.Handler {
}) })
} }
func UpdateArtist(artist *model.Artist) http.Handler { func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := json.NewDecoder(r.Body).Decode(&artist) err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil { if err != nil {
@ -136,7 +135,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
} else { } else {
if strings.Contains(artist.Avatar, ";base64,") { if strings.Contains(artist.Avatar, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "avatar") var artworkDirectory = filepath.Join("uploads", "avatar")
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID) filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID)
// clean up files with this ID and different extensions // clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -155,7 +154,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
} }
} }
err = controller.UpdateArtist(global.DB, artist) err = controller.UpdateArtist(app.DB, artist)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -167,9 +166,9 @@ func UpdateArtist(artist *model.Artist) http.Handler {
}) })
} }
func DeleteArtist(artist *model.Artist) http.Handler { func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := controller.DeleteArtist(global.DB, artist.ID) err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)

View file

@ -10,17 +10,16 @@ import (
"strings" "strings"
"time" "time"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
func ServeRelease(release *model.Release) http.Handler { func ServeRelease(app *model.AppState, 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) {
// 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(global.DB, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -67,14 +66,14 @@ func ServeRelease(release *model.Release) http.Handler {
if release.IsReleased() || privileged { if release.IsReleased() || privileged {
// get credits // get credits
credits, err := controller.GetReleaseCredits(global.DB, release.ID) credits, err := controller.GetReleaseCredits(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
for _, credit := range credits { for _, credit := range credits {
artist, err := controller.GetArtist(global.DB, credit.Artist.ID) artist, err := controller.GetArtist(app.DB, credit.Artist.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -89,7 +88,7 @@ func ServeRelease(release *model.Release) http.Handler {
} }
// get tracks // get tracks
tracks, err := controller.GetReleaseTracks(global.DB, release.ID) tracks, err := controller.GetReleaseTracks(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -104,7 +103,7 @@ func ServeRelease(release *model.Release) http.Handler {
} }
// get links // get links
links, err := controller.GetReleaseLinks(global.DB, release.ID) links, err := controller.GetReleaseLinks(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -126,9 +125,9 @@ func ServeRelease(release *model.Release) http.Handler {
}) })
} }
func ServeCatalog() http.Handler { func ServeCatalog(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) {
releases, err := controller.GetAllReleases(global.DB, false, 0, true) releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
@ -146,7 +145,7 @@ func ServeCatalog() http.Handler {
} }
catalog := []Release{} catalog := []Release{}
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -192,7 +191,7 @@ func ServeCatalog() http.Handler {
}) })
} }
func CreateRelease() http.Handler { func CreateRelease(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 {
http.NotFound(w, r) http.NotFound(w, r)
@ -220,7 +219,7 @@ func CreateRelease() http.Handler {
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
err = controller.CreateRelease(global.DB, &release) err = controller.CreateRelease(app.DB, &release)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
@ -243,7 +242,7 @@ func CreateRelease() http.Handler {
}) })
} }
func UpdateRelease(release *model.Release) http.Handler { func UpdateRelease(app *model.AppState, 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) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
http.NotFound(w, r) http.NotFound(w, r)
@ -255,11 +254,11 @@ func UpdateRelease(release *model.Release) http.Handler {
if len(segments) == 2 { if len(segments) == 2 {
switch segments[1] { switch segments[1] {
case "tracks": case "tracks":
UpdateReleaseTracks(release).ServeHTTP(w, r) UpdateReleaseTracks(app, release).ServeHTTP(w, r)
case "credits": case "credits":
UpdateReleaseCredits(release).ServeHTTP(w, r) UpdateReleaseCredits(app, release).ServeHTTP(w, r)
case "links": case "links":
UpdateReleaseLinks(release).ServeHTTP(w, r) UpdateReleaseLinks(app, release).ServeHTTP(w, r)
} }
return return
} }
@ -281,7 +280,7 @@ func UpdateRelease(release *model.Release) http.Handler {
} else { } else {
if strings.Contains(release.Artwork, ";base64,") { if strings.Contains(release.Artwork, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "musicart") var artworkDirectory = filepath.Join("uploads", "musicart")
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID) filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID)
// clean up files with this ID and different extensions // clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -300,7 +299,7 @@ func UpdateRelease(release *model.Release) http.Handler {
} }
} }
err = controller.UpdateRelease(global.DB, release) err = controller.UpdateRelease(app.DB, release)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -312,7 +311,7 @@ func UpdateRelease(release *model.Release) http.Handler {
}) })
} }
func UpdateReleaseTracks(release *model.Release) http.Handler { func UpdateReleaseTracks(app *model.AppState, 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) {
var trackIDs = []string{} var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs) err := json.NewDecoder(r.Body).Decode(&trackIDs)
@ -321,7 +320,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
return return
} }
err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs) err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -333,7 +332,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
}) })
} }
func UpdateReleaseCredits(release *model.Release) http.Handler { func UpdateReleaseCredits(app *model.AppState, 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) {
type creditJSON struct { type creditJSON struct {
Artist string Artist string
@ -358,7 +357,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
}) })
} }
err = controller.UpdateReleaseCredits(global.DB, release.ID, credits) err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest) http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
@ -374,7 +373,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
}) })
} }
func UpdateReleaseLinks(release *model.Release) http.Handler { func UpdateReleaseLinks(app *model.AppState, 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) {
if r.Method != http.MethodPut { if r.Method != http.MethodPut {
http.NotFound(w, r) http.NotFound(w, r)
@ -388,7 +387,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
return return
} }
err = controller.UpdateReleaseLinks(global.DB, release.ID, links) err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
@ -400,9 +399,9 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
}) })
} }
func DeleteRelease(release *model.Release) http.Handler { func DeleteRelease(app *model.AppState, 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) {
err := controller.DeleteRelease(global.DB, release.ID) err := controller.DeleteRelease(app.DB, release.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
@ -17,7 +16,7 @@ type (
} }
) )
func ServeAllTracks() http.Handler { func ServeAllTracks(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) {
type Track struct { type Track struct {
ID string `json:"id"` ID string `json:"id"`
@ -26,7 +25,7 @@ func ServeAllTracks() http.Handler {
var tracks = []Track{} var tracks = []Track{}
var dbTracks = []*model.Track{} var dbTracks = []*model.Track{}
dbTracks, err := controller.GetAllTracks(global.DB) dbTracks, err := controller.GetAllTracks(app.DB)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -50,9 +49,9 @@ func ServeAllTracks() http.Handler {
}) })
} }
func ServeTrack(track *model.Track) http.Handler { func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -74,7 +73,7 @@ func ServeTrack(track *model.Track) http.Handler {
}) })
} }
func CreateTrack() http.Handler { func CreateTrack(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 {
http.NotFound(w, r) http.NotFound(w, r)
@ -93,7 +92,7 @@ func CreateTrack() http.Handler {
return return
} }
id, err := controller.CreateTrack(global.DB, &track) id, err := controller.CreateTrack(app.DB, &track)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to create track: %s\n", err) fmt.Printf("WARN: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -106,7 +105,7 @@ func CreateTrack() http.Handler {
}) })
} }
func UpdateTrack(track *model.Track) http.Handler { func UpdateTrack(app *model.AppState, track *model.Track) 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.MethodPut || r.URL.Path == "/" { if r.Method != http.MethodPut || r.URL.Path == "/" {
http.NotFound(w, r) http.NotFound(w, r)
@ -124,9 +123,9 @@ func UpdateTrack(track *model.Track) http.Handler {
return return
} }
err = controller.UpdateTrack(global.DB, track) err = controller.UpdateTrack(app.DB, track)
if err != nil { if err != nil {
fmt.Printf("Failed to update track %s: %s\n", track.ID, err) fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -141,7 +140,7 @@ func UpdateTrack(track *model.Track) http.Handler {
}) })
} }
func DeleteTrack(track *model.Track) http.Handler { func DeleteTrack(app *model.AppState, track *model.Track) 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.MethodDelete || r.URL.Path == "/" { if r.Method != http.MethodDelete || r.URL.Path == "/" {
http.NotFound(w, r) http.NotFound(w, r)
@ -149,9 +148,9 @@ func DeleteTrack(track *model.Track) http.Handler {
} }
var trackID = r.URL.Path[1:] var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(global.DB, trackID) err := controller.DeleteTrack(app.DB, trackID)
if err != nil { if err != nil {
fmt.Printf("Failed to delete track %s: %s\n", trackID, err) fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })

View file

@ -1,7 +1,7 @@
package api package api
import ( import (
"arimelody-web/global" "arimelody-web/model"
"bufio" "bufio"
"encoding/base64" "encoding/base64"
"errors" "errors"
@ -11,12 +11,12 @@ import (
"strings" "strings"
) )
func HandleImageUpload(data *string, directory string, filename string) (string, error) { func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
split := strings.Split(*data, ";base64,") split := strings.Split(*data, ";base64,")
header := split[0] header := split[0]
imageData, err := base64.StdEncoding.DecodeString(split[1]) imageData, err := base64.StdEncoding.DecodeString(split[1])
ext, _ := strings.CutPrefix(header, "data:image/") ext, _ := strings.CutPrefix(header, "data:image/")
directory = filepath.Join(global.Config.DataDirectory, directory) directory = filepath.Join(app.Config.DataDirectory, directory)
switch ext { switch ext {
case "png": case "png":

View file

@ -1,7 +1,6 @@
package controller package controller
import ( import (
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"errors" "errors"
"fmt" "fmt"
@ -11,6 +10,17 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
var accounts = []model.Account{}
err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
var account = model.Account{} var account = model.Account{}
@ -61,7 +71,7 @@ func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
return tokenStr return tokenStr
} }
cookie, err := r.Cookie(global.COOKIE_TOKEN) cookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil { if err != nil {
return "" return ""
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )

View file

@ -1,4 +1,4 @@
package global package controller
import ( import (
"errors" "errors"
@ -6,42 +6,26 @@ import (
"os" "os"
"strconv" "strconv"
"github.com/jmoiron/sqlx" "arimelody-web/model"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
) )
type ( func GetConfig() model.Config {
dbConfig struct {
Host string `toml:"host"`
Name string `toml:"name"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
discordConfig struct {
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
DB dbConfig `toml:"db"`
Discord discordConfig `toml:"discord"`
}
)
var Config = func() config {
configFile := os.Getenv("ARIMELODY_CONFIG") configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" { if configFile == "" {
configFile = "config.toml" configFile = "config.toml"
} }
config := config{ config := model.Config{
BaseUrl: "https://arimelody.me", BaseUrl: "https://arimelody.me",
Port: 8080, Port: 8080,
DB: model.DBConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
Name: "arimelody",
},
} }
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
@ -56,20 +40,18 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config) err = toml.Unmarshal([]byte(data), &config)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err) panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err))
os.Exit(1)
} }
err = handleConfigOverrides(&config) err = handleConfigOverrides(&config)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err) panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err))
os.Exit(1)
} }
return config return config
}() }
func handleConfigOverrides(config *config) error { 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 }
@ -80,6 +62,10 @@ func handleConfigOverrides(config *config) error {
if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env } if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env }
if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env } if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env }
if env, has := os.LookupEnv("ARIMELODY_DB_PORT"); has {
config.DB.Port, err = strconv.ParseInt(env, 10, 0)
if err != nil { return errors.New("ARIMELODY_DB_PORT: " + err.Error()) }
}
if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env } if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env }
if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env } if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env }
if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env } if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env }
@ -90,5 +76,3 @@ func handleConfigOverrides(config *config) error {
return nil return nil
} }
var DB *sqlx.DB

View file

@ -20,9 +20,13 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
) )
oldDBVersion := 0 oldDBVersion := 0
schemaVersionCount := 0
err := db.Get(&schemaVersionCount, "SELECT COUNT(*) FROM schema_version")
if err != nil { panic(err) }
if schemaVersionCount > 0 {
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version") err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
if err != nil { panic(err) } if err != nil { panic(err) }
}
for oldDBVersion < DB_VERSION { for oldDBVersion < DB_VERSION {
switch oldDBVersion { switch oldDBVersion {

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )

129
controller/totp.go Normal file
View file

@ -0,0 +1,129 @@
package controller
import (
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
const TOTP_SECRET_LENGTH = 32
const TIME_STEP int64 = 30
const CODE_LENGTH = 6
func GenerateTOTP(secret string, timeStepOffset int) string {
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
}
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
mac := hmac.New(sha1.New, []byte(decodedSecret))
mac.Write(counterBytes)
hash := mac.Sum(nil)
offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
}
func GenerateTOTPSecret(length int) string {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
panic("FATAL: Failed to generate random TOTP bytes")
}
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
return strings.ToUpper(secret)
}
func GenerateTOTPURI(username string, secret string) string {
url := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
}
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.me")
query.Set("algorithm", "SHA1")
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH))
query.Set("period", fmt.Sprintf("%d", TIME_STEP))
url.RawQuery = query.Encode()
return url.String()
}
func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
totps := []model.TOTP{}
err := db.Select(
&totps,
"SELECT * FROM totp " +
"WHERE account=$1 " +
"ORDER BY created_at ASC",
accountID,
)
if err != nil {
return nil, err
}
return totps, nil
}
func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
totp := model.TOTP{}
err := db.Get(
&totp,
"SELECT * FROM totp " +
"WHERE account=$1",
accountID,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &totp, nil
}
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
_, err := db.Exec(
"INSERT INTO totp (account, name, secret) " +
"VALUES ($1,$2,$3)",
totp.AccountID,
totp.Name,
totp.Secret,
)
return err
}
func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
_, err := db.Exec(
"DELETE FROM totp WHERE account=$1 AND name=$2",
accountID,
name,
)
return err
}

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )

View file

@ -1,3 +0,0 @@
package global
const COOKIE_TOKEN string = "AM_TOKEN"

View file

@ -1,101 +0,0 @@
package global
import (
"fmt"
"math/rand"
"net/http"
"strconv"
"time"
"arimelody-web/colour"
)
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
"your mother",
"awesome powers beyond comprehension",
"jared",
"the weight of my sins",
"the arc reactor",
"AA batteries",
"15 euro solar panel from ebay",
"magnets, how do they work",
"a fax machine",
"dell optiplex",
"a trans girl's nintendo wii",
"BASS",
"electricity, duh",
"seven hamsters in a big wheel",
"girls",
"mzungu hosting",
"golang",
"the state of the world right now",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
"the good folks at aperture science",
"free2play CDs",
"aridoodle",
"the love of creating",
"not for the sake of art; not for the sake of money; we like painting naked people",
"30 billion dollars in VC funding",
}
func DefaultHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "arimelody.me")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
w.Header().Add("X-Thinking-With", "Portals")
w.Header().Add(
"X-Powered-By",
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
)
next.ServeHTTP(w, r)
})
}
type LoggingResponseWriter struct {
http.ResponseWriter
Status int
}
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := LoggingResponseWriter{w, http.StatusOK}
next.ServeHTTP(&lrw, r)
after := time.Now()
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
elapsed := "<1"
if difference >= 1 {
elapsed = strconv.Itoa(difference)
}
statusColour := colour.Reset
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,
statusColour,
lrw.Status,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])
})
}

340
main.go
View file

@ -4,16 +4,19 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/api" "arimelody-web/api"
"arimelody-web/colour"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global" "arimelody-web/model"
"arimelody-web/templates" "arimelody-web/templates"
"arimelody-web/view" "arimelody-web/view"
@ -29,56 +32,191 @@ const DEFAULT_PORT int64 = 8080
func main() { func main() {
fmt.Printf("made with <3 by ari melody\n\n") fmt.Printf("made with <3 by ari melody\n\n")
// TODO: refactor `global` to `AppState`
// this should contain `Config` and `DB`, and be passed through to all
// handlers that need it. it's better than weird static globals everywhere!
app := model.AppState{
Config: controller.GetConfig(),
}
// initialise database connection // initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } if app.Config.DB.Host == "" {
if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env }
if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env }
if global.Config.DB.Host == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if global.Config.DB.Name == "" { if app.Config.DB.Name == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if global.Config.DB.User == "" { if app.Config.DB.User == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if global.Config.DB.Pass == "" { if app.Config.DB.Pass == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
var err error var err error
global.DB, err = sqlx.Connect( app.DB, err = sqlx.Connect(
"postgres", "postgres",
fmt.Sprintf( fmt.Sprintf(
"host=%s user=%s dbname=%s password='%s' sslmode=disable", "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
global.Config.DB.Host, app.Config.DB.Host,
global.Config.DB.User, app.Config.DB.Port,
global.Config.DB.Name, app.Config.DB.User,
global.Config.DB.Pass, app.Config.DB.Name,
app.Config.DB.Pass,
), ),
) )
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1) os.Exit(1)
} }
global.DB.SetConnMaxLifetime(time.Minute * 3) app.DB.SetConnMaxLifetime(time.Minute * 3)
global.DB.SetMaxOpenConns(10) app.DB.SetMaxOpenConns(10)
global.DB.SetMaxIdleConns(10) app.DB.SetMaxIdleConns(10)
defer global.DB.Close() defer app.DB.Close()
// handle command arguments // handle command arguments
if len(os.Args) > 1 { if len(os.Args) > 1 {
arg := os.Args[1] arg := os.Args[1]
switch arg { switch arg {
case "createTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccount(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totp := model.TOTP {
AccountID: account.ID,
Name: totpName,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url)
return
case "deleteTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccount(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
case "listTOTP":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
account, err := controller.GetAccount(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
os.Exit(1)
}
for i, totp := range totps {
fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
}
if len(totps) == 0 {
fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username)
}
return
case "testTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccount(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1)
}
if totp == nil {
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1)
}
code := controller.GenerateTOTP(totp.Secret, 0)
fmt.Printf("%s\n", code)
return
case "createInvite": case "createInvite":
fmt.Printf("Creating invite...\n") fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(global.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, "Failed to create invite code: %v\n", err)
os.Exit(1) os.Exit(1)
@ -89,7 +227,7 @@ func main() {
case "purgeInvites": case "purgeInvites":
fmt.Printf("Deleting all invites...\n") fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(global.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, "Failed to delete invites: %v\n", err)
os.Exit(1) os.Exit(1)
@ -98,17 +236,38 @@ func main() {
fmt.Printf("Invites deleted successfully.\n") fmt.Printf("Invites deleted successfully.\n")
return return
case "listAccounts":
accounts, err := controller.GetAllAccounts(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
os.Exit(1)
}
for _, account := range accounts {
fmt.Printf(
"User: %s\n" +
"\tID: %s\n" +
"\tEmail: %s\n" +
"\tCreated: %s\n",
account.Username,
account.ID,
account.Email,
account.CreatedAt,
)
}
return
case "deleteAccount": case "deleteAccount":
if len(os.Args) < 2 { if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
os.Exit(1) os.Exit(1)
} }
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(global.DB, username) account, err := controller.GetAccount(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
@ -124,7 +283,7 @@ func main() {
return return
} }
err = controller.DeleteAccount(global.DB, username) err = controller.DeleteAccount(app.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
os.Exit(1) os.Exit(1)
@ -135,61 +294,68 @@ func main() {
} }
fmt.Printf( // command help
fmt.Print(
"Available commands:\n\n" + "Available commands:\n\n" +
"createTOTP <username> <name>:\n\tCreates a timed one-time passcode method.\n" +
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
"\n" +
"createInvite:\n\tCreates an invite code to register new accounts.\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" +
"listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n", "deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
) )
return return
} }
// handle DB migrations // handle DB migrations
controller.CheckDBVersionAndMigrate(global.DB) controller.CheckDBVersionAndMigrate(app.DB)
// initial invite code // initial invite code
accountsCount := 0 accountsCount := 0
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) } if err != nil { panic(err) }
if accountsCount == 0 { if accountsCount == 0 {
_, err := global.DB.Exec("DELETE FROM invite") _, err := app.DB.Exec("DELETE FROM invite")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
invite, err := controller.CreateInvite(global.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, "FATAL: 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.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl) fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
} }
// delete expired invites // delete expired invites
err = controller.DeleteExpiredInvites(global.DB) err = controller.DeleteExpiredInvites(app.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// start the web server! // start the web server!
mux := createServeMux() mux := createServeMux(&app)
fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port) fmt.Printf("Now serving at %s:%d\n", app.Config.BaseUrl, app.Config.Port)
log.Fatal( log.Fatal(
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), http.ListenAndServe(fmt.Sprintf(":%d", app.Config.Port),
global.HTTPLog(global.DefaultHeaders(mux)), HTTPLog(DefaultHeaders(mux)),
)) ))
} }
func createServeMux() *http.ServeMux { func createServeMux(app *model.AppState) *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads")))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -230,3 +396,93 @@ func staticHandler(directory string) http.Handler {
http.FileServer(http.Dir(directory)).ServeHTTP(w, r) http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
}) })
} }
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
"your mother",
"awesome powers beyond comprehension",
"jared",
"the weight of my sins",
"the arc reactor",
"AA batteries",
"15 euro solar panel from ebay",
"magnets, how do they work",
"a fax machine",
"dell optiplex",
"a trans girl's nintendo wii",
"BASS",
"electricity, duh",
"seven hamsters in a big wheel",
"girls",
"mzungu hosting",
"golang",
"the state of the world right now",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
"the good folks at aperture science",
"free2play CDs",
"aridoodle",
"the love of creating",
"not for the sake of art; not for the sake of money; we like painting naked people",
"30 billion dollars in VC funding",
}
func DefaultHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "arimelody.me")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
w.Header().Add("X-Thinking-With", "Portals")
w.Header().Add(
"X-Powered-By",
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
)
next.ServeHTTP(w, r)
})
}
type LoggingResponseWriter struct {
http.ResponseWriter
Status int
}
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := LoggingResponseWriter{w, http.StatusOK}
next.ServeHTTP(&lrw, r)
after := time.Now()
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
elapsed := "<1"
if difference >= 1 {
elapsed = strconv.Itoa(difference)
}
statusColour := colour.Reset
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,
statusColour,
lrw.Status,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])
})
}

View file

@ -1,5 +1,9 @@
package model package model
import "time"
const COOKIE_TOKEN string = "AM_TOKEN"
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
@ -7,6 +11,8 @@ type (
Password string `json:"password" db:"password"` Password string `json:"password" db:"password"`
Email string `json:"email" db:"email"` Email string `json:"email" db:"email"`
AvatarURL string `json:"avatar_url" db:"avatar_url"` AvatarURL string `json:"avatar_url" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Privileges []AccountPrivilege `json:"privileges"` Privileges []AccountPrivilege `json:"privileges"`
} }

32
model/appstate.go Normal file
View file

@ -0,0 +1,32 @@
package model
import "github.com/jmoiron/sqlx"
type (
DBConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Name string `toml:"name"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
DiscordConfig struct {
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
DB DBConfig `toml:"db"`
Discord DiscordConfig `toml:"discord"`
}
AppState struct {
DB *sqlx.DB
Config Config
}
)

12
model/totp.go Normal file
View file

@ -0,0 +1,12 @@
package model
import (
"time"
)
type TOTP struct {
Name string `json:"name" db:"name"`
AccountID string `json:"accountID" db:"account"`
Secret string `json:"-" db:"secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View file

@ -1,10 +1,4 @@
CREATE SCHEMA arimelody; CREATE SCHEMA IF NOT EXISTS arimelody;
-- Schema verison
CREATE TABLE arimelody.schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
-- --
-- Tables -- Tables
@ -16,7 +10,8 @@ CREATE TABLE arimelody.account (
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
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
@ -27,14 +22,6 @@ CREATE TABLE arimelody.privilege (
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- TOTP
CREATE TABLE arimelody.totp (
account uuid NOT NULL,
name text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Invites -- Invites
CREATE TABLE arimelody.invite ( CREATE TABLE arimelody.invite (
code text NOT NULL, code text NOT NULL,
@ -53,6 +40,16 @@ CREATE TABLE arimelody.token (
); );
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
-- TOTPs
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Artists (should be applicable to all art) -- Artists (should be applicable to all art)
CREATE TABLE arimelody.artist ( CREATE TABLE arimelody.artist (
@ -121,8 +118,8 @@ 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.totp ADD CONSTRAINT totp_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.token ADD CONSTRAINT token_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;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;

View file

@ -22,7 +22,8 @@ CREATE TABLE arimelody.account (
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
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
@ -33,14 +34,6 @@ CREATE TABLE arimelody.privilege (
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- TOTP
CREATE TABLE arimelody.totp (
account uuid NOT NULL,
name text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Invites -- Invites
CREATE TABLE arimelody.invite ( CREATE TABLE arimelody.invite (
code text NOT NULL, code text NOT NULL,
@ -59,7 +52,16 @@ CREATE TABLE arimelody.token (
); );
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
-- TOTPs
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Foreign keys -- Foreign keys
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.totp ADD CONSTRAINT totp_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.token ADD CONSTRAINT token_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;

View file

@ -6,37 +6,36 @@ import (
"os" "os"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/templates" "arimelody-web/templates"
) )
// HTTP HANDLER METHODS // HTTP HANDLER METHODS
func MusicHandler() http.Handler { func MusicHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
ServeCatalog().ServeHTTP(w, r) ServeCatalog(app).ServeHTTP(w, r)
return return
} }
release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true) release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
ServeGateway(release).ServeHTTP(w, r) ServeGateway(app, release).ServeHTTP(w, r)
})) }))
return mux return mux
} }
func ServeCatalog() http.Handler { func ServeCatalog(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) {
releases, err := controller.GetAllReleases(global.DB, true, 0, true) releases, err := controller.GetAllReleases(app.DB, true, 0, true)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -56,12 +55,12 @@ func ServeCatalog() http.Handler {
}) })
} }
func ServeGateway(release *model.Release) http.Handler { func ServeGateway(app *model.AppState, 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) {
// 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(global.DB, r) account, err := controller.GetAccountByRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)