schema migration and account fixes

very close to rolling this out! just need to address some security concerns first
This commit is contained in:
ari melody 2025-01-20 18:54:03 +00:00
parent 5566a795da
commit 570cdf6ce2
Signed by: ari
GPG key ID: CF99829C92678188
20 changed files with 641 additions and 392 deletions

View file

@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff!
## running
the server should be run once to generate a default `config.toml` file.
configure as needed. note that a valid DB connection is required, and the admin
panel will be disabled without valid discord app credentials (this can however
be bypassed by running the server with `-adminBypass`).
configure as needed. a valid DB connection is required to run this website.
if no admin users exist, an invite code will be provided. invite codes are
the only way to create admin accounts at this time.
the configuration may be overridden using environment variables in the format
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may
@ -32,6 +32,16 @@ be overridden with `ARIMELODY_DB_HOST`.
the location of the configuration file can also be overridden with
`ARIMELODY_CONFIG`.
## command arguments
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
need to be up for this, making this ideal for some offline maintenance.
- `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes.
- `deleteAccount <username>`: Deletes an account with a given `username`.
## database
the server requires a postgres database to run. you can use the

View file

@ -1,38 +0,0 @@
package admin
import (
"fmt"
"time"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
)
type (
Session struct {
Token string
Account *model.Account
Expires time.Time
}
)
const TOKEN_LENGTH = 64
var ADMIN_BYPASS = func() bool {
if global.Args["adminBypass"] == "true" {
fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)")
return true
}
return false
}()
var sessions []*Session
func createSession(account *model.Account, expires time.Time) Session {
return Session{
Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)),
Account: account,
Expires: expires,
}
}

View file

@ -26,8 +26,9 @@ func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/login", LoginHandler())
mux.Handle("/create-account", createAccountHandler())
mux.Handle("/register", createAccountHandler())
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
// TODO: /admin/account
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist())))
@ -96,7 +97,7 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account == nil {
@ -117,7 +118,7 @@ func LoginHandler() http.Handler {
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: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
@ -141,7 +142,6 @@ func LoginHandler() http.Handler {
err := r.ParseForm()
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
@ -151,21 +151,25 @@ func LoginHandler() http.Handler {
Password string `json:"password"`
TOTP string `json:"totp"`
}
data := LoginRequest{
credentials := LoginRequest{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"),
}
account, err := controller.GetAccount(global.DB, data.Username)
account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
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(account.Password, []byte(data.Password))
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
@ -174,7 +178,7 @@ func LoginHandler() http.Handler {
// login success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -209,24 +213,12 @@ func LogoutHandler() http.Handler {
return
}
token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
tokenStr := controller.GetTokenFromRequest(global.DB, r)
if token_str == "" {
cookie, err := r.Cookie(global.COOKIE_TOKEN)
if len(tokenStr) > 0 {
err := controller.DeleteToken(global.DB, tokenStr)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if cookie != nil {
token_str = cookie.Value
}
}
if len(token_str) > 0 {
err := controller.DeleteToken(global.DB, token_str)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -248,9 +240,141 @@ func LogoutHandler() http.Handler {
func createAccountHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := pages["create-account"].Execute(w, TemplateData{})
checkAccount, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Printf("Error rendering create account page: %s\n", err)
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
}

View file

@ -65,26 +65,23 @@ button:active {
background: #d0d0d0;
border-color: #808080;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Success}}
<meta http-equiv="refresh" content="5;url=/admin/" />
<p>
{{.Message}}
You should be redirected to <a href="/admin">/admin</a> in 5 seconds.
</p>
{{else}}
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
<form action="/admin/create-account" method="POST" id="create-account">
<form action="/admin/register" method="POST" id="create-account">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
@ -101,7 +98,5 @@ button:active {
<button type="submit" class="new">Create Account</button>
</form>
{{end}}
</main>
{{end}}

View file

@ -28,6 +28,10 @@
<div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a>
</div>
{{else}}
<div class="nav-item">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
</header>

View file

@ -43,6 +43,10 @@ input {
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
button {
padding: .5em .8em;
@ -89,7 +93,7 @@ button:active {
<input type="password" name="password" value="">
<label for="totp">TOTP</label>
<input type="text" name="totp" value="">
<input type="text" name="totp" value="" disabled>
</div>
<button type="submit" class="save">Login</button>

View file

@ -35,25 +35,35 @@ func handleLogin() http.HandlerFunc {
account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password))
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
// TODO: sessions and tokens
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Logged in successfully. TODO: Session tokens\n"))
err = json.NewEncoder(w).Encode(LoginResponse{
Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
@ -68,7 +78,7 @@ func handleAccountRegistration() http.HandlerFunc {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Code string `json:"code"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{}
@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc {
}
// make sure code exists in DB
invite := model.Invite{}
err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code)
invite, err := controller.GetInvite(global.DB, credentials.Invite)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.Error(w, "Invalid invite code", http.StatusBadRequest)
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
if invite == nil {
http.Error(w, "Invalid invite code", http.StatusBadRequest)
return
}
if time.Now().After(invite.ExpiresAt) {
err := controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
http.Error(w, "Invalid invite code", http.StatusBadRequest)
_, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) }
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
account := model.Account{
Username: credentials.Username,
Password: hashedPassword,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(global.DB, &account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error())
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
http.Error(w, "An account with that username already exists", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
_, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) }
err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
w.WriteHeader(http.StatusCreated)
w.Write([]byte("Account created successfully\n"))
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
err = json.NewEncoder(w).Encode(LoginResponse{
Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
@ -151,20 +176,22 @@ func handleDeleteAccount() http.HandlerFunc {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password))
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
http.Error(w, "Invalid password", http.StatusBadRequest)
return
}
err = controller.DeleteAccount(global.DB, account.ID)
// TODO: check TOTP
err = controller.DeleteAccount(global.DB, account.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

View file

@ -54,7 +54,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -62,7 +62,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %s\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)
return
}

View file

@ -22,7 +22,7 @@ func ServeRelease(release *model.Release) http.Handler {
if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -148,7 +148,7 @@ func ServeCatalog() http.Handler {
catalog := []Release{}
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

View file

@ -5,7 +5,6 @@ import (
"arimelody-web/model"
"errors"
"fmt"
"math/rand"
"net/http"
"strings"
@ -17,6 +16,9 @@ func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
@ -28,6 +30,9 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
@ -41,7 +46,7 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
if err != nil {
if err.Error() == "sql: no rows in result set" {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
@ -50,24 +55,28 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
return &account, nil
}
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 {
return tokenStr
}
if tokenStr == "" {
cookie, err := r.Cookie(global.COOKIE_TOKEN)
if err != nil {
// not logged in
return nil, nil
}
tokenStr = cookie.Value
return ""
}
return cookie.Value
}
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
tokenStr := GetTokenFromRequest(db, r)
token, err := GetToken(db, tokenStr)
if err != nil {
if strings.HasPrefix(err.Error(), "sql: no rows") {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, errors.New(fmt.Sprintf("GetToken: %s", err.Error()))
return nil, errors.New("GetToken: " + err.Error())
}
// does user-agent match the token?
@ -83,13 +92,16 @@ func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
}
func CreateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
err := db.Get(
&account.ID,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4)",
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username,
account.Password,
account.Email,
account.AvatarURL)
account.AvatarURL,
)
return err
}
@ -103,22 +115,13 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
account.Username,
account.Password,
account.Email,
account.AvatarURL)
account.AvatarURL,
)
return err
}
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
return err
}
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
func GenerateInviteCode(length int) []byte {
code := []byte{}
for i := 0; i < length; i++ {
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
}
return code
}

67
controller/invite.go Normal file
View file

@ -0,0 +1,67 @@
package controller
import (
"arimelody-web/model"
"math/rand"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) {
invite := model.Invite{}
err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &invite, nil
}
func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) {
invite := model.Invite{
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(lifetime),
}
code := []byte{}
for i := 0; i < length; i++ {
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
}
invite.Code = string(code)
_, err := db.Exec(
"INSERT INTO invite (code, created_at, expires_at) " +
"VALUES ($1, $2, $3)",
invite.Code,
invite.CreatedAt,
invite.ExpiresAt,
)
if err != nil {
return nil, err
}
return &invite, nil
}
func DeleteInvite(db *sqlx.DB, code string) error {
_, err := db.Exec("DELETE FROM invite WHERE code=$1", code)
return err
}
func DeleteAllInvites(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM invite")
return err
}
func DeleteExpiredInvites(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM invite WHERE expires_at<current_timestamp")
return err
}

86
controller/migrator.go Normal file
View file

@ -0,0 +1,86 @@
package controller
import (
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 2
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
db.MustExec("SET search_path TO arimelody, public")
db.MustExec(
"CREATE TABLE IF NOT EXISTS arimelody.schema_version (" +
"version INTEGER PRIMARY KEY, " +
"applied_at TIMESTAMP DEFAULT current_timestamp)",
)
oldDBVersion := 0
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
if err != nil { panic(err) }
for oldDBVersion < DB_VERSION {
switch oldDBVersion {
case 0:
// default case; assume no database exists
ApplyMigration(db, "000-init")
oldDBVersion = DB_VERSION
case 1:
// the irony is i actually have to awkwardly shove schema_version
// into the old database in order for this to work LOL
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
}
}
fmt.Printf("Database schema up to date.\n")
}
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
os.Exit(1)
}
script := string(bytes)
tx, err := db.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(script)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to apply migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(
"INSERT INTO schema_version (version, applied_at) " +
"VALUES ($1, $2)",
DB_VERSION,
time.Now(),
)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to update schema version: %v\n", err)
os.Exit(1)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to commit transaction: %v\n", err)
os.Exit(1)
}
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2"
@ -57,13 +56,13 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1)
}
err = handleConfigOverrides(&config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error())
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1)
}
@ -92,30 +91,4 @@ func handleConfigOverrides(config *config) error {
return nil
}
var Args = func() map[string]string {
args := map[string]string{}
index := 0
for index < len(os.Args[1:]) {
arg := os.Args[index + 1]
if !strings.HasPrefix(arg, "-") {
fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg)
os.Exit(1)
}
if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") {
args[arg[1:]] = "true"
index += 1
continue
}
val := os.Args[index + 2]
args[arg[1:]] = val
// fmt.Printf("%s: %s\n", arg[1:], val)
index += 2
}
return args
}()
var DB *sqlx.DB

View file

@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler {
type LoggingResponseWriter struct {
http.ResponseWriter
Code int
Status int
}
func (lrw *LoggingResponseWriter) WriteHeader(code int) {
lrw.Code = code
lrw.ResponseWriter.WriteHeader(code)
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
@ -81,19 +81,19 @@ func HTTPLog(next http.Handler) http.Handler {
elapsed = strconv.Itoa(difference)
}
codeColour := colour.Reset
statusColour := colour.Reset
if lrw.Code - 600 <= 0 { codeColour = colour.Red }
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow }
if lrw.Code - 400 <= 0 { codeColour = colour.White }
if lrw.Code - 300 <= 0 { codeColour = colour.Green }
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,
codeColour,
lrw.Code,
statusColour,
lrw.Status,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])

251
main.go
View file

@ -7,22 +7,28 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/global"
"arimelody-web/view"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
// initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env }
if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
@ -65,39 +71,107 @@ func main() {
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
_, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP")
// handle command arguments
if len(os.Args) > 1 {
arg := os.Args[1]
switch arg {
case "createInvite":
fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
os.Exit(1)
}
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code)
return
case "purgeInvites":
fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
os.Exit(1)
}
fmt.Printf("Invites deleted successfully.\n")
return
case "deleteAccount":
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error())
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username)
res := ""
fmt.Scanln(&res)
if !strings.HasPrefix(res, "y") {
return
}
err = controller.DeleteAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
}
fmt.Printf(
"Available commands:\n\n" +
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" +
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
)
return
}
// handle DB migrations
controller.CheckDBVersionAndMigrate(global.DB)
// initial invite code
accountsCount := 0
global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
if accountsCount == 0 {
code := controller.GenerateInviteCode(8)
tx, err := global.DB.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec("DELETE FROM invite")
_, err := global.DB.Exec("DELETE FROM invite")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute))
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
os.Exit(1)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1)
}
fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)")
fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl)
}
// delete expired invites
err = controller.DeleteExpiredInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1)
}
// start the web server!
@ -109,141 +183,6 @@ func main() {
))
}
func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) {
db, err := sqlx.Connect(driverName, dataSourceName)
if err != nil { return nil, err }
// ensure tables exist
// account
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS account (" +
"id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " +
"username text NOT NULL UNIQUE, " +
"password text NOT NULL, " +
"email text, " +
"avatar_url text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) }
// privilege
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS privilege (" +
"account uuid NOT NULL, " +
"privilege text NOT NULL, " +
"CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " +
"CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) }
// totp
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS totp (" +
"account uuid NOT NULL, " +
"name text NOT NULL, " +
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
"CONSTRAINT totp_pk PRIMARY KEY (account, name), " +
"CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) }
// invites
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS invite (" +
"code text NOT NULL PRIMARY KEY, " +
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
"expires_at TIMESTAMP NOT NULL)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) }
// account token
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS token (" +
"token TEXT PRIMARY KEY," +
"account UUID REFERENCES account(id) ON DELETE CASCADE NOT NULL," +
"user_agent TEXT NOT NULL," +
"created_at TIMESTAMP NOT NULL DEFAULT current_timestamp)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create token table: %s\n", err.Error())) }
// artist
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS artist (" +
"id character varying(64) PRIMARY KEY, " +
"name text NOT NULL, " +
"website text, " +
"avatar text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) }
// musicrelease
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS musicrelease (" +
"id character varying(64) PRIMARY KEY, " +
"visible bool DEFAULT false, " +
"title text NOT NULL, " +
"description text, " +
"type text, " +
"release_date TIMESTAMP NOT NULL, " +
"artwork text, " +
"buyname text, " +
"buylink text, " +
"copyright text, " +
"copyrightURL text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) }
// musiclink
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musiclink (" +
"release character varying(64) NOT NULL, " +
"name text NOT NULL, " +
"url text NOT NULL, " +
"CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " +
"CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) }
// musiccredit
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musiccredit (" +
"release character varying(64) NOT NULL, " +
"artist character varying(64) NOT NULL, " +
"role text NOT NULL, " +
"is_primary boolean DEFAULT false, " +
"CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " +
"CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " +
"CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) }
// musictrack
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musictrack (" +
"id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " +
"title text NOT NULL, " +
"description text, " +
"lyrics text, " +
"preview_url text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) }
// musicreleasetrack
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" +
"release character varying(64) NOT NULL, " +
"track uuid NOT NULL, " +
"number integer NOT NULL, " +
"CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " +
"CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " +
"CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) }
// TODO: automatic database migration
return db, nil
}
func createServeMux() *http.ServeMux {
mux := http.NewServeMux()

View file

@ -1,27 +1,16 @@
package model
import (
"time"
)
type (
Account struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password []byte `json:"password" db:"password"`
Password string `json:"password" db:"password"`
Email string `json:"email" db:"email"`
AvatarURL string `json:"avatar_url" db:"avatar_url"`
Privileges []AccountPrivilege `json:"privileges"`
}
AccountPrivilege string
Invite struct {
Code string `db:"code"`
CreatedByID string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
)
const (

10
model/invite.go Normal file
View file

@ -0,0 +1,10 @@
package model
import "time"
type Invite struct {
Code string `db:"code"`
CreatedByID string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}

View file

@ -1,8 +1,16 @@
CREATE SCHEMA arimelody AUTHORIZATION arimelody;
CREATE SCHEMA arimelody;
-- Schema verison
CREATE TABLE arimelody.schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
--
-- Acounts
-- Tables
--
-- Accounts
CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
@ -12,18 +20,14 @@ CREATE TABLE arimelody.account (
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
--
-- Privilege
--
CREATE TABLE arimelody.privilege (
account uuid NOT NULL,
privilege text NOT NULL
);
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,
@ -31,9 +35,7 @@ CREATE TABLE arimelody.totp (
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
--
-- Invites
--
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -41,9 +43,7 @@ CREATE TABLE arimelody.invite (
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
--
-- Tokens
--
CREATE TABLE arimelody.token (
token TEXT,
account UUID NOT NULL,
@ -54,9 +54,7 @@ CREATE TABLE arimelody.token (
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
--
-- Artists (should be applicable to all art)
--
CREATE TABLE arimelody.artist (
id character varying(64),
name text NOT NULL,
@ -65,9 +63,7 @@ CREATE TABLE arimelody.artist (
);
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
--
-- Music releases
--
CREATE TABLE arimelody.musicrelease (
id character varying(64) NOT NULL,
visible bool DEFAULT false,
@ -83,9 +79,7 @@ CREATE TABLE arimelody.musicrelease (
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
--
-- Music links (external platform links under a release)
--
CREATE TABLE arimelody.musiclink (
release character varying(64) NOT NULL,
name text NOT NULL,
@ -93,9 +87,7 @@ CREATE TABLE arimelody.musiclink (
);
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
--
-- Music credits (artist credits under a release)
--
CREATE TABLE arimelody.musiccredit (
release character varying(64) NOT NULL,
artist character varying(64) NOT NULL,
@ -104,9 +96,7 @@ CREATE TABLE arimelody.musiccredit (
);
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
--
-- Music tracks (tracks under a release)
--
CREATE TABLE arimelody.musictrack (
id uuid DEFAULT gen_random_uuid(),
title text NOT NULL,
@ -116,9 +106,7 @@ CREATE TABLE arimelody.musictrack (
);
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
--
-- Music release/track pairs
--
CREATE TABLE arimelody.musicreleasetrack (
release character varying(64) NOT NULL,
track uuid NOT NULL,
@ -126,9 +114,12 @@ CREATE TABLE arimelody.musicreleasetrack (
);
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
--
-- Foreign keys
--
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;

View file

@ -0,0 +1,65 @@
--
-- Migration
--
-- Move existing tables to new schema
ALTER TABLE public.artist SET SCHEMA arimelody;
ALTER TABLE public.musicrelease SET SCHEMA arimelody;
ALTER TABLE public.musiclink SET SCHEMA arimelody;
ALTER TABLE public.musiccredit SET SCHEMA arimelody;
ALTER TABLE public.musictrack SET SCHEMA arimelody;
ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody;
--
-- New items
--
-- Acounts
CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
password text NOT NULL,
email text,
avatar_url text
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
account uuid NOT NULL,
privilege text NOT NULL
);
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
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Tokens
CREATE TABLE arimelody.token (
token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL
);
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
-- Foreign keys
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;

View file

@ -63,7 +63,7 @@ func ServeGateway(release *model.Release) http.Handler {
if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}