From 570cdf6ce2249b8060bdc67bec9b685fce25c938 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 18:54:03 +0000 Subject: [PATCH] schema migration and account fixes very close to rolling this out! just need to address some security concerns first --- README.md | 16 +- admin/admin.go | 38 --- admin/http.go | 180 +++++++++++--- admin/views/create-account.html | 21 +- admin/views/layout.html | 4 + admin/views/login.html | 6 +- api/account.go | 89 ++++--- api/artist.go | 4 +- api/release.go | 4 +- controller/account.go | 83 ++++--- controller/invite.go | 67 +++++ controller/migrator.go | 86 +++++++ global/config.go | 31 +-- global/funcs.go | 22 +- main.go | 257 ++++++++------------ model/account.go | 13 +- model/invite.go | 10 + schema.sql => schema_migration/000-init.sql | 35 +-- schema_migration/001-pre-versioning.sql | 65 +++++ view/music.go | 2 +- 20 files changed, 641 insertions(+), 392 deletions(-) delete mode 100644 admin/admin.go create mode 100644 controller/invite.go create mode 100644 controller/migrator.go create mode 100644 model/invite.go rename schema.sql => schema_migration/000-init.sql (95%) create mode 100644 schema_migration/001-pre-versioning.sql diff --git a/README.md b/README.md index df0c351..0873ff6 100644 --- a/README.md +++ b/README.md @@ -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__`. 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 `: Deletes an account with a given `username`. + ## database the server requires a postgres database to run. you can use the diff --git a/admin/admin.go b/admin/admin.go deleted file mode 100644 index 92ba8d5..0000000 --- a/admin/admin.go +++ /dev/null @@ -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, - } -} diff --git a/admin/http.go b/admin/http.go index d71213f..4dbc66b 100644 --- a/admin/http.go +++ b/admin/http.go @@ -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 } diff --git a/admin/views/create-account.html b/admin/views/create-account.html index 1976d27..5d92627 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -65,26 +65,23 @@ button:active { background: #d0d0d0; border-color: #808080; } + +#error { + background: #ffa9b8; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; +} {{end}} {{define "content"}}
- {{if .Success}} - - -

- {{.Message}} - You should be redirected to /admin in 5 seconds. -

- - {{else}} - {{if .Message}}

{{.Message}}

{{end}} -
+
@@ -101,7 +98,5 @@ button:active { - - {{end}}
{{end}} diff --git a/admin/views/layout.html b/admin/views/layout.html index dd12bf5..0a33b72 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -28,6 +28,10 @@ + {{else}} + {{end}} diff --git a/admin/views/login.html b/admin/views/login.html index de69be8..16c0fcc 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -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 { - + diff --git a/api/account.go b/api/account.go index 37c4c7f..3ce52c8 100644 --- a/api/account.go +++ b/api/account.go @@ -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) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + 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 } diff --git a/api/artist.go b/api/artist.go index 6158fb6..c46db59 100644 --- a/api/artist.go +++ b/api/artist.go @@ -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 } diff --git a/api/release.go b/api/release.go index 5f9d590..d17fb5f 100644 --- a/api/release.go +++ b/api/release.go @@ -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 } diff --git a/controller/account.go b/controller/account.go index 856d493..362e297 100644 --- a/controller/account.go +++ b/controller/account.go @@ -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 tokenStr == "" { - cookie, err := r.Cookie(global.COOKIE_TOKEN) - if err != nil { - // not logged in - return nil, nil - } - tokenStr = cookie.Value + if len(tokenStr) > 0 { + return tokenStr } + cookie, err := r.Cookie(global.COOKIE_TOKEN) + if err != nil { + 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,42 +92,36 @@ func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { } func CreateAccount(db *sqlx.DB, account *model.Account) error { - _, err := db.Exec( - "INSERT INTO account (username, password, email, avatar_url) " + - "VALUES ($1, $2, $3, $4)", - account.Username, - account.Password, - account.Email, - account.AvatarURL) + err := db.Get( + &account.ID, + "INSERT INTO account (username, password, email, avatar_url) " + + "VALUES ($1, $2, $3, $4) " + + "RETURNING id", + account.Username, + account.Password, + account.Email, + account.AvatarURL, + ) return err } func UpdateAccount(db *sqlx.DB, account *model.Account) error { _, err := db.Exec( - "UPDATE account " + - "SET username=$2, password=$3, email=$4, avatar_url=$5) " + - "WHERE id=$1", - account.ID, - account.Username, - account.Password, - account.Email, - account.AvatarURL) + "UPDATE account " + + "SET username=$2, password=$3, email=$4, avatar_url=$5) " + + "WHERE id=$1", + account.ID, + account.Username, + account.Password, + account.Email, + 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 -} diff --git a/controller/invite.go b/controller/invite.go new file mode 100644 index 0000000..f30db64 --- /dev/null +++ b/controller/invite.go @@ -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 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 diff --git a/global/funcs.go b/global/funcs.go index 4bb3a15..49edb01 100644 --- a/global/funcs.go +++ b/global/funcs.go @@ -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]) diff --git a/main.go b/main.go index f50da24..2f0cb43 100644 --- a/main.go +++ b/main.go @@ -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") - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) - os.Exit(1) + // 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, "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 :\n\tDeletes an account with a given `username`.\n", + ) + return } - accountsCount := 0 - global.DB.Get(&accountsCount, "SELECT count(*) FROM account") - if accountsCount == 0 { - code := controller.GenerateInviteCode(8) + // handle DB migrations + controller.CheckDBVersionAndMigrate(global.DB) - 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") + // initial invite code + accountsCount := 0 + err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") + if err != nil { panic(err) } + if accountsCount == 0 { + _, 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() diff --git a/model/account.go b/model/account.go index 16b0e3b..03e95c5 100644 --- a/model/account.go +++ b/model/account.go @@ -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 ( diff --git a/model/invite.go b/model/invite.go new file mode 100644 index 0000000..b7a66ae --- /dev/null +++ b/model/invite.go @@ -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"` +} diff --git a/schema.sql b/schema_migration/000-init.sql similarity index 95% rename from schema.sql rename to schema_migration/000-init.sql index f835044..cd11a5e 100644 --- a/schema.sql +++ b/schema_migration/000-init.sql @@ -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; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql new file mode 100644 index 0000000..fc730a0 --- /dev/null +++ b/schema_migration/001-pre-versioning.sql @@ -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; diff --git a/view/music.go b/view/music.go index 95fe38f..3799182 100644 --- a/view/music.go +++ b/view/music.go @@ -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 }