diff --git a/.gitignore b/.gitignore index 025d915..9bdf788 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ uploads/ docker-compose*.yml !docker-compose.example.yml config*.toml ->>>>>>> dev +arimelody-web diff --git a/README.md b/README.md index 0873ff6..7e7860c 100644 --- a/README.md +++ b/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 `: Creates a timed one-time passcode method. +- `listTOTP `: Lists an account's TOTP methods. +- `deleteTOTP `: Deletes an account's TOTP method. +- `testTOTP `: 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 `: 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. diff --git a/admin/accounthttp.go b/admin/accounthttp.go new file mode 100644 index 0000000..b2cf18a --- /dev/null +++ b/admin/accounthttp.go @@ -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 + } + }) +} diff --git a/admin/artisthttp.go b/admin/artisthttp.go index af42cb1..d6a5e76 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -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) diff --git a/admin/http.go b/admin/http.go index 4dbc66b..b44cfa9 100644 --- a/admin/http.go +++ b/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))) diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 9132fe8..503166b 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -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) diff --git a/admin/static/admin.css b/admin/static/admin.css index 510ee8b..32f69bb 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -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; +} diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css new file mode 100644 index 0000000..625db13 --- /dev/null +++ b/admin/static/edit-account.css @@ -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; +} diff --git a/admin/static/edit-account.js b/admin/static/edit-account.js new file mode 100644 index 0000000..e69de29 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index e481b68..793b989 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -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; diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 9feb9ad..10eada3 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -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; diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 6e87397..8a05089 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -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; diff --git a/admin/static/index.css b/admin/static/index.css index ec426af..9d38940 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -98,4 +98,3 @@ .track .empty { opacity: 0.75; } - diff --git a/admin/templates.go b/admin/templates.go index e91313a..1fa7a65 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -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"), diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 2cea123..fa49b53 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -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) diff --git a/admin/views/create-account.html b/admin/views/create-account.html index 5d92627..8d59c0f 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -1,7 +1,7 @@ {{define "head"}} Register - ari melody 💫 - + {{end}} diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html new file mode 100644 index 0000000..4d89052 --- /dev/null +++ b/admin/views/edit-account.html @@ -0,0 +1,69 @@ +{{define "head"}} +Account Settings - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+

Account Settings ({{.Account.Username}})

+ +
+

Change Password

+
+
+
+
+ + + + + + + + +
+ + +
+
+ +
+

MFA Devices

+
+
+ {{if .TOTPs}} + {{range .TOTPs}} +
+
+

{{.Name}}

+

Added: {{.CreatedAt}}

+
+
+ Delete +
+
+ {{end}} + {{else}} +

You have no MFA devices.

+ {{end}} + + +
+ +
+

Danger Zone

+
+
+

+ Clicking the button below will delete your account. + This action is irreversible. + You will be prompted to confirm this decision. +

+ +
+ +
+ + +{{end}} diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index 8cd88f0..ccb3a45 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -1,7 +1,6 @@ {{define "head"}} Editing {{.Artist.Name}} - ari melody 💫 - {{end}} diff --git a/admin/views/edit-release.html b/admin/views/edit-release.html index 9c1ba99..02447e1 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -1,7 +1,6 @@ {{define "head"}} Editing {{.Release.Title}} - ari melody 💫 - {{end}} diff --git a/admin/views/edit-track.html b/admin/views/edit-track.html index 5bb74eb..56e0ae4 100644 --- a/admin/views/edit-track.html +++ b/admin/views/edit-track.html @@ -1,6 +1,6 @@ {{define "head"}} Editing Track - ari melody 💫 - + {{end}} diff --git a/admin/views/login.html b/admin/views/login.html index 16c0fcc..7744e91 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -1,7 +1,7 @@ {{define "head"}} Login - ari melody 💫 - + {{end}} {{define "content"}}
+ {{if .Message}} +

{{.Message}}

+ {{end}} + {{if .Token}} @@ -87,13 +69,13 @@ button:active {
- + - + - +
diff --git a/api/account.go b/api/account.go index 3ce52c8..0a9a7f9 100644 --- a/api/account.go +++ b/api/account.go @@ -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) diff --git a/api/api.go b/api/api.go index af6c6a7..b16d45d 100644 --- a/api/api.go +++ b/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) } diff --git a/api/artist.go b/api/artist.go index c46db59..a9676b1 100644 --- a/api/artist.go +++ b/api/artist.go @@ -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) diff --git a/api/release.go b/api/release.go index d17fb5f..c71043e 100644 --- a/api/release.go +++ b/api/release.go @@ -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) diff --git a/api/track.go b/api/track.go index ebbaa10..c342e08 100644 --- a/api/track.go +++ b/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) } }) diff --git a/api/uploads.go b/api/uploads.go index 6b1c496..ddcf6ee 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -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": diff --git a/controller/account.go b/controller/account.go index 362e297..044faec 100644 --- a/controller/account.go +++ b/controller/account.go @@ -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 "" } diff --git a/controller/artist.go b/controller/artist.go index c52b78d..1a613aa 100644 --- a/controller/artist.go +++ b/controller/artist.go @@ -2,6 +2,7 @@ package controller import ( "arimelody-web/model" + "github.com/jmoiron/sqlx" ) diff --git a/global/config.go b/controller/config.go similarity index 63% rename from global/config.go rename to controller/config.go index 0115c3f..28d4be4 100644 --- a/global/config.go +++ b/controller/config.go @@ -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 diff --git a/controller/migrator.go b/controller/migrator.go index 46a564d..3624255 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -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 { diff --git a/controller/release.go b/controller/release.go index c9791ac..362669a 100644 --- a/controller/release.go +++ b/controller/release.go @@ -5,6 +5,7 @@ import ( "fmt" "arimelody-web/model" + "github.com/jmoiron/sqlx" ) diff --git a/controller/totp.go b/controller/totp.go new file mode 100644 index 0000000..18616da --- /dev/null +++ b/controller/totp.go @@ -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 +} diff --git a/controller/track.go b/controller/track.go index d302045..fa4efc1 100644 --- a/controller/track.go +++ b/controller/track.go @@ -2,6 +2,7 @@ package controller import ( "arimelody-web/model" + "github.com/jmoiron/sqlx" ) diff --git a/global/const.go b/global/const.go deleted file mode 100644 index 157668d..0000000 --- a/global/const.go +++ /dev/null @@ -1,3 +0,0 @@ -package global - -const COOKIE_TOKEN string = "AM_TOKEN" diff --git a/global/funcs.go b/global/funcs.go deleted file mode 100644 index 49edb01..0000000 --- a/global/funcs.go +++ /dev/null @@ -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]) - }) -} diff --git a/main.go b/main.go index 2f0cb43..93466bd 100644 --- a/main.go +++ b/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 :\n\tCreates a timed one-time passcode method.\n" + + "listTOTP :\n\tLists an account's TOTP methods.\n" + + "deleteTOTP :\n\tDeletes an account's TOTP method.\n" + + "testTOTP :\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 :\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]) + }) +} diff --git a/model/account.go b/model/account.go index 03e95c5..72720a0 100644 --- a/model/account.go +++ b/model/account.go @@ -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"` } diff --git a/model/appstate.go b/model/appstate.go new file mode 100644 index 0000000..08016b7 --- /dev/null +++ b/model/appstate.go @@ -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 + } +) diff --git a/model/totp.go b/model/totp.go new file mode 100644 index 0000000..8d8422f --- /dev/null +++ b/model/totp.go @@ -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"` +} diff --git a/schema_migration/000-init.sql b/schema_migration/000-init.sql index cd11a5e..3f4c723 100644 --- a/schema_migration/000-init.sql +++ b/schema_migration/000-init.sql @@ -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; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql index fc730a0..62bc15b 100644 --- a/schema_migration/001-pre-versioning.sql +++ b/schema_migration/001-pre-versioning.sql @@ -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; diff --git a/view/music.go b/view/music.go index 3799182..cae325f 100644 --- a/view/music.go +++ b/view/music.go @@ -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)