schema migration and account fixes
very close to rolling this out! just need to address some security concerns first
This commit is contained in:
parent
5566a795da
commit
570cdf6ce2
16
README.md
16
README.md
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
180
admin/http.go
180
admin/http.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
67
controller/invite.go
Normal 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
86
controller/migrator.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
251
main.go
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
10
model/invite.go
Normal 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"`
|
||||||
|
}
|
|
@ -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;
|
65
schema_migration/001-pre-versioning.sql
Normal file
65
schema_migration/001-pre-versioning.sql
Normal 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;
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue