Compare commits
10 commits
570cdf6ce2
...
00ebf99cc0
Author | SHA1 | Date | |
---|---|---|---|
00ebf99cc0 | |||
3d674515ce | |||
7bea6b548e | |||
7964fbbf99 | |||
686eea09a5 | |||
39b332b477 | |||
7044f7344b | |||
ae254dd731 | |||
5e493554dc | |||
be5cd05d08 |
43 changed files with 1243 additions and 847 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,4 +7,4 @@ uploads/
|
|||
docker-compose*.yml
|
||||
!docker-compose.example.yml
|
||||
config*.toml
|
||||
>>>>>>> dev
|
||||
arimelody-web
|
||||
|
|
13
README.md
13
README.md
|
@ -32,18 +32,17 @@ be overridden with `ARIMELODY_DB_HOST`.
|
|||
the location of the configuration file can also be overridden with
|
||||
`ARIMELODY_CONFIG`.
|
||||
|
||||
## command arguments
|
||||
### 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.
|
||||
|
||||
- `createTOTP <username> <name>`: Creates a timed one-time passcode method.
|
||||
- `listTOTP <username>`: Lists an account's TOTP methods.
|
||||
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
|
||||
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
|
||||
- `createInvite`: Creates an invite code to register new accounts.
|
||||
- `purgeInvites`: Deletes all available invite codes.
|
||||
- `listAccounts`: Lists all active accounts.
|
||||
- `deleteAccount <username>`: Deletes an account with a given `username`.
|
||||
|
||||
## database
|
||||
|
||||
the server requires a postgres database to run. you can use the
|
||||
[schema.sql](schema.sql) provided in this repo to generate the required tables.
|
||||
automatic schema building/migration may come in a future update.
|
||||
|
|
343
admin/accounthttp.go
Normal file
343
admin/accounthttp.go
Normal file
|
@ -0,0 +1,343 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
Account *model.Account
|
||||
Message string
|
||||
Token string
|
||||
}
|
||||
|
||||
func AccountHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
type AccountResponse struct {
|
||||
Account *model.Account
|
||||
TOTPs []model.TOTP
|
||||
}
|
||||
|
||||
err = pages["account"].Execute(w, AccountResponse{
|
||||
Account: account,
|
||||
TOTPs: totps,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to render admin account page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func LoginHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Account *model.Account
|
||||
Token string
|
||||
Message string
|
||||
}
|
||||
|
||||
render := func(data LoginResponse) {
|
||||
err := pages["login"].Execute(w, data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r);
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Malformed request." })
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
credentials := LoginRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Password: r.Form.Get("password"),
|
||||
TOTP: r.Form.Get("totp"),
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(app.DB, credentials.Username)
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
return
|
||||
}
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
||||
return
|
||||
}
|
||||
if len(totps) > 0 {
|
||||
success := false
|
||||
for _, totp := range totps {
|
||||
check := controller.GenerateTOTP(totp.Secret, 0)
|
||||
if check == credentials.TOTP {
|
||||
success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
render(LoginResponse{ Message: "Invalid TOTP" })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: user should be prompted to add 2FA method
|
||||
}
|
||||
|
||||
// login success!
|
||||
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = model.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(app.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
render(LoginResponse{ Account: account, Token: token.Token })
|
||||
})
|
||||
}
|
||||
|
||||
func LogoutHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := controller.GetTokenFromRequest(app.DB, r)
|
||||
|
||||
if len(tokenStr) > 0 {
|
||||
err := controller.DeleteToken(app.DB, tokenStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = model.COOKIE_TOKEN
|
||||
cookie.Value = ""
|
||||
cookie.Expires = time.Now()
|
||||
if strings.HasPrefix(app.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func createAccountHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checkAccount, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if checkAccount != nil {
|
||||
// user is already logged in
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
type CreateAccountResponse struct {
|
||||
Account *model.Account
|
||||
Message string
|
||||
}
|
||||
|
||||
render := func(data CreateAccountResponse) {
|
||||
err := pages["create-account"].Execute(w, data)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
render(CreateAccountResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
render(CreateAccountResponse{
|
||||
Message: "Malformed data.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
credentials := RegisterRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Email: r.Form.Get("email"),
|
||||
Password: r.Form.Get("password"),
|
||||
Invite: r.Form.Get("invite"),
|
||||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite, err := controller.GetInvite(app.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
||||
if invite != nil {
|
||||
err := controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
}
|
||||
render(CreateAccountResponse{
|
||||
Message: "Invalid invite code.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: string(hashedPassword),
|
||||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(app.DB, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
render(CreateAccountResponse{
|
||||
Message: "An account with that username already exists.",
|
||||
})
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
// registration success!
|
||||
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
// gracefully redirect user to login page
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = model.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(app.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{
|
||||
Account: &account,
|
||||
Token: token.Token,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,16 +5,15 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/controller"
|
||||
)
|
||||
|
||||
func serveArtist() http.Handler {
|
||||
func serveArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
id := slices[0]
|
||||
artist, err := controller.GetArtist(global.DB, id)
|
||||
artist, err := controller.GetArtist(app.DB, id)
|
||||
if err != nil {
|
||||
if artist == nil {
|
||||
http.NotFound(w, r)
|
||||
|
@ -25,7 +24,7 @@ func serveArtist() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
credits, err := controller.GetArtistCredits(global.DB, artist.ID, true)
|
||||
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
308
admin/http.go
308
admin/http.go
|
@ -6,40 +6,29 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
Account *model.Account
|
||||
Token string
|
||||
}
|
||||
|
||||
func Handler() http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/login", LoginHandler())
|
||||
mux.Handle("/register", createAccountHandler())
|
||||
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
|
||||
// TODO: /admin/account
|
||||
mux.Handle("/login", LoginHandler(app))
|
||||
mux.Handle("/register", createAccountHandler(app))
|
||||
mux.Handle("/logout", RequireAccount(app, LogoutHandler(app)))
|
||||
mux.Handle("/account", RequireAccount(app, AccountHandler(app)))
|
||||
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())))
|
||||
mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack())))
|
||||
mux.Handle("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app))))
|
||||
mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
|
||||
mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app))))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
|
||||
}
|
||||
|
@ -48,21 +37,21 @@ func Handler() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
artists, err := controller.GetAllArtists(global.DB)
|
||||
artists, err := controller.GetAllArtists(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tracks, err := controller.GetOrphanTracks(global.DB)
|
||||
tracks, err := controller.GetOrphanTracks(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -92,9 +81,9 @@ func Handler() http.Handler {
|
|||
return mux
|
||||
}
|
||||
|
||||
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
|
||||
func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account, err := controller.GetAccountByRequest(db, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
|
@ -112,275 +101,6 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
|
|||
})
|
||||
}
|
||||
|
||||
func LoginHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r);
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
credentials := LoginRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Password: r.Form.Get("password"),
|
||||
TOTP: r.Form.Get("totp"),
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: check TOTP
|
||||
|
||||
// login success!
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{
|
||||
Account: account,
|
||||
Token: token.Token,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func LogoutHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := controller.GetTokenFromRequest(global.DB, r)
|
||||
|
||||
if len(tokenStr) > 0 {
|
||||
err := controller.DeleteToken(global.DB, tokenStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = ""
|
||||
cookie.Expires = time.Now()
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func createAccountHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checkAccount, err := controller.GetAccountByRequest(global.DB, r)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if checkAccount != nil {
|
||||
// user is already logged in
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
type CreateAccountResponse struct {
|
||||
Account *model.Account
|
||||
Message string
|
||||
}
|
||||
|
||||
render := func(data CreateAccountResponse) {
|
||||
err := pages["create-account"].Execute(w, data)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
render(CreateAccountResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
render(CreateAccountResponse{
|
||||
Message: "Malformed data.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
credentials := RegisterRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Email: r.Form.Get("email"),
|
||||
Password: r.Form.Get("password"),
|
||||
Invite: r.Form.Get("invite"),
|
||||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite, err := controller.GetInvite(global.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
||||
if invite != nil {
|
||||
err := controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
}
|
||||
render(CreateAccountResponse{
|
||||
Message: "Invalid invite code.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: string(hashedPassword),
|
||||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(global.DB, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
render(CreateAccountResponse{
|
||||
Message: "An account with that username already exists.",
|
||||
})
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
// registration success!
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
// gracefully redirect user to login page
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{
|
||||
Account: &account,
|
||||
Token: token.Token,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func staticHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
|
||||
|
|
|
@ -5,19 +5,18 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func serveRelease() http.Handler {
|
||||
func serveRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
releaseID := slices[0]
|
||||
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
|
||||
release, err := controller.GetRelease(global.DB, releaseID, true)
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -34,10 +33,10 @@ func serveRelease() http.Handler {
|
|||
serveEditCredits(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addcredit":
|
||||
serveAddCredit(release).ServeHTTP(w, r)
|
||||
serveAddCredit(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newcredit":
|
||||
serveNewCredit().ServeHTTP(w, r)
|
||||
serveNewCredit(app).ServeHTTP(w, r)
|
||||
return
|
||||
case "editlinks":
|
||||
serveEditLinks(release).ServeHTTP(w, r)
|
||||
|
@ -46,10 +45,10 @@ func serveRelease() http.Handler {
|
|||
serveEditTracks(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addtrack":
|
||||
serveAddTrack(release).ServeHTTP(w, r)
|
||||
serveAddTrack(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newtrack":
|
||||
serveNewTrack().ServeHTTP(w, r)
|
||||
serveNewTrack(app).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
|
@ -83,9 +82,9 @@ func serveEditCredits(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveAddCredit(release *model.Release) http.Handler {
|
||||
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID)
|
||||
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -109,10 +108,10 @@ func serveAddCredit(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveNewCredit() http.Handler {
|
||||
func serveNewCredit(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
artistID := strings.Split(r.URL.Path, "/")[3]
|
||||
artist, err := controller.GetArtist(global.DB, artistID)
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -154,9 +153,9 @@ func serveEditTracks(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveAddTrack(release *model.Release) http.Handler {
|
||||
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID)
|
||||
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -181,10 +180,10 @@ func serveAddTrack(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveNewTrack() http.Handler {
|
||||
func serveNewTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
trackID := strings.Split(r.URL.Path, "/")[3]
|
||||
track, err := controller.GetTrack(global.DB, trackID)
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
@ -124,3 +124,65 @@ a img.icon {
|
|||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border: 1px solid #dc5959;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
button, .button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button:hover, .button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active, .button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
button.new {
|
||||
background: #c4ff6a;
|
||||
border-color: #84b141;
|
||||
}
|
||||
button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button.delete {
|
||||
background: #ff7171;
|
||||
border-color: #7d3535;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
button[disabled] {
|
||||
background: #d0d0d0 !important;
|
||||
border-color: #808080 !important;
|
||||
opacity: .5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
a.delete {
|
||||
color: #d22828;
|
||||
}
|
||||
|
|
65
admin/static/edit-account.css
Normal file
65
admin/static/edit-account.css
Normal file
|
@ -0,0 +1,65 @@
|
|||
@import url("/admin/static/index.css");
|
||||
|
||||
form#change-password {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
form div {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
form button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
color: #10101080;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
margin: .5rem 0;
|
||||
padding: .3rem .5rem;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border: 1px solid #dc5959;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mfa-device {
|
||||
padding: .75em;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
border-radius: .5em;
|
||||
margin-bottom: .5em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mfa-device div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mfa-device p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mfa-device .mfa-device-name {
|
||||
font-weight: bold;
|
||||
}
|
0
admin/static/edit-account.js
Normal file
0
admin/static/edit-account.js
Normal file
|
@ -66,54 +66,6 @@ input[type="text"]:focus {
|
|||
border-color: #808080;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button:hover, .button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active, .button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button.delete {
|
||||
background: #ff7171;
|
||||
border-color: #7d3535;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
button[disabled] {
|
||||
background: #d0d0d0 !important;
|
||||
border-color: #808080 !important;
|
||||
opacity: .5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
a.delete {
|
||||
color: #d22828;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
|
|
|
@ -109,58 +109,6 @@ input[type="text"] {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button:hover, .button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active, .button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
button.new {
|
||||
background: #c4ff6a;
|
||||
border-color: #84b141;
|
||||
}
|
||||
button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button.delete {
|
||||
background: #ff7171;
|
||||
border-color: #7d3535;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
button[disabled] {
|
||||
background: #d0d0d0 !important;
|
||||
border-color: #808080 !important;
|
||||
opacity: .5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
a.delete {
|
||||
color: #d22828;
|
||||
}
|
||||
|
||||
.release-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
|
|
|
@ -67,54 +67,6 @@ h1 {
|
|||
border-color: #808080;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button:hover, .button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active, .button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button.delete {
|
||||
background: #ff7171;
|
||||
border-color: #7d3535;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
button[disabled] {
|
||||
background: #d0d0d0 !important;
|
||||
border-color: #808080 !important;
|
||||
opacity: .5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
a.delete {
|
||||
color: #d22828;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
|
|
|
@ -98,4 +98,3 @@
|
|||
.track .empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,11 @@ var pages = map[string]*template.Template{
|
|||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "logout.html"),
|
||||
)),
|
||||
"account": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-account.html"),
|
||||
)),
|
||||
|
||||
"release": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
|
|
|
@ -5,16 +5,15 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/controller"
|
||||
)
|
||||
|
||||
func serveTrack() http.Handler {
|
||||
func serveTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
id := slices[0]
|
||||
track, err := controller.GetTrack(global.DB, id)
|
||||
track, err := controller.GetTrack(app.DB, id)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -25,7 +24,7 @@ func serveTrack() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
releases, err := controller.GetTrackReleases(global.DB, track.ID, true)
|
||||
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>Register - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
p a {
|
||||
color: #2a67c8;
|
||||
|
@ -43,35 +43,6 @@ input {
|
|||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button.new {
|
||||
background: #c4ff6a;
|
||||
border-color: #84b141;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border: 1px solid #dc5959;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
|
|
69
admin/views/edit-account.html
Normal file
69
admin/views/edit-account.html
Normal file
|
@ -0,0 +1,69 @@
|
|||
{{define "head"}}
|
||||
<title>Account Settings - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-account.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Account Settings ({{.Account.Username}})</h1>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form action="/api/v1/change-password" method="POST" id="change-password">
|
||||
<div>
|
||||
<label for="current-password">Current Password</label>
|
||||
<input type="password" name="current-password" value="" autocomplete="current-password">
|
||||
|
||||
<label for="new-password">Password</label>
|
||||
<input type="password" name="new-password" value="" autocomplete="new-password">
|
||||
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input type="password" name="confirm-password" value="" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>MFA Devices</h2>
|
||||
</div>
|
||||
<div class="card mfa-devices">
|
||||
{{if .TOTPs}}
|
||||
{{range .TOTPs}}
|
||||
<div class="mfa-device">
|
||||
<div>
|
||||
<p class="mfa-device-name">{{.Name}}</p>
|
||||
<p class="mfa-device-date">Added: {{.CreatedAt}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a class="delete">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p>You have no MFA devices.</p>
|
||||
{{end}}
|
||||
|
||||
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card danger">
|
||||
<p>
|
||||
Clicking the button below will delete your account.
|
||||
This action is <strong>irreversible</strong>.
|
||||
You will be prompted to confirm this decision.
|
||||
</p>
|
||||
<button class="delete" id="delete">Delete Account</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/edit-account.js" defer></script>
|
||||
{{end}}
|
|
@ -1,7 +1,6 @@
|
|||
{{define "head"}}
|
||||
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/edit-artist.css">
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
{{define "head"}}
|
||||
<title>Editing {{.Release.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/edit-release.css">
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{define "head"}}
|
||||
<title>Editing Track - ari melody 💫</title>
|
||||
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-track.css">
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>Login - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
p a {
|
||||
color: #2a67c8;
|
||||
|
@ -47,33 +47,15 @@ input[disabled] {
|
|||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
}
|
||||
button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Message}}
|
||||
<p id="error">{{.Message}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Token}}
|
||||
|
||||
<meta http-equiv="refresh" content="0;url=/admin/" />
|
||||
|
@ -87,13 +69,13 @@ button:active {
|
|||
<form action="/admin/login" method="POST" id="login">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" value="">
|
||||
<input type="text" name="username" value="" autocomplete="username">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="">
|
||||
<input type="password" name="password" value="" autocomplete="current-password">
|
||||
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="" disabled>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save">Login</button>
|
||||
|
|
|
@ -3,7 +3,6 @@ package api
|
|||
import (
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/global"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -14,7 +13,7 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func handleLogin() http.HandlerFunc {
|
||||
func handleLogin(app *model.AppState) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
|
@ -33,7 +32,7 @@ func handleLogin() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
account, err := controller.GetAccount(app.DB, credentials.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -50,7 +49,7 @@ func handleLogin() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
|
@ -67,7 +66,7 @@ func handleLogin() http.HandlerFunc {
|
|||
})
|
||||
}
|
||||
|
||||
func handleAccountRegistration() http.HandlerFunc {
|
||||
func handleAccountRegistration(app *model.AppState) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
|
@ -89,7 +88,7 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite, err := controller.GetInvite(global.DB, credentials.Invite)
|
||||
invite, err := controller.GetInvite(app.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -101,7 +100,7 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
}
|
||||
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
err := controller.DeleteInvite(global.DB, invite.Code)
|
||||
err := controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
http.Error(w, "Invalid invite code", http.StatusBadRequest)
|
||||
return
|
||||
|
@ -120,7 +119,7 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(global.DB, &account)
|
||||
err = controller.CreateAccount(app.DB, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
http.Error(w, "An account with that username already exists", http.StatusBadRequest)
|
||||
|
@ -131,10 +130,10 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteInvite(global.DB, invite.Code)
|
||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent())
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
|
@ -151,7 +150,7 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
})
|
||||
}
|
||||
|
||||
func handleDeleteAccount() http.HandlerFunc {
|
||||
func handleDeleteAccount(app *model.AppState) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
|
@ -170,7 +169,7 @@ func handleDeleteAccount() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
account, err := controller.GetAccount(app.DB, credentials.Username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
|
@ -189,7 +188,7 @@ func handleDeleteAccount() http.HandlerFunc {
|
|||
|
||||
// TODO: check TOTP
|
||||
|
||||
err = controller.DeleteAccount(global.DB, account.Username)
|
||||
err = controller.DeleteAccount(app.DB, account.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
48
api/api.go
48
api/api.go
|
@ -6,24 +6,32 @@ import (
|
|||
"strings"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func Handler() http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// ACCOUNT ENDPOINTS
|
||||
|
||||
/*
|
||||
// temporarily disabling these
|
||||
// accounts should really be handled via the frontend rn, and juggling
|
||||
// two different token bearer methods kinda sucks!!
|
||||
// i'll look into generating API tokens on the frontend in the future
|
||||
// TODO: generate API keys on the frontend
|
||||
|
||||
mux.Handle("/v1/login", handleLogin())
|
||||
mux.Handle("/v1/register", handleAccountRegistration())
|
||||
mux.Handle("/v1/delete-account", handleDeleteAccount())
|
||||
*/
|
||||
|
||||
// ARTIST ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artistID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
artist, err := controller.GetArtist(global.DB, artistID)
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -37,13 +45,13 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist/{id}
|
||||
ServeArtist(artist).ServeHTTP(w, r)
|
||||
ServeArtist(app, artist).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/artist/{id} (admin)
|
||||
admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/artist/{id} (admin)
|
||||
admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -52,10 +60,10 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist
|
||||
ServeAllArtists().ServeHTTP(w, r)
|
||||
ServeAllArtists(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/artist (admin)
|
||||
admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -65,7 +73,7 @@ func Handler() http.Handler {
|
|||
|
||||
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
release, err := controller.GetRelease(global.DB, releaseID, true)
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -79,13 +87,13 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music/{id}
|
||||
ServeRelease(release).ServeHTTP(w, r)
|
||||
ServeRelease(app, release).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/music/{id} (admin)
|
||||
admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/music/{id} (admin)
|
||||
admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -94,10 +102,10 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music
|
||||
ServeCatalog().ServeHTTP(w, r)
|
||||
ServeCatalog(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/music (admin)
|
||||
admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -107,7 +115,7 @@ func Handler() http.Handler {
|
|||
|
||||
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var trackID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
track, err := controller.GetTrack(global.DB, trackID)
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -121,13 +129,13 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -136,10 +144,10 @@ func Handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track (admin)
|
||||
admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/track (admin)
|
||||
admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
|
@ -10,15 +10,14 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func ServeAllArtists() http.Handler {
|
||||
func ServeAllArtists(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artists = []*model.Artist{}
|
||||
artists, err := controller.GetAllArtists(global.DB)
|
||||
artists, err := controller.GetAllArtists(app.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -35,7 +34,7 @@ func ServeAllArtists() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeArtist(artist *model.Artist) http.Handler {
|
||||
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type (
|
||||
creditJSON struct {
|
||||
|
@ -52,7 +51,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
)
|
||||
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -60,7 +59,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
show_hidden_releases := account != nil
|
||||
|
||||
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
|
||||
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -92,7 +91,7 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateArtist() http.Handler {
|
||||
func CreateArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artist model.Artist
|
||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||
|
@ -107,7 +106,7 @@ func CreateArtist() http.Handler {
|
|||
}
|
||||
if artist.Name == "" { artist.Name = artist.ID }
|
||||
|
||||
err = controller.CreateArtist(global.DB, &artist)
|
||||
err = controller.CreateArtist(app.DB, &artist)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
|
||||
|
@ -122,7 +121,7 @@ func CreateArtist() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateArtist(artist *model.Artist) http.Handler {
|
||||
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||
if err != nil {
|
||||
|
@ -136,7 +135,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
} else {
|
||||
if strings.Contains(artist.Avatar, ";base64,") {
|
||||
var artworkDirectory = filepath.Join("uploads", "avatar")
|
||||
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID)
|
||||
filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID)
|
||||
|
||||
// clean up files with this ID and different extensions
|
||||
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
|
||||
|
@ -155,7 +154,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
err = controller.UpdateArtist(global.DB, artist)
|
||||
err = controller.UpdateArtist(app.DB, artist)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -167,9 +166,9 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteArtist(artist *model.Artist) http.Handler {
|
||||
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := controller.DeleteArtist(global.DB, artist.ID)
|
||||
err := controller.DeleteArtist(app.DB, artist.ID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
|
|
@ -10,17 +10,16 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func ServeRelease(release *model.Release) http.Handler {
|
||||
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only allow authorised users to view hidden releases
|
||||
privileged := false
|
||||
if !release.Visible {
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -67,14 +66,14 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
|
||||
if release.IsReleased() || privileged {
|
||||
// get credits
|
||||
credits, err := controller.GetReleaseCredits(global.DB, release.ID)
|
||||
credits, err := controller.GetReleaseCredits(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, credit := range credits {
|
||||
artist, err := controller.GetArtist(global.DB, credit.Artist.ID)
|
||||
artist, err := controller.GetArtist(app.DB, credit.Artist.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -89,7 +88,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
// get tracks
|
||||
tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
|
||||
tracks, err := controller.GetReleaseTracks(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -104,7 +103,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
// get links
|
||||
links, err := controller.GetReleaseLinks(global.DB, release.ID)
|
||||
links, err := controller.GetReleaseLinks(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -126,9 +125,9 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeCatalog() http.Handler {
|
||||
func ServeCatalog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -146,7 +145,7 @@ func ServeCatalog() http.Handler {
|
|||
}
|
||||
|
||||
catalog := []Release{}
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -192,7 +191,7 @@ func ServeCatalog() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateRelease() http.Handler {
|
||||
func CreateRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
|
@ -220,7 +219,7 @@ func CreateRelease() http.Handler {
|
|||
|
||||
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
|
||||
|
||||
err = controller.CreateRelease(global.DB, &release)
|
||||
err = controller.CreateRelease(app.DB, &release)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
|
||||
|
@ -243,7 +242,7 @@ func CreateRelease() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateRelease(release *model.Release) http.Handler {
|
||||
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
|
@ -255,11 +254,11 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
if len(segments) == 2 {
|
||||
switch segments[1] {
|
||||
case "tracks":
|
||||
UpdateReleaseTracks(release).ServeHTTP(w, r)
|
||||
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
|
||||
case "credits":
|
||||
UpdateReleaseCredits(release).ServeHTTP(w, r)
|
||||
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
|
||||
case "links":
|
||||
UpdateReleaseLinks(release).ServeHTTP(w, r)
|
||||
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -281,7 +280,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
} else {
|
||||
if strings.Contains(release.Artwork, ";base64,") {
|
||||
var artworkDirectory = filepath.Join("uploads", "musicart")
|
||||
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
|
||||
filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID)
|
||||
|
||||
// clean up files with this ID and different extensions
|
||||
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
|
||||
|
@ -300,7 +299,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
err = controller.UpdateRelease(global.DB, release)
|
||||
err = controller.UpdateRelease(app.DB, release)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -312,7 +311,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseTracks(release *model.Release) http.Handler {
|
||||
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var trackIDs = []string{}
|
||||
err := json.NewDecoder(r.Body).Decode(&trackIDs)
|
||||
|
@ -321,7 +320,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
|
||||
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -333,7 +332,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseCredits(release *model.Release) http.Handler {
|
||||
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type creditJSON struct {
|
||||
Artist string
|
||||
|
@ -358,7 +357,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseCredits(global.DB, release.ID, credits)
|
||||
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
|
||||
|
@ -374,7 +373,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseLinks(release *model.Release) http.Handler {
|
||||
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.NotFound(w, r)
|
||||
|
@ -388,7 +387,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseLinks(global.DB, release.ID, links)
|
||||
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -400,9 +399,9 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteRelease(release *model.Release) http.Handler {
|
||||
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := controller.DeleteRelease(global.DB, release.ID)
|
||||
err := controller.DeleteRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
|
25
api/track.go
25
api/track.go
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
@ -17,7 +16,7 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
func ServeAllTracks() http.Handler {
|
||||
func ServeAllTracks(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
|
@ -26,7 +25,7 @@ func ServeAllTracks() http.Handler {
|
|||
var tracks = []Track{}
|
||||
|
||||
var dbTracks = []*model.Track{}
|
||||
dbTracks, err := controller.GetAllTracks(global.DB)
|
||||
dbTracks, err := controller.GetAllTracks(app.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -50,9 +49,9 @@ func ServeAllTracks() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeTrack(track *model.Track) http.Handler {
|
||||
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
|
||||
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -74,7 +73,7 @@ func ServeTrack(track *model.Track) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateTrack() http.Handler {
|
||||
func CreateTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
|
@ -93,7 +92,7 @@ func CreateTrack() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
id, err := controller.CreateTrack(global.DB, &track)
|
||||
id, err := controller.CreateTrack(app.DB, &track)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to create track: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -106,7 +105,7 @@ func CreateTrack() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateTrack(track *model.Track) http.Handler {
|
||||
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
|
@ -124,9 +123,9 @@ func UpdateTrack(track *model.Track) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateTrack(global.DB, track)
|
||||
err = controller.UpdateTrack(app.DB, track)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to update track %s: %s\n", track.ID, err)
|
||||
fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -141,7 +140,7 @@ func UpdateTrack(track *model.Track) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteTrack(track *model.Track) http.Handler {
|
||||
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
|
@ -149,9 +148,9 @@ func DeleteTrack(track *model.Track) http.Handler {
|
|||
}
|
||||
|
||||
var trackID = r.URL.Path[1:]
|
||||
err := controller.DeleteTrack(global.DB, trackID)
|
||||
err := controller.DeleteTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to delete track %s: %s\n", trackID, err)
|
||||
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
@ -11,12 +11,12 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
func HandleImageUpload(data *string, directory string, filename string) (string, error) {
|
||||
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
||||
split := strings.Split(*data, ";base64,")
|
||||
header := split[0]
|
||||
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
||||
ext, _ := strings.CutPrefix(header, "data:image/")
|
||||
directory = filepath.Join(global.Config.DataDirectory, directory)
|
||||
directory = filepath.Join(app.Config.DataDirectory, directory)
|
||||
|
||||
switch ext {
|
||||
case "png":
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -11,6 +10,17 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
||||
var accounts = []model.Account{}
|
||||
|
||||
err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
|
||||
var account = model.Account{}
|
||||
|
||||
|
@ -61,7 +71,7 @@ func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
|
|||
return tokenStr
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(global.COOKIE_TOKEN)
|
||||
cookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package global
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
@ -6,42 +6,26 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
dbConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Name string `toml:"name"`
|
||||
User string `toml:"user"`
|
||||
Pass string `toml:"pass"`
|
||||
}
|
||||
|
||||
discordConfig struct {
|
||||
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Port int64 `toml:"port"`
|
||||
DataDirectory string `toml:"data_dir"`
|
||||
DB dbConfig `toml:"db"`
|
||||
Discord discordConfig `toml:"discord"`
|
||||
}
|
||||
)
|
||||
|
||||
var Config = func() config {
|
||||
func GetConfig() model.Config {
|
||||
configFile := os.Getenv("ARIMELODY_CONFIG")
|
||||
if configFile == "" {
|
||||
configFile = "config.toml"
|
||||
}
|
||||
|
||||
config := config{
|
||||
config := model.Config{
|
||||
BaseUrl: "https://arimelody.me",
|
||||
Port: 8080,
|
||||
DB: model.DBConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "arimelody",
|
||||
Name: "arimelody",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
|
@ -56,20 +40,18 @@ var Config = func() config {
|
|||
|
||||
err = toml.Unmarshal([]byte(data), &config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
|
||||
os.Exit(1)
|
||||
panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err))
|
||||
}
|
||||
|
||||
err = handleConfigOverrides(&config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
|
||||
os.Exit(1)
|
||||
panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err))
|
||||
}
|
||||
|
||||
return config
|
||||
}()
|
||||
}
|
||||
|
||||
func handleConfigOverrides(config *config) error {
|
||||
func handleConfigOverrides(config *model.Config) error {
|
||||
var err error
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }
|
||||
|
@ -80,6 +62,10 @@ func handleConfigOverrides(config *config) error {
|
|||
if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env }
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_PORT"); has {
|
||||
config.DB.Port, err = strconv.ParseInt(env, 10, 0)
|
||||
if err != nil { return errors.New("ARIMELODY_DB_PORT: " + err.Error()) }
|
||||
}
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env }
|
||||
|
@ -90,5 +76,3 @@ func handleConfigOverrides(config *config) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
var DB *sqlx.DB
|
|
@ -20,9 +20,13 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
)
|
||||
|
||||
oldDBVersion := 0
|
||||
|
||||
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
|
||||
schemaVersionCount := 0
|
||||
err := db.Get(&schemaVersionCount, "SELECT COUNT(*) FROM schema_version")
|
||||
if err != nil { panic(err) }
|
||||
if schemaVersionCount > 0 {
|
||||
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
|
||||
if err != nil { panic(err) }
|
||||
}
|
||||
|
||||
for oldDBVersion < DB_VERSION {
|
||||
switch oldDBVersion {
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
129
controller/totp.go
Normal file
129
controller/totp.go
Normal file
|
@ -0,0 +1,129 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const TOTP_SECRET_LENGTH = 32
|
||||
const TIME_STEP int64 = 30
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
func GenerateTOTP(secret string, timeStepOffset int) string {
|
||||
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
|
||||
}
|
||||
|
||||
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset)
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(decodedSecret))
|
||||
mac.Write(counterBytes)
|
||||
hash := mac.Sum(nil)
|
||||
|
||||
offset := hash[len(hash) - 1] & 0x0f
|
||||
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
|
||||
code := binaryCode % int32(math.Pow10(CODE_LENGTH))
|
||||
|
||||
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
|
||||
}
|
||||
|
||||
func GenerateTOTPSecret(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
panic("FATAL: Failed to generate random TOTP bytes")
|
||||
}
|
||||
|
||||
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
|
||||
|
||||
return strings.ToUpper(secret)
|
||||
}
|
||||
|
||||
func GenerateTOTPURI(username string, secret string) string {
|
||||
url := url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "totp",
|
||||
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
|
||||
}
|
||||
|
||||
query := url.Query()
|
||||
query.Set("secret", secret)
|
||||
query.Set("issuer", "arimelody.me")
|
||||
query.Set("algorithm", "SHA1")
|
||||
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH))
|
||||
query.Set("period", fmt.Sprintf("%d", TIME_STEP))
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
|
||||
totps := []model.TOTP{}
|
||||
|
||||
err := db.Select(
|
||||
&totps,
|
||||
"SELECT * FROM totp " +
|
||||
"WHERE account=$1 " +
|
||||
"ORDER BY created_at ASC",
|
||||
accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return totps, nil
|
||||
}
|
||||
|
||||
func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
||||
totp := model.TOTP{}
|
||||
|
||||
err := db.Get(
|
||||
&totp,
|
||||
"SELECT * FROM totp " +
|
||||
"WHERE account=$1",
|
||||
accountID,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &totp, nil
|
||||
}
|
||||
|
||||
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO totp (account, name, secret) " +
|
||||
"VALUES ($1,$2,$3)",
|
||||
totp.AccountID,
|
||||
totp.Name,
|
||||
totp.Secret,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
|
||||
_, err := db.Exec(
|
||||
"DELETE FROM totp WHERE account=$1 AND name=$2",
|
||||
accountID,
|
||||
name,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
package global
|
||||
|
||||
const COOKIE_TOKEN string = "AM_TOKEN"
|
101
global/funcs.go
101
global/funcs.go
|
@ -1,101 +0,0 @@
|
|||
package global
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"arimelody-web/colour"
|
||||
)
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
"your mother",
|
||||
"awesome powers beyond comprehension",
|
||||
"jared",
|
||||
"the weight of my sins",
|
||||
"the arc reactor",
|
||||
"AA batteries",
|
||||
"15 euro solar panel from ebay",
|
||||
"magnets, how do they work",
|
||||
"a fax machine",
|
||||
"dell optiplex",
|
||||
"a trans girl's nintendo wii",
|
||||
"BASS",
|
||||
"electricity, duh",
|
||||
"seven hamsters in a big wheel",
|
||||
"girls",
|
||||
"mzungu hosting",
|
||||
"golang",
|
||||
"the state of the world right now",
|
||||
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
|
||||
"the good folks at aperture science",
|
||||
"free2play CDs",
|
||||
"aridoodle",
|
||||
"the love of creating",
|
||||
"not for the sake of art; not for the sake of money; we like painting naked people",
|
||||
"30 billion dollars in VC funding",
|
||||
}
|
||||
|
||||
func DefaultHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Server", "arimelody.me")
|
||||
w.Header().Add("Do-Not-Stab", "1")
|
||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
w.Header().Add("X-Hacker", "spare me please")
|
||||
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
|
||||
w.Header().Add("X-Thinking-With", "Portals")
|
||||
w.Header().Add(
|
||||
"X-Powered-By",
|
||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type LoggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Status int
|
||||
}
|
||||
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||
lrw.Status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func HTTPLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
lrw := LoggingResponseWriter{w, http.StatusOK}
|
||||
|
||||
next.ServeHTTP(&lrw, r)
|
||||
|
||||
after := time.Now()
|
||||
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
|
||||
elapsed := "<1"
|
||||
if difference >= 1 {
|
||||
elapsed = strconv.Itoa(difference)
|
||||
}
|
||||
|
||||
statusColour := colour.Reset
|
||||
|
||||
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
|
||||
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
|
||||
if lrw.Status - 400 <= 0 { statusColour = colour.White }
|
||||
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
|
||||
|
||||
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
|
||||
after.Format(time.UnixDate),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
statusColour,
|
||||
lrw.Status,
|
||||
colour.Reset,
|
||||
elapsed,
|
||||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
340
main.go
340
main.go
|
@ -4,16 +4,19 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/api"
|
||||
"arimelody-web/colour"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
"arimelody-web/view"
|
||||
|
||||
|
@ -29,56 +32,191 @@ const DEFAULT_PORT int64 = 8080
|
|||
func main() {
|
||||
fmt.Printf("made with <3 by ari melody\n\n")
|
||||
|
||||
// TODO: refactor `global` to `AppState`
|
||||
// this should contain `Config` and `DB`, and be passed through to all
|
||||
// handlers that need it. it's better than weird static globals everywhere!
|
||||
|
||||
app := model.AppState{
|
||||
Config: controller.GetConfig(),
|
||||
}
|
||||
|
||||
// initialise database connection
|
||||
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_USER"); env != "" { global.Config.DB.User = env }
|
||||
if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env }
|
||||
if global.Config.DB.Host == "" {
|
||||
if app.Config.DB.Host == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Name == "" {
|
||||
if app.Config.DB.Name == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.User == "" {
|
||||
if app.Config.DB.User == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Pass == "" {
|
||||
if app.Config.DB.Pass == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var err error
|
||||
global.DB, err = sqlx.Connect(
|
||||
app.DB, err = sqlx.Connect(
|
||||
"postgres",
|
||||
fmt.Sprintf(
|
||||
"host=%s user=%s dbname=%s password='%s' sslmode=disable",
|
||||
global.Config.DB.Host,
|
||||
global.Config.DB.User,
|
||||
global.Config.DB.Name,
|
||||
global.Config.DB.Pass,
|
||||
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
|
||||
app.Config.DB.Host,
|
||||
app.Config.DB.Port,
|
||||
app.Config.DB.User,
|
||||
app.Config.DB.Name,
|
||||
app.Config.DB.Pass,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
global.DB.SetConnMaxLifetime(time.Minute * 3)
|
||||
global.DB.SetMaxOpenConns(10)
|
||||
global.DB.SetMaxIdleConns(10)
|
||||
defer global.DB.Close()
|
||||
app.DB.SetConnMaxLifetime(time.Minute * 3)
|
||||
app.DB.SetMaxOpenConns(10)
|
||||
app.DB.SetMaxIdleConns(10)
|
||||
defer app.DB.Close()
|
||||
|
||||
// handle command arguments
|
||||
if len(os.Args) > 1 {
|
||||
arg := os.Args[1]
|
||||
|
||||
switch arg {
|
||||
case "createTOTP":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
||||
|
||||
account, err := controller.GetAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totp := model.TOTP {
|
||||
AccountID: account.ID,
|
||||
Name: totpName,
|
||||
Secret: string(secret),
|
||||
}
|
||||
|
||||
err = controller.CreateTOTP(app.DB, &totp)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
|
||||
fmt.Printf("%s\n", url)
|
||||
return
|
||||
|
||||
case "deleteTOTP":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
|
||||
account, err := controller.GetAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
|
||||
return
|
||||
|
||||
case "listTOTP":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
|
||||
account, err := controller.GetAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for i, totp := range totps {
|
||||
fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
|
||||
}
|
||||
if len(totps) == 0 {
|
||||
fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username)
|
||||
}
|
||||
return
|
||||
|
||||
case "testTOTP":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
|
||||
account, err := controller.GetAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if totp == nil {
|
||||
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := controller.GenerateTOTP(totp.Secret, 0)
|
||||
fmt.Printf("%s\n", code)
|
||||
return
|
||||
|
||||
case "createInvite":
|
||||
fmt.Printf("Creating invite...\n")
|
||||
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
|
||||
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
@ -89,7 +227,7 @@ func main() {
|
|||
|
||||
case "purgeInvites":
|
||||
fmt.Printf("Deleting all invites...\n")
|
||||
err := controller.DeleteAllInvites(global.DB)
|
||||
err := controller.DeleteAllInvites(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
@ -98,17 +236,38 @@ func main() {
|
|||
fmt.Printf("Invites deleted successfully.\n")
|
||||
return
|
||||
|
||||
case "listAccounts":
|
||||
accounts, err := controller.GetAllAccounts(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
fmt.Printf(
|
||||
"User: %s\n" +
|
||||
"\tID: %s\n" +
|
||||
"\tEmail: %s\n" +
|
||||
"\tCreated: %s\n",
|
||||
account.Username,
|
||||
account.ID,
|
||||
account.Email,
|
||||
account.CreatedAt,
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
case "deleteAccount":
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n")
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be 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)
|
||||
account, err := controller.GetAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error())
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -124,7 +283,7 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteAccount(global.DB, username)
|
||||
err = controller.DeleteAccount(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
@ -135,61 +294,68 @@ func main() {
|
|||
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
// command help
|
||||
fmt.Print(
|
||||
"Available commands:\n\n" +
|
||||
"createTOTP <username> <name>:\n\tCreates a timed one-time passcode method.\n" +
|
||||
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
|
||||
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
|
||||
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
|
||||
"\n" +
|
||||
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||
"listAccounts:\n\tLists all active accounts.\n",
|
||||
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// handle DB migrations
|
||||
controller.CheckDBVersionAndMigrate(global.DB)
|
||||
controller.CheckDBVersionAndMigrate(app.DB)
|
||||
|
||||
// initial invite code
|
||||
accountsCount := 0
|
||||
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
if err != nil { panic(err) }
|
||||
if accountsCount == 0 {
|
||||
_, err := global.DB.Exec("DELETE FROM invite")
|
||||
_, err := app.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)
|
||||
}
|
||||
|
||||
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
|
||||
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl)
|
||||
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
|
||||
}
|
||||
|
||||
// delete expired invites
|
||||
err = controller.DeleteExpiredInvites(global.DB)
|
||||
err = controller.DeleteExpiredInvites(app.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!
|
||||
mux := createServeMux()
|
||||
fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port)
|
||||
mux := createServeMux(&app)
|
||||
fmt.Printf("Now serving at %s:%d\n", app.Config.BaseUrl, app.Config.Port)
|
||||
log.Fatal(
|
||||
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
|
||||
global.HTTPLog(global.DefaultHeaders(mux)),
|
||||
http.ListenAndServe(fmt.Sprintf(":%d", app.Config.Port),
|
||||
HTTPLog(DefaultHeaders(mux)),
|
||||
))
|
||||
}
|
||||
|
||||
func createServeMux() *http.ServeMux {
|
||||
func createServeMux(app *model.AppState) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler()))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler()))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler()))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
@ -230,3 +396,93 @@ func staticHandler(directory string) http.Handler {
|
|||
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
"your mother",
|
||||
"awesome powers beyond comprehension",
|
||||
"jared",
|
||||
"the weight of my sins",
|
||||
"the arc reactor",
|
||||
"AA batteries",
|
||||
"15 euro solar panel from ebay",
|
||||
"magnets, how do they work",
|
||||
"a fax machine",
|
||||
"dell optiplex",
|
||||
"a trans girl's nintendo wii",
|
||||
"BASS",
|
||||
"electricity, duh",
|
||||
"seven hamsters in a big wheel",
|
||||
"girls",
|
||||
"mzungu hosting",
|
||||
"golang",
|
||||
"the state of the world right now",
|
||||
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
|
||||
"the good folks at aperture science",
|
||||
"free2play CDs",
|
||||
"aridoodle",
|
||||
"the love of creating",
|
||||
"not for the sake of art; not for the sake of money; we like painting naked people",
|
||||
"30 billion dollars in VC funding",
|
||||
}
|
||||
|
||||
func DefaultHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Server", "arimelody.me")
|
||||
w.Header().Add("Do-Not-Stab", "1")
|
||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
w.Header().Add("X-Hacker", "spare me please")
|
||||
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
|
||||
w.Header().Add("X-Thinking-With", "Portals")
|
||||
w.Header().Add(
|
||||
"X-Powered-By",
|
||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type LoggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Status int
|
||||
}
|
||||
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||
lrw.Status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func HTTPLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
lrw := LoggingResponseWriter{w, http.StatusOK}
|
||||
|
||||
next.ServeHTTP(&lrw, r)
|
||||
|
||||
after := time.Now()
|
||||
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
|
||||
elapsed := "<1"
|
||||
if difference >= 1 {
|
||||
elapsed = strconv.Itoa(difference)
|
||||
}
|
||||
|
||||
statusColour := colour.Reset
|
||||
|
||||
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
|
||||
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
|
||||
if lrw.Status - 400 <= 0 { statusColour = colour.White }
|
||||
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
|
||||
|
||||
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
|
||||
after.Format(time.UnixDate),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
statusColour,
|
||||
lrw.Status,
|
||||
colour.Reset,
|
||||
elapsed,
|
||||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const COOKIE_TOKEN string = "AM_TOKEN"
|
||||
|
||||
type (
|
||||
Account struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Password string `json:"password" db:"password"`
|
||||
Email string `json:"email" db:"email"`
|
||||
AvatarURL string `json:"avatar_url" db:"avatar_url"`
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Password string `json:"password" db:"password"`
|
||||
Email string `json:"email" db:"email"`
|
||||
AvatarURL string `json:"avatar_url" db:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
Privileges []AccountPrivilege `json:"privileges"`
|
||||
}
|
||||
|
||||
|
|
32
model/appstate.go
Normal file
32
model/appstate.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package model
|
||||
|
||||
import "github.com/jmoiron/sqlx"
|
||||
|
||||
type (
|
||||
DBConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
Name string `toml:"name"`
|
||||
User string `toml:"user"`
|
||||
Pass string `toml:"pass"`
|
||||
}
|
||||
|
||||
DiscordConfig struct {
|
||||
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
Config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Port int64 `toml:"port"`
|
||||
DataDirectory string `toml:"data_dir"`
|
||||
DB DBConfig `toml:"db"`
|
||||
Discord DiscordConfig `toml:"discord"`
|
||||
}
|
||||
|
||||
AppState struct {
|
||||
DB *sqlx.DB
|
||||
Config Config
|
||||
}
|
||||
)
|
12
model/totp.go
Normal file
12
model/totp.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TOTP struct {
|
||||
Name string `json:"name" db:"name"`
|
||||
AccountID string `json:"accountID" db:"account"`
|
||||
Secret string `json:"-" db:"secret"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
|
@ -1,10 +1,4 @@
|
|||
CREATE SCHEMA arimelody;
|
||||
|
||||
-- Schema verison
|
||||
CREATE TABLE arimelody.schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT current_timestamp
|
||||
);
|
||||
CREATE SCHEMA IF NOT EXISTS arimelody;
|
||||
|
||||
--
|
||||
-- Tables
|
||||
|
@ -16,7 +10,8 @@ CREATE TABLE arimelody.account (
|
|||
username text NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
email text,
|
||||
avatar_url text
|
||||
avatar_url text,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||
|
||||
|
@ -27,14 +22,6 @@ CREATE TABLE arimelody.privilege (
|
|||
);
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
|
||||
|
||||
-- TOTP
|
||||
CREATE TABLE arimelody.totp (
|
||||
account uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
-- Invites
|
||||
CREATE TABLE arimelody.invite (
|
||||
code text NOT NULL,
|
||||
|
@ -53,6 +40,16 @@ CREATE TABLE arimelody.token (
|
|||
);
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
|
||||
|
||||
-- TOTPs
|
||||
CREATE TABLE arimelody.totp (
|
||||
name TEXT NOT NULL,
|
||||
account UUID NOT NULL,
|
||||
secret TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
|
||||
|
||||
-- Artists (should be applicable to all art)
|
||||
CREATE TABLE arimelody.artist (
|
||||
|
@ -121,8 +118,8 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
|
|||
--
|
||||
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.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.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
|
||||
|
|
|
@ -22,7 +22,8 @@ CREATE TABLE arimelody.account (
|
|||
username text NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
email text,
|
||||
avatar_url text
|
||||
avatar_url text,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||
|
||||
|
@ -33,14 +34,6 @@ CREATE TABLE arimelody.privilege (
|
|||
);
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
|
||||
|
||||
-- TOTP
|
||||
CREATE TABLE arimelody.totp (
|
||||
account uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
-- Invites
|
||||
CREATE TABLE arimelody.invite (
|
||||
code text NOT NULL,
|
||||
|
@ -59,7 +52,16 @@ CREATE TABLE arimelody.token (
|
|||
);
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
|
||||
|
||||
-- TOTPs
|
||||
CREATE TABLE arimelody.totp (
|
||||
name TEXT NOT NULL,
|
||||
account UUID NOT NULL,
|
||||
secret TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
-- Foreign keys
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
|
|
|
@ -6,37 +6,36 @@ import (
|
|||
"os"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
)
|
||||
|
||||
// HTTP HANDLER METHODS
|
||||
|
||||
func MusicHandler() http.Handler {
|
||||
func MusicHandler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
ServeCatalog().ServeHTTP(w, r)
|
||||
ServeCatalog(app).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true)
|
||||
release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ServeGateway(release).ServeHTTP(w, r)
|
||||
ServeGateway(app, release).ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func ServeCatalog() http.Handler {
|
||||
func ServeCatalog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
releases, err := controller.GetAllReleases(global.DB, true, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -56,12 +55,12 @@ func ServeCatalog() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeGateway(release *model.Release) http.Handler {
|
||||
func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only allow authorised users to view hidden releases
|
||||
privileged := false
|
||||
if !release.Visible {
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
account, err := controller.GetAccountByRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue