diff --git a/.gitignore b/.gitignore index 9bdf788..025d915 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ uploads/ docker-compose*.yml !docker-compose.example.yml config*.toml -arimelody-web +>>>>>>> dev diff --git a/README.md b/README.md index 7e7860c..0873ff6 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,18 @@ 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 deleted file mode 100644 index b2cf18a..0000000 --- a/admin/accounthttp.go +++ /dev/null @@ -1,343 +0,0 @@ -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 d6a5e76..af42cb1 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -5,15 +5,16 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveArtist(app *model.AppState) http.Handler { +func serveArtist() 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(app.DB, id) + artist, err := controller.GetArtist(global.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -24,7 +25,7 @@ func serveArtist(app *model.AppState) http.Handler { return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(global.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 b44cfa9..4dbc66b 100644 --- a/admin/http.go +++ b/admin/http.go @@ -6,29 +6,40 @@ 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" ) -func Handler(app *model.AppState) http.Handler { +type TemplateData struct { + Account *model.Account + Token string +} + +func Handler() http.Handler { mux := http.NewServeMux() - 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("/login", LoginHandler()) + mux.Handle("/register", createAccountHandler()) + mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) + // TODO: /admin/account mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/release/", RequireAccount(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("/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("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - account, err := controller.GetAccountByRequest(app.DB, r) + account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) } @@ -37,21 +48,21 @@ func Handler(app *model.AppState) http.Handler { return } - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := controller.GetAllReleases(global.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(app.DB) + artists, err := controller.GetAllArtists(global.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(app.DB) + tracks, err := controller.GetOrphanTracks(global.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) @@ -81,9 +92,9 @@ func Handler(app *model.AppState) http.Handler { return mux } -func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { +func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - account, err := controller.GetAccountByRequest(app.DB, r) + account, err := controller.GetAccountByRequest(db, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) @@ -101,6 +112,275 @@ func RequireAccount(app *model.AppState, 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 503166b..9132fe8 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -5,18 +5,19 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func serveRelease(app *model.AppState) http.Handler { +func serveRelease() 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(app.DB, releaseID, true) + release, err := controller.GetRelease(global.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -33,10 +34,10 @@ func serveRelease(app *model.AppState) http.Handler { serveEditCredits(release).ServeHTTP(w, r) return case "addcredit": - serveAddCredit(app, release).ServeHTTP(w, r) + serveAddCredit(release).ServeHTTP(w, r) return case "newcredit": - serveNewCredit(app).ServeHTTP(w, r) + serveNewCredit().ServeHTTP(w, r) return case "editlinks": serveEditLinks(release).ServeHTTP(w, r) @@ -45,10 +46,10 @@ func serveRelease(app *model.AppState) http.Handler { serveEditTracks(release).ServeHTTP(w, r) return case "addtrack": - serveAddTrack(app, release).ServeHTTP(w, r) + serveAddTrack(release).ServeHTTP(w, r) return case "newtrack": - serveNewTrack(app).ServeHTTP(w, r) + serveNewTrack().ServeHTTP(w, r) return } http.NotFound(w, r) @@ -82,9 +83,9 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { +func serveAddCredit(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) + artists, err := controller.GetArtistsNotOnRelease(global.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) @@ -108,10 +109,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewCredit(app *model.AppState) http.Handler { +func serveNewCredit() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := controller.GetArtist(global.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) @@ -153,9 +154,9 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { +func serveAddTrack(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) + tracks, err := controller.GetTracksNotOnRelease(global.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) @@ -180,10 +181,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewTrack(app *model.AppState) http.Handler { +func serveNewTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(app.DB, trackID) + track, err := controller.GetTrack(global.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 32f69bb..510ee8b 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -124,65 +124,3 @@ 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 deleted file mode 100644 index 625db13..0000000 --- a/admin/static/edit-account.css +++ /dev/null @@ -1,65 +0,0 @@ -@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 deleted file mode 100644 index e69de29..0000000 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 793b989..e481b68 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -66,6 +66,54 @@ 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 10eada3..9feb9ad 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -109,6 +109,58 @@ 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 8a05089..6e87397 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -67,6 +67,54 @@ 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 9d38940..ec426af 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -98,3 +98,4 @@ .track .empty { opacity: 0.75; } + diff --git a/admin/templates.go b/admin/templates.go index 1fa7a65..e91313a 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -28,11 +28,6 @@ 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 fa49b53..2cea123 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -5,15 +5,16 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveTrack(app *model.AppState) http.Handler { +func serveTrack() 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(app.DB, id) + track, err := controller.GetTrack(global.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) @@ -24,7 +25,7 @@ func serveTrack(app *model.AppState) http.Handler { return } - releases, err := controller.GetTrackReleases(app.DB, track.ID, true) + releases, err := controller.GetTrackReleases(global.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 8d59c0f..5d92627 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 deleted file mode 100644 index 4d89052..0000000 --- a/admin/views/edit-account.html +++ /dev/null @@ -1,69 +0,0 @@ -{{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 ccb3a45..8cd88f0 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -1,6 +1,7 @@ {{define "head"}} Editing {{.Artist.Name}} - ari melody 💫 + {{end}} diff --git a/admin/views/edit-release.html b/admin/views/edit-release.html index 02447e1..9c1ba99 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -1,6 +1,7 @@ {{define "head"}} Editing {{.Release.Title}} - ari melody 💫 + {{end}} diff --git a/admin/views/edit-track.html b/admin/views/edit-track.html index 56e0ae4..5bb74eb 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 7744e91..16c0fcc 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}} @@ -69,13 +87,13 @@ input[disabled] {
- + - + - +
diff --git a/api/account.go b/api/account.go index 0a9a7f9..3ce52c8 100644 --- a/api/account.go +++ b/api/account.go @@ -3,6 +3,7 @@ package api import ( "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/global" "encoding/json" "fmt" "net/http" @@ -13,7 +14,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -func handleLogin(app *model.AppState) http.HandlerFunc { +func handleLogin() http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -32,7 +33,7 @@ func handleLogin(app *model.AppState) http.HandlerFunc { return } - account, err := controller.GetAccount(app.DB, credentials.Username) + account, err := controller.GetAccount(global.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) @@ -49,7 +50,7 @@ func handleLogin(app *model.AppState) http.HandlerFunc { return } - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) type LoginResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` @@ -66,7 +67,7 @@ func handleLogin(app *model.AppState) http.HandlerFunc { }) } -func handleAccountRegistration(app *model.AppState) http.HandlerFunc { +func handleAccountRegistration() http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -88,7 +89,7 @@ func handleAccountRegistration(app *model.AppState) http.HandlerFunc { } // make sure code exists in DB - invite, err := controller.GetInvite(app.DB, credentials.Invite) + invite, err := controller.GetInvite(global.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) @@ -100,7 +101,7 @@ func handleAccountRegistration(app *model.AppState) http.HandlerFunc { } if time.Now().After(invite.ExpiresAt) { - err := controller.DeleteInvite(app.DB, invite.Code) + err := controller.DeleteInvite(global.DB, invite.Code) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } http.Error(w, "Invalid invite code", http.StatusBadRequest) return @@ -119,7 +120,7 @@ func handleAccountRegistration(app *model.AppState) http.HandlerFunc { Email: credentials.Email, AvatarURL: "/img/default-avatar.png", } - err = controller.CreateAccount(app.DB, &account) + err = controller.CreateAccount(global.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) @@ -130,10 +131,10 @@ func handleAccountRegistration(app *model.AppState) http.HandlerFunc { return } - err = controller.DeleteInvite(app.DB, invite.Code) + err = controller.DeleteInvite(global.DB, invite.Code) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) type LoginResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` @@ -150,7 +151,7 @@ func handleAccountRegistration(app *model.AppState) http.HandlerFunc { }) } -func handleDeleteAccount(app *model.AppState) http.HandlerFunc { +func handleDeleteAccount() http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -169,7 +170,7 @@ func handleDeleteAccount(app *model.AppState) http.HandlerFunc { return } - account, err := controller.GetAccount(app.DB, credentials.Username) + account, err := controller.GetAccount(global.DB, credentials.Username) if err != nil { if strings.Contains(err.Error(), "no rows") { http.Error(w, "Invalid username or password", http.StatusBadRequest) @@ -188,7 +189,7 @@ func handleDeleteAccount(app *model.AppState) http.HandlerFunc { // TODO: check TOTP - err = controller.DeleteAccount(app.DB, account.Username) + err = controller.DeleteAccount(global.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 b16d45d..af6c6a7 100644 --- a/api/api.go +++ b/api/api.go @@ -6,32 +6,24 @@ import ( "strings" "arimelody-web/admin" + "arimelody-web/global" "arimelody-web/controller" - "arimelody-web/model" ) -func Handler(app *model.AppState) http.Handler { +func Handler() 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(app.DB, artistID) + artist, err := controller.GetArtist(global.DB, artistID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -45,13 +37,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist/{id} - ServeArtist(app, artist).ServeHTTP(w, r) + ServeArtist(artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - admin.RequireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -60,10 +52,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist - ServeAllArtists(app).ServeHTTP(w, r) + ServeAllArtists().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -73,7 +65,7 @@ func Handler(app *model.AppState) 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(app.DB, releaseID, true) + release, err := controller.GetRelease(global.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -87,13 +79,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music/{id} - ServeRelease(app, release).ServeHTTP(w, r) + ServeRelease(release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - admin.RequireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -102,10 +94,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music - ServeCatalog(app).ServeHTTP(w, r) + ServeCatalog().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -115,7 +107,7 @@ func Handler(app *model.AppState) 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(app.DB, trackID) + track, err := controller.GetTrack(global.DB, trackID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -129,13 +121,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - admin.RequireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - admin.RequireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -144,10 +136,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - admin.RequireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r) default: http.NotFound(w, r) } diff --git a/api/artist.go b/api/artist.go index a9676b1..c46db59 100644 --- a/api/artist.go +++ b/api/artist.go @@ -10,14 +10,15 @@ import ( "strings" "time" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeAllArtists(app *model.AppState) http.Handler { +func ServeAllArtists() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} - artists, err := controller.GetAllArtists(app.DB) + artists, err := controller.GetAllArtists(global.DB) if err != nil { fmt.Printf("WARN: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -34,7 +35,7 @@ func ServeAllArtists(app *model.AppState) http.Handler { }) } -func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { +func ServeArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type ( creditJSON struct { @@ -51,7 +52,7 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { } ) - account, err := controller.GetAccountByRequest(app.DB, r) + account, err := controller.GetAccountByRequest(global.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) @@ -59,7 +60,7 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { } show_hidden_releases := account != nil - dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) + dbCredits, err := controller.GetArtistCredits(global.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) @@ -91,7 +92,7 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { }) } -func CreateArtist(app *model.AppState) http.Handler { +func CreateArtist() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artist model.Artist err := json.NewDecoder(r.Body).Decode(&artist) @@ -106,7 +107,7 @@ func CreateArtist(app *model.AppState) http.Handler { } if artist.Name == "" { artist.Name = artist.ID } - err = controller.CreateArtist(app.DB, &artist) + err = controller.CreateArtist(global.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) @@ -121,7 +122,7 @@ func CreateArtist(app *model.AppState) http.Handler { }) } -func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { +func UpdateArtist(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 { @@ -135,7 +136,7 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { } else { if strings.Contains(artist.Avatar, ";base64,") { var artworkDirectory = filepath.Join("uploads", "avatar") - filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID) + filename, err := HandleImageUpload(&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 { @@ -154,7 +155,7 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { } } - err = controller.UpdateArtist(app.DB, artist) + err = controller.UpdateArtist(global.DB, artist) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -166,9 +167,9 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { }) } -func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler { +func DeleteArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteArtist(app.DB, artist.ID) + err := controller.DeleteArtist(global.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 c71043e..d17fb5f 100644 --- a/api/release.go +++ b/api/release.go @@ -10,16 +10,17 @@ import ( "strings" "time" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeRelease(app *model.AppState, release *model.Release) http.Handler { +func ServeRelease(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(app.DB, r) + account, err := controller.GetAccountByRequest(global.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) @@ -66,14 +67,14 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { if release.IsReleased() || privileged { // get credits - credits, err := controller.GetReleaseCredits(app.DB, release.ID) + credits, err := controller.GetReleaseCredits(global.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(app.DB, credit.Artist.ID) + artist, err := controller.GetArtist(global.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) @@ -88,7 +89,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { } // get tracks - tracks, err := controller.GetReleaseTracks(app.DB, release.ID) + tracks, err := controller.GetReleaseTracks(global.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) @@ -103,7 +104,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { } // get links - links, err := controller.GetReleaseLinks(app.DB, release.ID) + links, err := controller.GetReleaseLinks(global.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) @@ -125,9 +126,9 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { }) } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := controller.GetAllReleases(global.DB, false, 0, true) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -145,7 +146,7 @@ func ServeCatalog(app *model.AppState) http.Handler { } catalog := []Release{} - account, err := controller.GetAccountByRequest(app.DB, r) + account, err := controller.GetAccountByRequest(global.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) @@ -191,7 +192,7 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func CreateRelease(app *model.AppState) http.Handler { +func CreateRelease() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -219,7 +220,7 @@ func CreateRelease(app *model.AppState) http.Handler { if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } - err = controller.CreateRelease(app.DB, &release) + err = controller.CreateRelease(global.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) @@ -242,7 +243,7 @@ func CreateRelease(app *model.AppState) http.Handler { }) } -func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { +func UpdateRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.NotFound(w, r) @@ -254,11 +255,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { if len(segments) == 2 { switch segments[1] { case "tracks": - UpdateReleaseTracks(app, release).ServeHTTP(w, r) + UpdateReleaseTracks(release).ServeHTTP(w, r) case "credits": - UpdateReleaseCredits(app, release).ServeHTTP(w, r) + UpdateReleaseCredits(release).ServeHTTP(w, r) case "links": - UpdateReleaseLinks(app, release).ServeHTTP(w, r) + UpdateReleaseLinks(release).ServeHTTP(w, r) } return } @@ -280,7 +281,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { } else { if strings.Contains(release.Artwork, ";base64,") { var artworkDirectory = filepath.Join("uploads", "musicart") - filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID) + filename, err := HandleImageUpload(&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 { @@ -299,7 +300,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { } } - err = controller.UpdateRelease(app.DB, release) + err = controller.UpdateRelease(global.DB, release) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -311,7 +312,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { }) } -func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseTracks(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) @@ -320,7 +321,7 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl return } - err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs) + err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -332,7 +333,7 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl }) } -func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type creditJSON struct { Artist string @@ -357,7 +358,7 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand }) } - err = controller.UpdateReleaseCredits(app.DB, release.ID, credits) + err = controller.UpdateReleaseCredits(global.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) @@ -373,7 +374,7 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand }) } -func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseLinks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.NotFound(w, r) @@ -387,7 +388,7 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle return } - err = controller.UpdateReleaseLinks(app.DB, release.ID, links) + err = controller.UpdateReleaseLinks(global.DB, release.ID, links) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -399,9 +400,9 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle }) } -func DeleteRelease(app *model.AppState, release *model.Release) http.Handler { +func DeleteRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteRelease(app.DB, release.ID) + err := controller.DeleteRelease(global.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 c342e08..ebbaa10 100644 --- a/api/track.go +++ b/api/track.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) @@ -16,7 +17,7 @@ type ( } ) -func ServeAllTracks(app *model.AppState) http.Handler { +func ServeAllTracks() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type Track struct { ID string `json:"id"` @@ -25,7 +26,7 @@ func ServeAllTracks(app *model.AppState) http.Handler { var tracks = []Track{} var dbTracks = []*model.Track{} - dbTracks, err := controller.GetAllTracks(app.DB) + dbTracks, err := controller.GetAllTracks(global.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) @@ -49,9 +50,9 @@ func ServeAllTracks(app *model.AppState) http.Handler { }) } -func ServeTrack(app *model.AppState, track *model.Track) http.Handler { +func ServeTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) + dbReleases, err := controller.GetTrackReleases(global.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) @@ -73,7 +74,7 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler { }) } -func CreateTrack(app *model.AppState) http.Handler { +func CreateTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -92,7 +93,7 @@ func CreateTrack(app *model.AppState) http.Handler { return } - id, err := controller.CreateTrack(app.DB, &track) + id, err := controller.CreateTrack(global.DB, &track) if err != nil { fmt.Printf("WARN: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -105,7 +106,7 @@ func CreateTrack(app *model.AppState) http.Handler { }) } -func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { +func UpdateTrack(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) @@ -123,9 +124,9 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { return } - err = controller.UpdateTrack(app.DB, track) + err = controller.UpdateTrack(global.DB, track) if err != nil { - fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err) + fmt.Printf("Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -140,7 +141,7 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { }) } -func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { +func DeleteTrack(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) @@ -148,9 +149,9 @@ func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { } var trackID = r.URL.Path[1:] - err := controller.DeleteTrack(app.DB, trackID) + err := controller.DeleteTrack(global.DB, trackID) if err != nil { - fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err) + fmt.Printf("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 ddcf6ee..6b1c496 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,7 +1,7 @@ package api import ( - "arimelody-web/model" + "arimelody-web/global" "bufio" "encoding/base64" "errors" @@ -11,12 +11,12 @@ import ( "strings" ) -func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { +func HandleImageUpload(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(app.Config.DataDirectory, directory) + directory = filepath.Join(global.Config.DataDirectory, directory) switch ext { case "png": diff --git a/controller/account.go b/controller/account.go index 044faec..362e297 100644 --- a/controller/account.go +++ b/controller/account.go @@ -1,6 +1,7 @@ package controller import ( + "arimelody-web/global" "arimelody-web/model" "errors" "fmt" @@ -10,17 +11,6 @@ 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{} @@ -71,7 +61,7 @@ func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { return tokenStr } - cookie, err := r.Cookie(model.COOKIE_TOKEN) + cookie, err := r.Cookie(global.COOKIE_TOKEN) if err != nil { return "" } diff --git a/controller/artist.go b/controller/artist.go index 1a613aa..c52b78d 100644 --- a/controller/artist.go +++ b/controller/artist.go @@ -2,7 +2,6 @@ package controller import ( "arimelody-web/model" - "github.com/jmoiron/sqlx" ) diff --git a/controller/migrator.go b/controller/migrator.go index 3624255..46a564d 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -20,13 +20,9 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) { ) oldDBVersion := 0 - schemaVersionCount := 0 - err := db.Get(&schemaVersionCount, "SELECT COUNT(*) FROM schema_version") + + err := db.Get(&oldDBVersion, "SELECT MAX(version) 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 362669a..c9791ac 100644 --- a/controller/release.go +++ b/controller/release.go @@ -5,7 +5,6 @@ import ( "fmt" "arimelody-web/model" - "github.com/jmoiron/sqlx" ) diff --git a/controller/totp.go b/controller/totp.go deleted file mode 100644 index 18616da..0000000 --- a/controller/totp.go +++ /dev/null @@ -1,129 +0,0 @@ -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 fa4efc1..d302045 100644 --- a/controller/track.go +++ b/controller/track.go @@ -2,7 +2,6 @@ package controller import ( "arimelody-web/model" - "github.com/jmoiron/sqlx" ) diff --git a/controller/config.go b/global/config.go similarity index 63% rename from controller/config.go rename to global/config.go index 28d4be4..0115c3f 100644 --- a/controller/config.go +++ b/global/config.go @@ -1,4 +1,4 @@ -package controller +package global import ( "errors" @@ -6,26 +6,42 @@ import ( "os" "strconv" - "arimelody-web/model" - + "github.com/jmoiron/sqlx" "github.com/pelletier/go-toml/v2" ) -func GetConfig() model.Config { +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 { configFile := os.Getenv("ARIMELODY_CONFIG") if configFile == "" { configFile = "config.toml" } - config := model.Config{ + config := 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) @@ -40,18 +56,20 @@ func GetConfig() model.Config { err = toml.Unmarshal([]byte(data), &config) if err != nil { - panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err)) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err) + os.Exit(1) } err = handleConfigOverrides(&config) if err != nil { - panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err)) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err) + os.Exit(1) } return config -} +}() -func handleConfigOverrides(config *model.Config) error { +func handleConfigOverrides(config *config) error { var err error if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } @@ -62,10 +80,6 @@ func handleConfigOverrides(config *model.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 } @@ -76,3 +90,5 @@ func handleConfigOverrides(config *model.Config) error { return nil } + +var DB *sqlx.DB diff --git a/global/const.go b/global/const.go new file mode 100644 index 0000000..157668d --- /dev/null +++ b/global/const.go @@ -0,0 +1,3 @@ +package global + +const COOKIE_TOKEN string = "AM_TOKEN" diff --git a/global/funcs.go b/global/funcs.go new file mode 100644 index 0000000..49edb01 --- /dev/null +++ b/global/funcs.go @@ -0,0 +1,101 @@ +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 93466bd..2f0cb43 100644 --- a/main.go +++ b/main.go @@ -4,19 +4,16 @@ 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/model" + "arimelody-web/global" "arimelody-web/templates" "arimelody-web/view" @@ -32,191 +29,56 @@ 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 app.Config.DB.Host == "" { + 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 == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.Name == "" { + if global.Config.DB.Name == "" { fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.User == "" { + if global.Config.DB.User == "" { fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.Pass == "" { + if global.Config.DB.Pass == "" { fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") os.Exit(1) } var err error - app.DB, err = sqlx.Connect( + global.DB, err = sqlx.Connect( "postgres", fmt.Sprintf( - "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, + "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, ), ) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) os.Exit(1) } - app.DB.SetConnMaxLifetime(time.Minute * 3) - app.DB.SetMaxOpenConns(10) - app.DB.SetMaxIdleConns(10) - defer app.DB.Close() + global.DB.SetConnMaxLifetime(time.Minute * 3) + global.DB.SetMaxOpenConns(10) + global.DB.SetMaxIdleConns(10) + defer global.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(app.DB, 16, time.Hour * 24) + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) os.Exit(1) @@ -227,7 +89,7 @@ func main() { case "purgeInvites": fmt.Printf("Deleting all invites...\n") - err := controller.DeleteAllInvites(app.DB) + err := controller.DeleteAllInvites(global.DB) if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) os.Exit(1) @@ -236,38 +98,17 @@ 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) < 3 { - fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") os.Exit(1) } username := os.Args[2] fmt.Printf("Deleting account \"%s\"...\n", username) - account, err := controller.GetAccount(app.DB, username) + account, err := controller.GetAccount(global.DB, username) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) os.Exit(1) } @@ -283,7 +124,7 @@ func main() { return } - err = controller.DeleteAccount(app.DB, username) + err = controller.DeleteAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) os.Exit(1) @@ -294,68 +135,61 @@ func main() { } - // command help - fmt.Print( + fmt.Printf( "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(app.DB) + controller.CheckDBVersionAndMigrate(global.DB) // initial invite code accountsCount := 0 - err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") + err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") if err != nil { panic(err) } if accountsCount == 0 { - _, err := app.DB.Exec("DELETE FROM invite") + _, err := global.DB.Exec("DELETE FROM invite") if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) os.Exit(1) } - invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) + invite, err := controller.CreateInvite(global.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.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) + fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl) } // delete expired invites - err = controller.DeleteExpiredInvites(app.DB) + err = controller.DeleteExpiredInvites(global.DB) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) os.Exit(1) } // start the web server! - mux := createServeMux(&app) - fmt.Printf("Now serving at %s:%d\n", app.Config.BaseUrl, app.Config.Port) + mux := createServeMux() + fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port) log.Fatal( - http.ListenAndServe(fmt.Sprintf(":%d", app.Config.Port), - HTTPLog(DefaultHeaders(mux)), + http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), + global.HTTPLog(global.DefaultHeaders(mux)), )) } -func createServeMux(app *model.AppState) *http.ServeMux { +func createServeMux() *http.ServeMux { mux := http.NewServeMux() - 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("/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("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -396,93 +230,3 @@ 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 72720a0..03e95c5 100644 --- a/model/account.go +++ b/model/account.go @@ -1,18 +1,12 @@ 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"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - + 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"` Privileges []AccountPrivilege `json:"privileges"` } diff --git a/model/appstate.go b/model/appstate.go deleted file mode 100644 index 08016b7..0000000 --- a/model/appstate.go +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 8d8422f..0000000 --- a/model/totp.go +++ /dev/null @@ -1,12 +0,0 @@ -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 3f4c723..cd11a5e 100644 --- a/schema_migration/000-init.sql +++ b/schema_migration/000-init.sql @@ -1,4 +1,10 @@ -CREATE SCHEMA IF NOT EXISTS arimelody; +CREATE SCHEMA arimelody; + +-- Schema verison +CREATE TABLE arimelody.schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT current_timestamp +); -- -- Tables @@ -10,8 +16,7 @@ CREATE TABLE arimelody.account ( username text NOT NULL UNIQUE, password text NOT NULL, email text, - avatar_url text, - created_at TIMESTAMP DEFAULT current_timestamp + avatar_url text ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); @@ -22,6 +27,14 @@ 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, @@ -40,16 +53,6 @@ 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 ( @@ -118,8 +121,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.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.token ADD CONSTRAINT token_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 62bc15b..fc730a0 100644 --- a/schema_migration/001-pre-versioning.sql +++ b/schema_migration/001-pre-versioning.sql @@ -22,8 +22,7 @@ CREATE TABLE arimelody.account ( username text NOT NULL UNIQUE, password text NOT NULL, email text, - avatar_url text, - created_at TIMESTAMP DEFAULT current_timestamp + avatar_url text ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); @@ -34,6 +33,14 @@ 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, @@ -52,16 +59,7 @@ 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.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.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; diff --git a/view/music.go b/view/music.go index cae325f..3799182 100644 --- a/view/music.go +++ b/view/music.go @@ -6,36 +6,37 @@ import ( "os" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/templates" ) // HTTP HANDLER METHODS -func MusicHandler(app *model.AppState) http.Handler { +func MusicHandler() http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - ServeCatalog(app).ServeHTTP(w, r) + ServeCatalog().ServeHTTP(w, r) return } - release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true) + release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true) if err != nil { http.NotFound(w, r) return } - ServeGateway(app, release).ServeHTTP(w, r) + ServeGateway(release).ServeHTTP(w, r) })) return mux } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, true, 0, true) + releases, err := controller.GetAllReleases(global.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) @@ -55,12 +56,12 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func ServeGateway(app *model.AppState, release *model.Release) http.Handler { +func ServeGateway(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(app.DB, r) + account, err := controller.GetAccountByRequest(global.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)