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 ## running
the server should be run once to generate a default `config.toml` file. 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 configure as needed. a valid DB connection is required to run this website.
panel will be disabled without valid discord app credentials (this can however if no admin users exist, an invite code will be provided. invite codes are
be bypassed by running the server with `-adminBypass`). the only way to create admin accounts at this time.
the configuration may be overridden using environment variables in the format the configuration may be overridden using environment variables in the format
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may `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 the location of the configuration file can also be overridden with
`ARIMELODY_CONFIG`. `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 ## database
the server requires a postgres database to run. you can use the 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 := http.NewServeMux()
mux.Handle("/login", LoginHandler()) mux.Handle("/login", LoginHandler())
mux.Handle("/create-account", createAccountHandler()) mux.Handle("/register", createAccountHandler())
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
// TODO: /admin/account
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(global.DB, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) 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) account, err := controller.GetAccountByRequest(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: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return return
} }
if account == nil { if account == nil {
@ -117,7 +118,7 @@ func LoginHandler() http.Handler {
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(global.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: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return return
} }
if account != nil { if account != nil {
@ -141,7 +142,6 @@ func LoginHandler() http.Handler {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
@ -151,21 +151,25 @@ func LoginHandler() http.Handler {
Password string `json:"password"` Password string `json:"password"`
TOTP string `json:"totp"` TOTP string `json:"totp"`
} }
data := LoginRequest{ credentials := LoginRequest{
Username: r.Form.Get("username"), Username: r.Form.Get("username"),
Password: r.Form.Get("password"), Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"), TOTP: r.Form.Get("totp"),
} }
account, err := controller.GetAccount(global.DB, data.Username) account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil { 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 return
} }
err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil { 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 return
} }
@ -174,7 +178,7 @@ func LoginHandler() http.Handler {
// login success! // login success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -209,24 +213,12 @@ func LogoutHandler() http.Handler {
return return
} }
token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") tokenStr := controller.GetTokenFromRequest(global.DB, r)
if token_str == "" { if len(tokenStr) > 0 {
cookie, err := r.Cookie(global.COOKIE_TOKEN) err := controller.DeleteToken(global.DB, tokenStr)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\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())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -248,9 +240,141 @@ func LogoutHandler() http.Handler {
func createAccountHandler() http.Handler { func createAccountHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -65,26 +65,23 @@ button:active {
background: #d0d0d0; background: #d0d0d0;
border-color: #808080; border-color: #808080;
} }
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <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}} {{if .Message}}
<p id="error">{{.Message}}</p> <p id="error">{{.Message}}</p>
{{end}} {{end}}
<form action="/admin/create-account" method="POST" id="create-account"> <form action="/admin/register" method="POST" id="create-account">
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" value=""> <input type="text" name="username" value="">
@ -101,7 +98,5 @@ button:active {
<button type="submit" class="new">Create Account</button> <button type="submit" class="new">Create Account</button>
</form> </form>
{{end}}
</main> </main>
{{end}} {{end}}

View file

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

View file

@ -43,6 +43,10 @@ input {
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
} }
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
button { button {
padding: .5em .8em; padding: .5em .8em;
@ -89,7 +93,7 @@ button:active {
<input type="password" name="password" value=""> <input type="password" name="password" value="">
<label for="totp">TOTP</label> <label for="totp">TOTP</label>
<input type="text" name="totp" value=""> <input type="text" name="totp" value="" disabled>
</div> </div>
<button type="submit" class="save">Login</button> <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) account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return 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 { if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return 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) err = json.NewEncoder(w).Encode(LoginResponse{
w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) 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"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
Code string `json:"code"` Invite string `json:"invite"`
} }
credentials := RegisterRequest{} credentials := RegisterRequest{}
@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc {
} }
// make sure code exists in DB // make sure code exists in DB
invite := model.Invite{} invite, err := controller.GetInvite(global.DB, credentials.Invite)
err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
http.Error(w, "Invalid invite code", http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) if invite == nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, "Invalid invite code", http.StatusBadRequest)
return return
} }
if time.Now().After(invite.ExpiresAt) { 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) 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 return
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
account := model.Account{ account := model.Account{
Username: credentials.Username, Username: credentials.Username,
Password: hashedPassword, Password: string(hashedPassword),
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(global.DB, &account)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
_, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
w.WriteHeader(http.StatusCreated) token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
w.Write([]byte("Account created successfully\n")) 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) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil { if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid password", http.StatusBadRequest)
return return
} }
err = controller.DeleteAccount(global.DB, account.ID) // TODO: check TOTP
err = controller.DeleteAccount(global.DB, account.Username)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -54,7 +54,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -62,7 +62,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -22,7 +22,7 @@ func ServeRelease(release *model.Release) http.Handler {
if !release.Visible { if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -148,7 +148,7 @@ func ServeCatalog() http.Handler {
catalog := []Release{} catalog := []Release{}
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -5,7 +5,6 @@ import (
"arimelody-web/model" "arimelody-web/model"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"net/http" "net/http"
"strings" "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) err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err 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) err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err 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) err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
if err != nil { if err != nil {
if err.Error() == "sql: no rows in result set" { if strings.Contains(err.Error(), "no rows") {
return nil, nil return nil, nil
} }
return nil, err return nil, err
@ -50,24 +55,28 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
return &account, nil 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 ") tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 {
return tokenStr
}
if tokenStr == "" {
cookie, err := r.Cookie(global.COOKIE_TOKEN) cookie, err := r.Cookie(global.COOKIE_TOKEN)
if err != nil { if err != nil {
// not logged in return ""
return nil, nil
}
tokenStr = cookie.Value
} }
return cookie.Value
}
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
tokenStr := GetTokenFromRequest(db, r)
token, err := GetToken(db, tokenStr) token, err := GetToken(db, tokenStr)
if err != nil { if err != nil {
if strings.HasPrefix(err.Error(), "sql: no rows") { if strings.Contains(err.Error(), "no rows") {
return nil, nil 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? // 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 { 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) " + "INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4)", "VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username, account.Username,
account.Password, account.Password,
account.Email, account.Email,
account.AvatarURL) account.AvatarURL,
)
return err return err
} }
@ -103,22 +115,13 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
account.Username, account.Username,
account.Password, account.Password,
account.Email, account.Email,
account.AvatarURL) account.AvatarURL,
)
return err return err
} }
func DeleteAccount(db *sqlx.DB, accountID string) error { func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) _, err := db.Exec("DELETE FROM account WHERE username=$1", username)
return err 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" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
@ -57,13 +56,13 @@ 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: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1) 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 %s\n", err.Error()) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -92,30 +91,4 @@ func handleConfigOverrides(config *config) error {
return nil 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 var DB *sqlx.DB

View file

@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler {
type LoggingResponseWriter struct { type LoggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
Code int Status int
} }
func (lrw *LoggingResponseWriter) WriteHeader(code int) { func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Code = code lrw.Status = status
lrw.ResponseWriter.WriteHeader(code) lrw.ResponseWriter.WriteHeader(status)
} }
func HTTPLog(next http.Handler) http.Handler { func HTTPLog(next http.Handler) http.Handler {
@ -81,19 +81,19 @@ func HTTPLog(next http.Handler) http.Handler {
elapsed = strconv.Itoa(difference) elapsed = strconv.Itoa(difference)
} }
codeColour := colour.Reset statusColour := colour.Reset
if lrw.Code - 600 <= 0 { codeColour = colour.Red } if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow } if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Code - 400 <= 0 { codeColour = colour.White } if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Code - 300 <= 0 { codeColour = colour.Green } if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate), after.Format(time.UnixDate),
r.Method, r.Method,
r.URL.Path, r.URL.Path,
codeColour, statusColour,
lrw.Code, lrw.Status,
colour.Reset, colour.Reset,
elapsed, elapsed,
r.Header["User-Agent"][0]) r.Header["User-Agent"][0])

251
main.go
View file

@ -7,22 +7,28 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/api" "arimelody-web/api"
"arimelody-web/global"
"arimelody-web/view"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/templates" "arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080 const DEFAULT_PORT int64 = 8080
func main() { func main() {
fmt.Printf("made with <3 by ari melody\n\n")
// initialise database connection // initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } 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 } if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
@ -65,39 +71,107 @@ func main() {
global.DB.SetMaxIdleConns(10) global.DB.SetMaxIdleConns(10)
defer global.DB.Close() 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 { 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) 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 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 { if accountsCount == 0 {
code := controller.GenerateInviteCode(8) _, err := global.DB.Exec("DELETE FROM invite")
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")
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)
} }
_, 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 { 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)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
os.Exit(1) 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! // 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 { func createServeMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()

View file

@ -1,27 +1,16 @@
package model package model
import (
"time"
)
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password []byte `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"`
Privileges []AccountPrivilege `json:"privileges"` Privileges []AccountPrivilege `json:"privileges"`
} }
AccountPrivilege string 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 ( 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 ( CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(), id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE, username text NOT NULL UNIQUE,
@ -12,18 +20,14 @@ CREATE TABLE arimelody.account (
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
--
-- Privilege -- Privilege
--
CREATE TABLE arimelody.privilege ( CREATE TABLE arimelody.privilege (
account uuid NOT NULL, account uuid NOT NULL,
privilege text NOT NULL privilege text NOT NULL
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
--
-- TOTP -- TOTP
--
CREATE TABLE arimelody.totp ( CREATE TABLE arimelody.totp (
account uuid NOT NULL, account uuid NOT NULL,
name text 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); 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,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 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); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
--
-- Tokens -- Tokens
--
CREATE TABLE arimelody.token ( CREATE TABLE arimelody.token (
token TEXT, token TEXT,
account UUID NOT NULL, account UUID NOT NULL,
@ -54,9 +54,7 @@ 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);
--
-- Artists (should be applicable to all art) -- Artists (should be applicable to all art)
--
CREATE TABLE arimelody.artist ( CREATE TABLE arimelody.artist (
id character varying(64), id character varying(64),
name text NOT NULL, name text NOT NULL,
@ -65,9 +63,7 @@ CREATE TABLE arimelody.artist (
); );
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
--
-- Music releases -- Music releases
--
CREATE TABLE arimelody.musicrelease ( CREATE TABLE arimelody.musicrelease (
id character varying(64) NOT NULL, id character varying(64) NOT NULL,
visible bool DEFAULT false, visible bool DEFAULT false,
@ -83,9 +79,7 @@ CREATE TABLE arimelody.musicrelease (
); );
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
--
-- Music links (external platform links under a release) -- Music links (external platform links under a release)
--
CREATE TABLE arimelody.musiclink ( CREATE TABLE arimelody.musiclink (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
name text 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); ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
--
-- Music credits (artist credits under a release) -- Music credits (artist credits under a release)
--
CREATE TABLE arimelody.musiccredit ( CREATE TABLE arimelody.musiccredit (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
artist 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); ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
--
-- Music tracks (tracks under a release) -- Music tracks (tracks under a release)
--
CREATE TABLE arimelody.musictrack ( CREATE TABLE arimelody.musictrack (
id uuid DEFAULT gen_random_uuid(), id uuid DEFAULT gen_random_uuid(),
title text NOT NULL, title text NOT NULL,
@ -116,9 +106,7 @@ CREATE TABLE arimelody.musictrack (
); );
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
--
-- Music release/track pairs -- Music release/track pairs
--
CREATE TABLE arimelody.musicreleasetrack ( CREATE TABLE arimelody.musicreleasetrack (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
track uuid 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); ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
-- --
-- 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.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;

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 { if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r) account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }