diff --git a/.dockerignore b/.dockerignore index 785d79e..e2d5600 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,11 @@ .air.toml/ .gitattributes .gitignore -uploads/* +uploads/ test/ tmp/ res/ docker-compose.yml +docker-compose-test.yml Dockerfile schema.sql diff --git a/.gitignore b/.gitignore index cccde2b..9bdf788 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ uploads/ docker-compose*.yml !docker-compose.example.yml config*.toml +arimelody-web diff --git a/Dockerfile b/Dockerfile index 278f01a..0e0d2a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web # --- -FROM build-stage AS build-release-stage +FROM scratch WORKDIR /app diff --git a/README.md b/README.md index df0c351..7e7860c 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff! ## running the server should be run once to generate a default `config.toml` file. -configure as needed. note that a valid DB connection is required, and the admin -panel will be disabled without valid discord app credentials (this can however -be bypassed by running the server with `-adminBypass`). +configure as needed. a valid DB connection is required to run this website. +if no admin users exist, an invite code will be provided. invite codes are +the only way to create admin accounts at this time. the configuration may be overridden using environment variables in the format `ARIMELODY__`. for example, `db.host` in the config may @@ -32,8 +32,17 @@ be overridden with `ARIMELODY_DB_HOST`. the location of the configuration file can also be overridden with `ARIMELODY_CONFIG`. -## database +### command arguments -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. +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`. diff --git a/admin/accounthttp.go b/admin/accounthttp.go new file mode 100644 index 0000000..5e3f4b6 --- /dev/null +++ b/admin/accounthttp.go @@ -0,0 +1,389 @@ +package admin + +import ( + "database/sql" + "fmt" + "net/http" + "net/url" + "os" + "time" + + "arimelody-web/controller" + "arimelody-web/model" + + "golang.org/x/crypto/bcrypt" +) + +func accountHandler(app *model.AppState) http.Handler { + mux := http.NewServeMux() + + mux.Handle("/totp-setup", totpSetupHandler(app)) + mux.Handle("/totp-confirm", totpConfirmHandler(app)) + mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) + + mux.Handle("/password", changePasswordHandler(app)) + mux.Handle("/delete", deleteAccountHandler(app)) + + return mux +} + +func accountIndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.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 ( + TOTP struct { + model.TOTP + CreatedAtString string + } + + accountResponse struct { + Session *model.Session + TOTPs []TOTP + } + ) + + totps := []TOTP{} + for _, totp := range dbTOTPs { + totps = append(totps, TOTP{ + TOTP: totp, + CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"), + }) + } + + sessionMessage := session.Message + sessionError := session.Error + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + session.Message = sessionMessage + session.Error = sessionError + + err = accountTemplate.Execute(w, accountResponse{ + Session: session, + 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 changePasswordHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + + r.ParseForm() + + currentPassword := r.Form.Get("current-password") + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { + controller.SetSessionError(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + newPassword := r.Form.Get("new-password") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + session.Account.Password = string(hashedPassword) + err = controller.UpdateAccount(app.DB, session.Account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "Password updated successfully.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + }) +} + +func deleteAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 + } + + if !r.Form.Has("password") || !r.Form.Has("totp") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + session := r.Context().Value("session").(*model.Session) + + // check password + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) + controller.SetSessionError(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp")) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + if totpMethod == nil { + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + } + + err = controller.DeleteAccount(app.DB, session.Account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + fmt.Printf( + "[%s] INFO: Account \"%s\" deleted by user request.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) + + controller.SetSessionAccount(app.DB, session, nil) + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") + http.Redirect(w, r, "/admin/login", http.StatusFound) + }) +} + +type totpConfirmData struct { + Session *model.Session + TOTP *model.TOTP + NameEscaped string + QRBase64Image string +} + +func totpSetupHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + type totpSetupData struct { + Session *model.Session + } + + session := r.Context().Value("session").(*model.Session) + + err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) + if err != nil { + fmt.Printf("WARN: Failed to render TOTP setup 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 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + session := r.Context().Value("session").(*model.Session) + + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) + totp := model.TOTP { + AccountID: session.Account.ID, + Name: name, + Secret: string(secret), + } + err = controller.CreateTOTP(app.DB, &totp) + if err != nil { + fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) + if err != nil { + fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) + } + + err = totpConfirmTemplate.Execute(w, totpConfirmData{ + Session: session, + TOTP: &totp, + NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, + }) + if err != nil { + fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func totpConfirmHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + code := r.FormValue("totp") + if len(code) != controller.TOTP_CODE_LENGTH { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) + if err != nil { + fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + if totp == nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) + } + + confirmCode := controller.GenerateTOTP(totp.Secret, 0) + if code != confirmCode { + confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) + if code != confirmCodeOffset { + session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } + err = totpConfirmTemplate.Execute(w, totpConfirmData{ + Session: session, + TOTP: totp, + NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + } + + err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) + if err != nil { + fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) + http.Redirect(w, r, "/admin/account", http.StatusFound) + }) +} + +func totpDeleteHandler(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 + } + + if len(r.URL.Path) < 2 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + name := r.URL.Path[1:] + + session := r.Context().Value("session").(*model.Session) + + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) + if err != nil { + fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + if totp == nil { + http.NotFound(w, r) + return + } + + err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) + if err != nil { + fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) + http.Redirect(w, r, "/admin/account", http.StatusFound) + }) +} diff --git a/admin/admin.go b/admin/admin.go deleted file mode 100644 index 4c07fa0..0000000 --- a/admin/admin.go +++ /dev/null @@ -1,58 +0,0 @@ -package admin - -import ( - "fmt" - "math/rand" - "os" - "time" - - "arimelody-web/global" -) - -type ( - Session struct { - Token string - UserID string - Expires time.Time - } -) - -const TOKEN_LENGTH = 64 -const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -var ADMIN_BYPASS = func() bool { - if global.Args["adminBypass"] == "true" { - fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)") - return true - } - return false -}() - -var ADMIN_ID_DISCORD = func() string { - id := os.Getenv("DISCORD_ADMIN_ID") - if id == "" { id = global.Config.Discord.AdminID } - if id == "" { - fmt.Printf("WARN: discord.admin_id not provided. Admin login will be unavailable.\n") - } - return id -}() - -var sessions []*Session - -func createSession(username string, expires time.Time) Session { - return Session{ - Token: string(generateToken()), - UserID: username, - Expires: expires, - } -} - -func generateToken() string { - var token []byte - - for i := 0; i < TOKEN_LENGTH; i++ { - token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))]) - } - - return string(token) -} diff --git a/admin/artisthttp.go b/admin/artisthttp.go index ba26240..6dfbbfd 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -5,16 +5,15 @@ import ( "net/http" "strings" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveArtist() http.Handler { +func serveArtist(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - artist, err := controller.GetArtist(global.DB, id) + artist, err := controller.GetArtist(app.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -25,19 +24,26 @@ func serveArtist() http.Handler { return } - credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - type Artist struct { - *model.Artist - Credits []*model.Credit + type ArtistResponse struct { + Session *model.Session + Artist *model.Artist + Credits []*model.Credit } - err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits }) + session := r.Context().Value("session").(*model.Session) + + err = artistTemplate.Execute(w, ArtistResponse{ + Session: session, + Artist: artist, + Credits: credits, + }) 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/components/credits/editcredits.html b/admin/components/credits/editcredits.html index 999740a..94dc268 100644 --- a/admin/components/credits/editcredits.html +++ b/admin/components/credits/editcredits.html @@ -52,7 +52,6 @@ makeMagicList(creditList, ".credit"); function rigCredit(el) { - console.log(el); const artistID = el.dataset.artist; const deleteBtn = el.querySelector("a.delete"); diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index dba57a6..d03f80a 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,21 +3,21 @@

Editing: Tracks

Add -
+
    - {{range .Tracks}} -
  • + {{range $i, $track := .Release.Tracks}} +
  • - {{.Number}} - {{.Title}} + {{.Add $i 1}} + {{$track.Title}}

    Delete
    diff --git a/admin/http.go b/admin/http.go index 19afa92..6fd8f59 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,6 +2,7 @@ package admin import ( "context" + "database/sql" "fmt" "net/http" "os" @@ -9,229 +10,392 @@ import ( "strings" "time" - "arimelody-web/discord" - "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" + + "golang.org/x/crypto/bcrypt" ) -type loginData struct { - DiscordURI string - Token string -} - -func Handler() http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() - mux.Handle("/login", LoginHandler()) - mux.Handle("/logout", MustAuthorise(LogoutHandler())) + mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family") + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Write([]byte("")) + })) + + mux.Handle("/login", loginHandler(app)) + mux.Handle("/logout", requireAccount(app, logoutHandler(app))) + + mux.Handle("/register", registerAccountHandler(app)) + + mux.Handle("/account", requireAccount(app, accountIndexHandler(app))) + mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app)))) + + 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("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) - mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist()))) - mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + mux.Handle("/", requireAccount(app, AdminIndexHandler(app))) + + // response wrapper to make sure a session cookie exists + return enforceSession(app, mux) +} + +func AdminIndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - session := GetSession(r) - if session == nil { - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } + session := r.Context().Value("session").(*model.Session) - releases, err := controller.GetAllReleases(global.DB, false, 0, true) + releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { - fmt.Printf("FATAL: Failed to pull releases: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(global.DB) + artists, err := controller.GetAllArtists(app.DB) if err != nil { - fmt.Printf("FATAL: Failed to pull artists: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := controller.GetOrphanTracks(global.DB) - if err != nil { - fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + tracks, err := controller.GetOrphanTracks(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } type IndexData struct { + Session *model.Session Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } - err = pages["index"].Execute(w, IndexData{ + err = indexTemplate.Execute(w, IndexData{ + Session: session, Releases: releases, Artists: artists, Tracks: tracks, }) - if err != nil { - fmt.Printf("Error executing template: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - })) - - return mux -} - -func MustAuthorise(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := GetSession(r) - if session == nil { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - - ctx := context.WithValue(r.Context(), "session", session) - next.ServeHTTP(w, r.WithContext(ctx)) }) } -func GetSession(r *http.Request) *Session { - if ADMIN_BYPASS { - return &Session{} - } +func registerAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) - var token = "" - // is the session token in context? - var ctx_session = r.Context().Value("session") - if ctx_session != nil { - token = ctx_session.(*Session).Token - } - - // okay, is it in the auth header? - if token == "" { - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - token = r.Header.Get("Authorization")[7:] + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return } - } - // finally, is it in the cookie? - if token == "" { - cookie, err := r.Cookie("token") - if err != nil { - return nil - } - token = cookie.Value - } - var session *Session = nil - for _, s := range sessions { - if s.Expires.Before(time.Now()) { - // expired session. remove it from the list! - new_sessions := []*Session{} - for _, ns := range sessions { - if ns.Token == s.Token { - continue - } - new_sessions = append(new_sessions, ns) + type registerData struct { + Session *model.Session + } + + render := func() { + err := registerTemplate.Execute(w, registerData{ Session: session }) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - sessions = new_sessions - continue } - if s.Token == token { - session = s - break + if r.Method == http.MethodGet { + render() + return } - } - return session + 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 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 invite 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) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + 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) } + } + controller.SetSessionError(app.DB, session, "Invalid invite code.") + render() + 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) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: sql.NullString{ String: credentials.Email, Valid: true }, + AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, + } + err = controller.CreateAccount(app.DB, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + controller.SetSessionError(app.DB, session, "An account with that username already exists.") + render() + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + fmt.Printf( + "[%s]: Account registered: %s (%s)\n", + time.Now().Format(time.UnixDate), + account.Username, + account.ID, + ) + + 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! + controller.SetSessionAccount(app.DB, session, &account) + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) } -func LoginHandler() http.Handler { +func loginHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" { - http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.NotFound(w, r) return } - code := r.URL.Query().Get("code") + session := r.Context().Value("session").(*model.Session) - if code == "" { - pages["login"].Execute(w, loginData{DiscordURI: discord.REDIRECT_URI}) + type loginData struct { + Session *model.Session + } + + render := func() { + err := loginTemplate.Execute(w, loginData{ Session: session }) + 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.MethodGet { + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + render() return } - auth_token, err := discord.GetOAuthTokenFromCode(code) + err := r.ParseForm() if err != nil { - fmt.Printf("Failed to retrieve discord access token: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - discord_user, err := discord.GetDiscordUserFromAuth(auth_token) + 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.GetAccountByUsername(app.DB, credentials.Username) if err != nil { - fmt.Printf("Failed to retrieve discord user information: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() + return + } + if account == nil { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() return } - if discord_user.ID != ADMIN_ID_DISCORD { - // TODO: unauthorized user; revoke the token - fmt.Printf("Unauthorized login attempted: %s\n", discord_user.ID) - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + fmt.Printf( + "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n", + time.Now().Format(time.UnixDate), + account.Username, + ) + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() return } + var totpMethod *model.TOTP + if len(credentials.TOTP) == 0 { + // check if user has TOTP + totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + if len(totps) > 0 { + type loginTOTPData struct { + Session *model.Session + Username string + Password string + } + err = loginTOTPTemplate.Execute(w, loginTOTPData{ + Session: session, + Username: credentials.Username, + Password: credentials.Password, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + } else { + totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + if totpMethod == nil { + controller.SetSessionError(app.DB, session, "Invalid TOTP.") + render() + return + } + } + + if totpMethod != nil { + fmt.Printf( + "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", + time.Now().Format(time.UnixDate), + account.Username, + totpMethod.Name, + ) + } else { + fmt.Printf( + "[%s] INFO: Account \"%s\" logged in\n", + time.Now().Format(time.UnixDate), + account.Username, + ) + } + + // TODO: log login activity to user + // login success! - session := createSession(discord_user.Username, time.Now().Add(24 * time.Hour)) - sessions = append(sessions, &session) - - cookie := http.Cookie{} - cookie.Name = "token" - cookie.Value = session.Token - cookie.Expires = time.Now().Add(24 * time.Hour) - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, loginData{Token: session.Token}) - if err != nil { - fmt.Printf("Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + controller.SetSessionAccount(app.DB, session, account) + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) }) } -func LogoutHandler() http.Handler { +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 } - session := GetSession(r) - - // remove this session from the list - sessions = func (token string) []*Session { - new_sessions := []*Session{} - for _, session := range sessions { - if session.Token != token { - new_sessions = append(new_sessions, session) - } - } - return new_sessions - }(session.Token) - - err := pages["logout"].Execute(w, nil) + session := r.Context().Value("session").(*model.Session) + err := controller.DeleteSession(app.DB, session.Token) if err != nil { - fmt.Printf("Error rendering admin logout page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Expires: time.Now(), + Path: "/", + }) + + err = logoutTemplate.Execute(w, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + if session.Account == nil { + // TODO: include context in redirect + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + next.ServeHTTP(w, r) }) } @@ -255,3 +419,53 @@ func staticHandler() http.Handler { http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) }) } + +func enforceSession(app *model.AppState, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) + if err != nil && err != http.ErrNoCookie { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + var session *model.Session + + if sessionCookie != nil { + // fetch existing session + session, err = controller.GetSession(app.DB, sessionCookie.Value) + + if err != nil && !strings.Contains(err.Error(), "no rows") { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if session != nil { + // TODO: consider running security checks here (i.e. user agent mismatches) + } + } + + if session == nil { + // create a new session + session, err = controller.CreateSession(app.DB, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Value: session.Token, + Expires: session.ExpiresAt, + Secure: strings.HasPrefix(app.Config.BaseUrl, "https"), + HttpOnly: true, + Path: "/", + }) + } + + ctx := context.WithValue(r.Context(), "session", session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 54d0fd6..be5052b 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -5,17 +5,18 @@ import ( "net/http" "strings" - "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func serveRelease() http.Handler { +func serveRelease(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - release, err := controller.GetRelease(global.DB, releaseID, true) + session := r.Context().Value("session").(*model.Session) + + release, err := controller.GetRelease(app.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -26,22 +27,16 @@ func serveRelease() http.Handler { return } - authorised := GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return - } - if len(slices) > 1 { switch slices[1] { case "editcredits": serveEditCredits(release).ServeHTTP(w, r) return case "addcredit": - serveAddCredit(release).ServeHTTP(w, r) + serveAddCredit(app, release).ServeHTTP(w, r) return case "newcredit": - serveNewCredit().ServeHTTP(w, r) + serveNewCredit(app).ServeHTTP(w, r) return case "editlinks": serveEditLinks(release).ServeHTTP(w, r) @@ -50,17 +45,25 @@ func serveRelease() http.Handler { serveEditTracks(release).ServeHTTP(w, r) return case "addtrack": - serveAddTrack(release).ServeHTTP(w, r) + serveAddTrack(app, release).ServeHTTP(w, r) return case "newtrack": - serveNewTrack().ServeHTTP(w, r) + serveNewTrack(app).ServeHTTP(w, r) return } http.NotFound(w, r) return } - err = pages["release"].Execute(w, release) + type ReleaseResponse struct { + Session *model.Session + Release *model.Release + } + + err = releaseTemplate.Execute(w, ReleaseResponse{ + Session: session, + Release: release, + }) if err != nil { fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -71,7 +74,7 @@ func serveRelease() http.Handler { func serveEditCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["editcredits"].Execute(w, release) + err := editCreditsTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -79,9 +82,9 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(release *model.Release) http.Handler { +func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID) + artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -94,7 +97,7 @@ func serveAddCredit(release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addcredit"].Execute(w, response{ + err = addCreditTemplate.Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -105,10 +108,10 @@ func serveAddCredit(release *model.Release) http.Handler { }) } -func serveNewCredit() http.Handler { +func serveNewCredit(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(global.DB, artistID) + artist, err := controller.GetArtist(app.DB, artistID) if err != nil { fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -120,7 +123,7 @@ func serveNewCredit() http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newcredit"].Execute(w, artist) + err = newCreditTemplate.Execute(w, artist) if err != nil { fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -131,7 +134,7 @@ func serveNewCredit() http.Handler { func serveEditLinks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["editlinks"].Execute(w, release) + err := editLinksTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -142,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler { func serveEditTracks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["edittracks"].Execute(w, release) + + type editTracksData struct { + Release *model.Release + Add func(a int, b int) int + } + + err := editTracksTemplate.Execute(w, editTracksData{ + Release: release, + Add: func(a, b int) int { return a + b }, + }) if err != nil { fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -150,9 +162,9 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(release *model.Release) http.Handler { +func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID) + tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -165,7 +177,7 @@ func serveAddTrack(release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addtrack"].Execute(w, response{ + err = addTrackTemplate.Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -177,10 +189,10 @@ func serveAddTrack(release *model.Release) http.Handler { }) } -func serveNewTrack() http.Handler { +func serveNewTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(app.DB, trackID) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -192,7 +204,7 @@ func serveNewTrack() http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newtrack"].Execute(w, track) + err = newTrackTemplate.Execute(w, track) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/static/admin.css b/admin/static/admin.css index 0d1269c..877b5da 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -24,7 +24,7 @@ nav { justify-content: left; background: #f8f8f8; - border-radius: .5em; + border-radius: 4px; border: 1px solid #808080; } nav .icon { @@ -43,7 +43,7 @@ nav .title { color: inherit; } -nav a { +.nav-item { width: auto; height: 100%; @@ -53,16 +53,17 @@ nav a { display: flex; line-height: 2em; - text-decoration: none; - - color: inherit; } -nav a:hover { +.nav-item:hover { background: #00000010; text-decoration: none; } +nav a { + text-decoration: none; + color: inherit; +} nav #logout { - margin-left: auto; + /* margin-left: auto; */ } main { @@ -84,6 +85,15 @@ a img.icon { height: .8em; } +code { + background: #303030; + color: #f0f0f0; + padding: .23em .3em; + border-radius: 4px; +} + + + .card { margin-bottom: 1em; } @@ -92,13 +102,6 @@ a img.icon { margin: 0 0 .5em 0; } -/* -.card h3, -.card p { - margin: 0; -} -*/ - .card-title { margin-bottom: 1em; display: flex; @@ -114,8 +117,112 @@ a img.icon { margin: 0; } +.flex-fill { + flex-grow: 1; +} + @media screen and (max-width: 520px) { body { font-size: 12px; } } + + + +#message, +#error { + margin: 0 0 1em 0; + padding: 1em; + border-radius: 4px; + background: #ffffff; + border: 1px solid #888; +} +#message { + background: #a9dfff; + border-color: #599fdc; +} +#error { + background: #ffa9b8; + border-color: #dc5959; +} + + + +a.delete:not(.button) { + color: #d22828; +} + +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: 4px; + 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, button { + color: inherit; +} +.button.new, button.new { + background: #c4ff6a; + border-color: #84b141; +} +.button.save, button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +.button.delete, button.delete { + background: #ff7171; + border-color: #7d3535; +} +.button:hover, button:hover { + background: #fff; + border-color: #d0d0d0; +} +.button:active, button:active { + background: #d0d0d0; + border-color: #808080; +} +.button[disabled], button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + + + +form { + width: 100%; + display: block; +} +form label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} +form input { + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} +input[disabled] { + opacity: .5; + cursor: not-allowed; +} diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css new file mode 100644 index 0000000..7a4d34a --- /dev/null +++ b/admin/static/edit-account.css @@ -0,0 +1,48 @@ +@import url("/admin/static/index.css"); + +div.card { + margin-bottom: 2rem; +} + +label { + width: auto; + margin: 0; + display: flex; + align-items: center; + color: inherit; +} +input { + width: min(20rem, calc(100% - 1rem)); + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} + +.mfa-device { + padding: .75em; + background: #f8f8f8f8; + border: 1px solid #808080; + border-radius: 8px; + margin-bottom: .5em; + display: flex; + justify-content: space-between; +} + +.mfa-device div { + display: flex; + flex-direction: column; + justify-content: center; +} + +.mfa-device p { + margin: 0; +} + +.mfa-device .mfa-device-name { + font-weight: bold; +} diff --git a/admin/static/edit-account.js b/admin/static/edit-account.js new file mode 100644 index 0000000..e69de29 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index e481b68..5627e64 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -9,7 +9,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -66,54 +66,6 @@ input[type="text"]:focus { border-color: #808080; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .artist-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 9feb9ad..aa70e34 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -11,7 +11,7 @@ input[type="text"] { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -109,58 +109,6 @@ input[type="text"] { padding: 0; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.new { - background: #c4ff6a; - border-color: #84b141; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .release-actions { margin-top: auto; display: flex; @@ -212,7 +160,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -222,7 +170,7 @@ dialog div.dialog-actions { } .card.credits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } .card.credits .credit .artist-name { @@ -248,7 +196,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -267,7 +215,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } #editcredits .credit .credit-info { @@ -280,12 +228,14 @@ dialog div.dialog-actions { } #editcredits .credit .credit-info .credit-attribute label { + width: auto; + margin: 0; display: flex; align-items: center; } #editcredits .credit .credit-info .credit-attribute input[type="text"] { - margin-left: .25em; + margin: 0 0 0 .25em; padding: .2em .4em; flex-grow: 1; font-family: inherit; @@ -293,6 +243,9 @@ dialog div.dialog-actions { border-radius: 4px; color: inherit; } +#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { + margin: 0 .3em; +} #editcredits .credit .artist-name { font-weight: bold; @@ -421,8 +374,10 @@ dialog div.dialog-actions { #editlinks td input[type="text"] { width: calc(100% - .6em); height: 100%; + margin: 0; padding: 0 .3em; border: none; + border-radius: 0; outline: none; cursor: pointer; background: none; @@ -445,7 +400,7 @@ dialog div.dialog-actions { flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 6e87397..600b680 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -11,7 +11,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -67,54 +67,6 @@ h1 { border-color: #808080; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .track-actions { margin-top: 1em; display: flex; diff --git a/admin/static/index.css b/admin/static/index.css index ec426af..9fcd731 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,23 +1,5 @@ @import url("/admin/static/release-list-item.css"); -.create-btn { - background: #c4ff6a; - padding: .5em .8em; - border-radius: .5em; - border: 1px solid #84b141; - text-decoration: none; -} -.create-btn:hover { - background: #fff; - border-color: #d0d0d0; - text-decoration: inherit; -} -.create-btn:active { - background: #d0d0d0; - border-color: #808080; - text-decoration: inherit; -} - .artist { margin-bottom: .5em; padding: .5em; @@ -26,7 +8,7 @@ align-items: center; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -49,7 +31,7 @@ flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -98,4 +80,3 @@ .track .empty { opacity: 0.75; } - diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css index ee67de7..638eac0 100644 --- a/admin/static/release-list-item.css +++ b/admin/static/release-list-item.css @@ -5,7 +5,7 @@ flex-direction: row; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -50,7 +50,7 @@ padding: .5em; display: block; - border-radius: .5em; + border-radius: 8px; text-decoration: none; color: #f0f0f0; background: #303030; @@ -73,7 +73,7 @@ padding: .3em .5em; display: inline-block; - border-radius: .3em; + border-radius: 4px; background: #e0e0e0; transition: color .1s, background .1s; diff --git a/admin/templates.go b/admin/templates.go index b7aaf9e..49c118b 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,55 +1,90 @@ package admin import ( - "html/template" - "path/filepath" + "html/template" + "path/filepath" ) -var pages = map[string]*template.Template{ - "index": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "index.html"), - )), +var indexTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "index.html"), +)) - "login": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "login.html"), - )), - "logout": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "logout.html"), - )), +var loginTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login.html"), +)) +var loginTOTPTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), +)) +var registerTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "register.html"), +)) +var logoutTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logout.html"), +)) +var accountTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), +)) +var totpSetupTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-setup.html"), +)) +var totpConfirmTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-confirm.html"), +)) - "release": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-release.html"), - )), - "artist": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-artist.html"), - )), - "track": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "edit-track.html"), - )), -} +var releaseTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-release.html"), +)) +var artistTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-artist.html"), +)) +var trackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "edit-track.html"), +)) -var components = map[string]*template.Template{ - "editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))), - "addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))), - "newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))), +var editCreditsTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "editcredits.html"), +)) +var addCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "addcredit.html"), +)) +var newCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "newcredit.html"), +)) - "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))), +var editLinksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "links", "editlinks.html"), +)) - "edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))), - "addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))), - "newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))), -} +var editTracksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "edittracks.html"), +)) +var addTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "addtrack.html"), +)) +var newTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "newtrack.html"), +)) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 148b7d8..a92f81a 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -5,16 +5,15 @@ import ( "net/http" "strings" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveTrack() http.Handler { +func serveTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - track, err := controller.GetTrack(global.DB, id) + track, err := controller.GetTrack(app.DB, id) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -25,19 +24,26 @@ func serveTrack() http.Handler { return } - releases, err := controller.GetTrackReleases(global.DB, track.ID, true) + releases, err := controller.GetTrackReleases(app.DB, track.ID, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - type Track struct { - *model.Track + type TrackResponse struct { + Session *model.Session + Track *model.Track Releases []*model.Release } - err = pages["track"].Execute(w, Track{ Track: track, Releases: releases }) + session := r.Context().Value("session").(*model.Session) + + err = trackTemplate.Execute(w, TrackResponse{ + Session: session, + Track: track, + Releases: releases, + }) 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/views/edit-account.html b/admin/views/edit-account.html new file mode 100644 index 0000000..6c17088 --- /dev/null +++ b/admin/views/edit-account.html @@ -0,0 +1,84 @@ +{{define "head"}} +Account Settings - ari melody 💫 + + +{{end}} + +{{define "content"}} +
    + {{if .Session.Message.Valid}} +

    {{html .Session.Message.String}}

    + {{end}} + {{if .Session.Error.Valid}} +

    {{html .Session.Error.String}}

    + {{end}} +

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

    + +
    +

    Change Password

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

    MFA Devices

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

    {{.TOTP.Name}}

    +

    Added: {{.CreatedAtString}}

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

    You have no MFA devices.

    + {{end}} + +
    + + Add TOTP Device +
    +
    + +
    +

    Danger Zone

    +
    +
    +

    + Clicking the button below will delete your account. + This action is irreversible. + You will need to enter your password and TOTP below. +

    +
    + + + + + + + +
    +
    + +
    + + +{{end}} diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index b54361a..b0cfb41 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -1,6 +1,6 @@ {{define "head"}} -Editing {{.Name}} - ari melody 💫 - +Editing {{.Artist.Name}} - ari melody 💫 + {{end}} @@ -8,20 +8,20 @@

    Editing Artist

    -
    +
    - +

    Name

    - +

    Website

    - +
    diff --git a/admin/views/edit-release.html b/admin/views/edit-release.html index d106bb5..02447e1 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -1,28 +1,27 @@ {{define "head"}} -Editing {{.Title}} - ari melody 💫 - - +Editing {{.Release.Title}} - ari melody 💫 + {{end}} {{define "content"}}
    -
    +
    - +

    - +

    Type - {{$t := .ReleaseType}} + {{$t := .Release.ReleaseType}} + >{{.Release.Description}}
    Release Date - +
    Buy Name - +
    Buy Link - +
    Copyright - +
    Copyright URL - +
    Visible
    -

    Credits ({{len .Credits}})

    +

    Credits ({{len .Release.Credits}})

    Edit
    - {{range .Credits}} + {{range .Release.Credits}}
    @@ -122,37 +121,37 @@
    {{end}} - {{if not .Credits}} + {{if not .Release.Credits}}

    There are no credits.

    {{end}}
    -

    Links ({{len .Links}})

    +

    Links ({{len .Release.Links}})

    Edit
    -

    Tracklist ({{len .Tracks}})

    +

    Tracklist ({{len .Release.Tracks}})

    Edit
    - {{range $i, $track := .Tracks}} + {{range $i, $track := .Release.Tracks}}

    {{.Add $i 1}} diff --git a/admin/views/edit-track.html b/admin/views/edit-track.html index 2b0fed5..56e0ae4 100644 --- a/admin/views/edit-track.html +++ b/admin/views/edit-track.html @@ -1,6 +1,6 @@ {{define "head"}} Editing Track - ari melody 💫 - + {{end}} @@ -8,30 +8,30 @@

    Editing Track

    -
    +

    Title

    - +

    Description

    + >{{.Track.Description}}

    Lyrics

    + >{{.Track.Lyrics}}
    diff --git a/admin/views/index.html b/admin/views/index.html index 8f42e0e..2b9c897 100644 --- a/admin/views/index.html +++ b/admin/views/index.html @@ -9,7 +9,7 @@

    Releases

    - Create New + Create New
    {{range .Releases}} @@ -22,7 +22,7 @@

    Artists

    - Create New + Create New
    {{range $Artist := .Artists}} @@ -38,7 +38,7 @@

    Tracks

    - Create New + Create New

    "Orphaned" tracks that have not yet been bound to a release.

    diff --git a/admin/views/layout.html b/admin/views/layout.html index ca9ce1f..8c34c8e 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -17,9 +17,25 @@
    diff --git a/admin/views/login-totp.html b/admin/views/login-totp.html new file mode 100644 index 0000000..d959e3c --- /dev/null +++ b/admin/views/login-totp.html @@ -0,0 +1,42 @@ +{{define "head"}} +Login - ari melody 💫 + + + +{{end}} + +{{define "content"}} +
    +
    +

    Two-Factor Authentication

    + +
    + + + + +
    + + +
    +
    +{{end}} diff --git a/admin/views/login.html b/admin/views/login.html index f615746..fbb7294 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -1,32 +1,50 @@ {{define "head"}} Login - ari melody 💫 - + {{end}} {{define "content"}}
    - {{if .Token}} - - -

    - Logged in successfully. - You should be redirected to /admin in 5 seconds. -

    - - {{else}} - -

    Log in with Discord.

    - + {{if .Session.Message.Valid}} +

    {{html .Session.Message.String}}

    {{end}} + {{if .Session.Error.Valid}} +

    {{html .Session.Error.String}}

    + {{end}} + +
    +

    Log In

    + +
    + + + + + +
    + + +
    {{end}} diff --git a/admin/views/logout.html b/admin/views/logout.html index 1999377..f127fd6 100644 --- a/admin/views/logout.html +++ b/admin/views/logout.html @@ -12,13 +12,10 @@ p a { {{define "content"}}
    - +

    Logged out successfully. - You should be redirected to / in 5 seconds. - + You should be redirected to /admin/login shortly.

    diff --git a/admin/views/register.html b/admin/views/register.html new file mode 100644 index 0000000..37f0947 --- /dev/null +++ b/admin/views/register.html @@ -0,0 +1,61 @@ +{{define "head"}} +Register - ari melody 💫 + + + +{{end}} + +{{define "content"}} +
    + {{if .Session.Error.Valid}} +

    {{html .Session.Error.String}}

    + {{end}} + +
    +

    Create Account

    + +
    + + + + + + + + + + + +
    + + +
    +
    +{{end}} diff --git a/admin/views/totp-confirm.html b/admin/views/totp-confirm.html new file mode 100644 index 0000000..7d305ec --- /dev/null +++ b/admin/views/totp-confirm.html @@ -0,0 +1,48 @@ +{{define "head"}} +TOTP Confirmation - ari melody 💫 + + + +{{end}} + +{{define "content"}} +
    + {{if .Session.Error.Valid}} +

    {{html .Session.Error.String}}

    + {{end}} + +
    + {{if .QRBase64Image}} + + +

    + Scan the QR code above into your authentication app or password manager, + then enter your 2FA code below. +

    + +

    + If the QR code does not work, you may also enter this secret code: +

    + {{else}} +

    + Paste the below secret code into your authentication app or password manager, + then enter your 2FA code below: +

    + {{end}} + +

    {{.TOTP.Secret}}

    + + + + + +
    +
    +{{end}} diff --git a/admin/views/totp-setup.html b/admin/views/totp-setup.html new file mode 100644 index 0000000..e74c970 --- /dev/null +++ b/admin/views/totp-setup.html @@ -0,0 +1,20 @@ +{{define "head"}} +TOTP Setup - ari melody 💫 + + +{{end}} + +{{define "content"}} +
    + {{if .Session.Error.Valid}} +

    {{html .Session.Error.String}}

    + {{end}} + +
    + + + + +
    +
    +{{end}} diff --git a/api/api.go b/api/api.go index 4757a42..50b1c63 100644 --- a/api/api.go +++ b/api/api.go @@ -1,23 +1,27 @@ package api import ( + "context" + "errors" "fmt" "net/http" + "os" "strings" - "arimelody-web/admin" - "arimelody-web/global" "arimelody-web/controller" + "arimelody-web/model" ) -func Handler() http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() + // TODO: generate API keys on the frontend + // ARTIST ENDPOINTS mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artistID = strings.Split(r.URL.Path[1:], "/")[0] - artist, err := controller.GetArtist(global.DB, artistID) + artist, err := controller.GetArtist(app.DB, artistID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -31,13 +35,13 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist/{id} - ServeArtist(artist).ServeHTTP(w, r) + ServeArtist(app, artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - admin.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r) + requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r) + requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -46,10 +50,10 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist - ServeAllArtists().ServeHTTP(w, r) + ServeAllArtists(app).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r) + requireAccount(app, CreateArtist(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -59,7 +63,7 @@ func Handler() http.Handler { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var releaseID = strings.Split(r.URL.Path[1:], "/")[0] - release, err := controller.GetRelease(global.DB, releaseID, true) + release, err := controller.GetRelease(app.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -73,13 +77,13 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music/{id} - ServeRelease(release).ServeHTTP(w, r) + ServeRelease(app, release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) + requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r) + requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -88,10 +92,10 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music - ServeCatalog().ServeHTTP(w, r) + ServeCatalog(app).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r) + requireAccount(app, CreateRelease(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -101,7 +105,7 @@ func Handler() http.Handler { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackID = strings.Split(r.URL.Path[1:], "/")[0] - track, err := controller.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(app.DB, trackID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -115,13 +119,13 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r) + requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r) + requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r) + requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -130,10 +134,10 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r) + requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r) + requireAccount(app, CreateTrack(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -141,3 +145,51 @@ func Handler() http.Handler { return mux } + +func requireAccount(app *model.AppState, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := getSession(app, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to get session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if session.Account == nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), "session", session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getSession(app *model.AppState, r *http.Request) (*model.Session, error) { + var token string + + // check cookies first + sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) + if err != nil && err != http.ErrNoCookie { + return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err)) + } + if sessionCookie != nil { + token = sessionCookie.Value + } else { + // check Authorization header + token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + } + + if token == "" { return nil, nil } + + // fetch existing session + session, err := controller.GetSession(app.DB, token) + + if err != nil && !strings.Contains(err.Error(), "no rows") { + return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err)) + } + + if session != nil { + // TODO: consider running security checks here (i.e. user agent mismatches) + } + + return session, nil +} diff --git a/api/artist.go b/api/artist.go index c793e23..51c9d62 100644 --- a/api/artist.go +++ b/api/artist.go @@ -10,18 +10,16 @@ import ( "strings" "time" - "arimelody-web/admin" - "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeAllArtists() http.Handler { +func ServeAllArtists(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} - artists, err := controller.GetAllArtists(global.DB) + artists, err := controller.GetAllArtists(app.DB) if err != nil { - fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) + fmt.Printf("WARN: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -36,7 +34,7 @@ func ServeAllArtists() http.Handler { }) } -func ServeArtist(artist *model.Artist) http.Handler { +func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type ( creditJSON struct { @@ -53,12 +51,12 @@ func ServeArtist(artist *model.Artist) http.Handler { } ) - show_hidden_releases := admin.GetSession(r) != nil + session := r.Context().Value("session").(*model.Session) + show_hidden_releases := session != nil && session.Account != nil - var dbCredits []*model.Credit - dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) + dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) if err != nil { - fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -88,7 +86,7 @@ func ServeArtist(artist *model.Artist) http.Handler { }) } -func CreateArtist() http.Handler { +func CreateArtist(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artist model.Artist err := json.NewDecoder(r.Body).Decode(&artist) @@ -103,13 +101,13 @@ func CreateArtist() http.Handler { } if artist.Name == "" { artist.Name = artist.ID } - err = controller.CreateArtist(global.DB, &artist) + err = controller.CreateArtist(app.DB, &artist) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) return } - fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -118,11 +116,11 @@ func CreateArtist() http.Handler { }) } -func UpdateArtist(artist *model.Artist) http.Handler { +func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := json.NewDecoder(r.Body).Decode(&artist) if err != nil { - fmt.Printf("FATAL: Failed to update artist: %s\n", err) + fmt.Printf("WARN: Failed to update artist: %s\n", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -132,7 +130,7 @@ func UpdateArtist(artist *model.Artist) http.Handler { } else { if strings.Contains(artist.Avatar, ";base64,") { var artworkDirectory = filepath.Join("uploads", "avatar") - filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID) + filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID) // clean up files with this ID and different extensions err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { @@ -151,27 +149,27 @@ func UpdateArtist(artist *model.Artist) http.Handler { } } - err = controller.UpdateArtist(global.DB, artist) + err = controller.UpdateArtist(app.DB, artist) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func DeleteArtist(artist *model.Artist) http.Handler { +func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteArtist(global.DB, artist.ID) + err := controller.DeleteArtist(app.DB, artist.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/release.go b/api/release.go index 2288153..4e7372f 100644 --- a/api/release.go +++ b/api/release.go @@ -10,19 +10,25 @@ import ( "strings" "time" - "arimelody-web/admin" - "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeRelease(release *model.Release) http.Handler { +func ServeRelease(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return + privileged := false + if !release.Visible { + session := r.Context().Value("session").(*model.Session) + if session != nil && session.Account != nil { + // TODO: check privilege on release + privileged = true + } + + if !privileged { + http.NotFound(w, r) + return + } } type ( @@ -53,18 +59,18 @@ func ServeRelease(release *model.Release) http.Handler { Links: make(map[string]string), } - if authorised || release.IsReleased() { + if release.IsReleased() || privileged { // get credits - credits, err := controller.GetReleaseCredits(global.DB, release.ID) + credits, err := controller.GetReleaseCredits(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } for _, credit := range credits { - artist, err := controller.GetArtist(global.DB, credit.Artist.ID) + artist, err := controller.GetArtist(app.DB, credit.Artist.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -77,9 +83,9 @@ func ServeRelease(release *model.Release) http.Handler { } // get tracks - tracks, err := controller.GetReleaseTracks(global.DB, release.ID) + tracks, err := controller.GetReleaseTracks(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -92,9 +98,9 @@ func ServeRelease(release *model.Release) http.Handler { } // get links - links, err := controller.GetReleaseLinks(global.DB, release.ID) + links, err := controller.GetReleaseLinks(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -114,9 +120,9 @@ func ServeRelease(release *model.Release) http.Handler { }) } -func ServeCatalog() http.Handler { +func ServeCatalog(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(global.DB, false, 0, true) + releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -134,11 +140,19 @@ func ServeCatalog() http.Handler { } catalog := []Release{} - authorised := admin.GetSession(r) != nil + session := r.Context().Value("session").(*model.Session) for _, release := range releases { - if !release.Visible && !authorised { - continue + if !release.Visible { + privileged := false + if session != nil && session.Account != nil { + // TODO: check privilege on release + privileged = true + } + if !privileged { + continue + } } + artists := []string{} for _, credit := range release.Credits { if !credit.Primary { continue } @@ -167,7 +181,7 @@ func ServeCatalog() http.Handler { }) } -func CreateRelease() http.Handler { +func CreateRelease(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -195,13 +209,13 @@ func CreateRelease() http.Handler { if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } - err = controller.CreateRelease(global.DB, &release) + err = controller.CreateRelease(app.DB, &release) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) return } - fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -218,7 +232,7 @@ func CreateRelease() http.Handler { }) } -func UpdateRelease(release *model.Release) http.Handler { +func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.NotFound(w, r) @@ -230,11 +244,11 @@ func UpdateRelease(release *model.Release) http.Handler { if len(segments) == 2 { switch segments[1] { case "tracks": - UpdateReleaseTracks(release).ServeHTTP(w, r) + UpdateReleaseTracks(app, release).ServeHTTP(w, r) case "credits": - UpdateReleaseCredits(release).ServeHTTP(w, r) + UpdateReleaseCredits(app, release).ServeHTTP(w, r) case "links": - UpdateReleaseLinks(release).ServeHTTP(w, r) + UpdateReleaseLinks(app, release).ServeHTTP(w, r) } return } @@ -256,7 +270,7 @@ func UpdateRelease(release *model.Release) http.Handler { } else { if strings.Contains(release.Artwork, ";base64,") { var artworkDirectory = filepath.Join("uploads", "musicart") - filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID) + filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID) // clean up files with this ID and different extensions err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { @@ -275,19 +289,19 @@ func UpdateRelease(release *model.Release) http.Handler { } } - err = controller.UpdateRelease(global.DB, release) + err = controller.UpdateRelease(app.DB, release) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseTracks(release *model.Release) http.Handler { +func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackIDs = []string{} err := json.NewDecoder(r.Body).Decode(&trackIDs) @@ -296,19 +310,19 @@ func UpdateReleaseTracks(release *model.Release) http.Handler { return } - err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs) + err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseCredits(release *model.Release) http.Handler { +func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type creditJSON struct { Artist string @@ -333,7 +347,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler { }) } - err = controller.UpdateReleaseCredits(global.DB, release.ID, credits) + err = controller.UpdateReleaseCredits(app.DB, release.ID, credits) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest) @@ -343,13 +357,13 @@ func UpdateReleaseCredits(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseLinks(release *model.Release) http.Handler { +func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.NotFound(w, r) @@ -363,27 +377,27 @@ func UpdateReleaseLinks(release *model.Release) http.Handler { return } - err = controller.UpdateReleaseLinks(global.DB, release.ID, links) + err = controller.UpdateReleaseLinks(app.DB, release.ID, links) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func DeleteRelease(release *model.Release) http.Handler { +func DeleteRelease(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteRelease(global.DB, release.ID) + err := controller.DeleteRelease(app.DB, release.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/track.go b/api/track.go index f6d5578..c342e08 100644 --- a/api/track.go +++ b/api/track.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" - "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) @@ -17,7 +16,7 @@ type ( } ) -func ServeAllTracks() http.Handler { +func ServeAllTracks(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type Track struct { ID string `json:"id"` @@ -26,9 +25,9 @@ func ServeAllTracks() http.Handler { var tracks = []Track{} var dbTracks = []*model.Track{} - dbTracks, err := controller.GetAllTracks(global.DB) + dbTracks, err := controller.GetAllTracks(app.DB) if err != nil { - fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) + fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -44,17 +43,17 @@ func ServeAllTracks() http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(tracks) if err != nil { - fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err) + fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func ServeTrack(track *model.Track) http.Handler { +func ServeTrack(app *model.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) + dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) if err != nil { - fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err) + 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) } @@ -68,13 +67,13 @@ func ServeTrack(track *model.Track) http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(Track{ track, releases }) if err != nil { - fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err) + fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func CreateTrack() http.Handler { +func CreateTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -93,9 +92,9 @@ func CreateTrack() http.Handler { return } - id, err := controller.CreateTrack(global.DB, &track) + id, err := controller.CreateTrack(app.DB, &track) if err != nil { - fmt.Printf("FATAL: Failed to create track: %s\n", err) + fmt.Printf("WARN: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -106,7 +105,7 @@ func CreateTrack() http.Handler { }) } -func UpdateTrack(track *model.Track) http.Handler { +func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || r.URL.Path == "/" { http.NotFound(w, r) @@ -124,9 +123,9 @@ func UpdateTrack(track *model.Track) http.Handler { return } - err = controller.UpdateTrack(global.DB, track) + err = controller.UpdateTrack(app.DB, track) if err != nil { - fmt.Printf("Failed to update track %s: %s\n", track.ID, err) + fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -141,7 +140,7 @@ func UpdateTrack(track *model.Track) http.Handler { }) } -func DeleteTrack(track *model.Track) http.Handler { +func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path == "/" { http.NotFound(w, r) @@ -149,9 +148,9 @@ func DeleteTrack(track *model.Track) http.Handler { } var trackID = r.URL.Path[1:] - err := controller.DeleteTrack(global.DB, trackID) + err := controller.DeleteTrack(app.DB, trackID) if err != nil { - fmt.Printf("Failed to delete track %s: %s\n", trackID, err) + fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/uploads.go b/api/uploads.go index 6b1c496..ddcf6ee 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,7 +1,7 @@ package api import ( - "arimelody-web/global" + "arimelody-web/model" "bufio" "encoding/base64" "errors" @@ -11,12 +11,12 @@ import ( "strings" ) -func HandleImageUpload(data *string, directory string, filename string) (string, error) { +func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { split := strings.Split(*data, ";base64,") header := split[0] imageData, err := base64.StdEncoding.DecodeString(split[1]) ext, _ := strings.CutPrefix(header, "data:image/") - directory = filepath.Join(global.Config.DataDirectory, directory) + directory = filepath.Join(app.Config.DataDirectory, directory) switch ext { case "png": diff --git a/controller/account.go b/controller/account.go new file mode 100644 index 0000000..0cf3364 --- /dev/null +++ b/controller/account.go @@ -0,0 +1,126 @@ +package controller + +import ( + "arimelody-web/model" + "net/http" + "strings" + + "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 GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) { + var account = model.Account{} + + err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) { + var account = model.Account{} + + err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) { + var account = model.Account{} + + err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) { + if sessionToken == "" { return nil, nil } + + account := model.Account{} + + err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func GetSessionFromRequest(db *sqlx.DB, r *http.Request) string { + tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if len(tokenStr) > 0 { + return tokenStr + } + + cookie, err := r.Cookie(model.COOKIE_TOKEN) + if err != nil { + return "" + } + return cookie.Value +} + +func CreateAccount(db *sqlx.DB, account *model.Account) error { + err := db.Get( + &account.ID, + "INSERT INTO account (username, password, email, avatar_url) " + + "VALUES ($1, $2, $3, $4) " + + "RETURNING id", + account.Username, + account.Password, + account.Email, + account.AvatarURL, + ) + + return err +} + +func UpdateAccount(db *sqlx.DB, account *model.Account) error { + _, err := db.Exec( + "UPDATE account " + + "SET username=$2,password=$3,email=$4,avatar_url=$5 " + + "WHERE id=$1", + account.ID, + account.Username, + account.Password, + account.Email, + account.AvatarURL, + ) + + return err +} + +func DeleteAccount(db *sqlx.DB, accountID string) error { + _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) + return err +} diff --git a/controller/artist.go b/controller/artist.go index c52b78d..1a613aa 100644 --- a/controller/artist.go +++ b/controller/artist.go @@ -2,6 +2,7 @@ package controller import ( "arimelody-web/model" + "github.com/jmoiron/sqlx" ) diff --git a/global/config.go b/controller/config.go similarity index 51% rename from global/config.go rename to controller/config.go index d8c9f3d..8772b6b 100644 --- a/global/config.go +++ b/controller/config.go @@ -1,48 +1,32 @@ -package global +package controller import ( "errors" "fmt" "os" "strconv" - "strings" - "github.com/jmoiron/sqlx" + "arimelody-web/model" + "github.com/pelletier/go-toml/v2" ) -type ( - dbConfig struct { - Host string `toml:"host"` - Name string `toml:"name"` - User string `toml:"user"` - Pass string `toml:"pass"` - } - - discordConfig struct { - AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."` - ClientID string `toml:"client_id"` - Secret string `toml:"secret"` - } - - config struct { - BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` - Port int64 `toml:"port"` - DataDirectory string `toml:"data_dir"` - DB dbConfig `toml:"db"` - Discord discordConfig `toml:"discord"` - } -) - -var Config = func() config { +func GetConfig() model.Config { configFile := os.Getenv("ARIMELODY_CONFIG") if configFile == "" { configFile = "config.toml" } - config := config{ + config := model.Config{ BaseUrl: "https://arimelody.me", + Host: "0.0.0.0", Port: 8080, + DB: model.DBConfig{ + Host: "127.0.0.1", + Port: 5432, + User: "arimelody", + Name: "arimelody", + }, } data, err := os.ReadFile(configFile) @@ -57,23 +41,22 @@ var Config = func() config { err = toml.Unmarshal([]byte(data), &config) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) - os.Exit(1) + panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err)) } err = handleConfigOverrides(&config) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) - os.Exit(1) + panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err)) } return config -}() +} -func handleConfigOverrides(config *config) error { +func handleConfigOverrides(config *model.Config) error { var err error if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } + if env, has := os.LookupEnv("ARIMELODY_HOST"); has { config.Host = env } if env, has := os.LookupEnv("ARIMELODY_PORT"); has { config.Port, err = strconv.ParseInt(env, 10, 0) if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) } @@ -81,6 +64,10 @@ func handleConfigOverrides(config *config) error { if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env } if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env } + if env, has := os.LookupEnv("ARIMELODY_DB_PORT"); has { + config.DB.Port, err = strconv.ParseInt(env, 10, 0) + if err != nil { return errors.New("ARIMELODY_DB_PORT: " + err.Error()) } + } if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env } if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env } if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env } @@ -91,31 +78,3 @@ func handleConfigOverrides(config *config) error { return nil } - -var Args = func() map[string]string { - args := map[string]string{} - - index := 0 - for index < len(os.Args[1:]) { - arg := os.Args[index + 1] - if !strings.HasPrefix(arg, "-") { - fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg) - os.Exit(1) - } - - if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") { - args[arg[1:]] = "true" - index += 1 - continue - } - - val := os.Args[index + 2] - args[arg[1:]] = val - // fmt.Printf("%s: %s\n", arg[1:], val) - index += 2 - } - - return args -}() - -var DB *sqlx.DB diff --git a/controller/controller.go b/controller/controller.go new file mode 100644 index 0000000..44194e4 --- /dev/null +++ b/controller/controller.go @@ -0,0 +1,13 @@ +package controller + +import "math/rand" + +func GenerateAlnumString(length int) []byte { + const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + res := []byte{} + for i := 0; i < length; i++ { + res = append(res, CHARS[rand.Intn(len(CHARS))]) + } + return res +} + diff --git a/controller/invite.go b/controller/invite.go new file mode 100644 index 0000000..f30db64 --- /dev/null +++ b/controller/invite.go @@ -0,0 +1,67 @@ +package controller + +import ( + "arimelody-web/model" + "math/rand" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) { + invite := model.Invite{} + + err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &invite, nil +} + +func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) { + invite := model.Invite{ + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(lifetime), + } + + code := []byte{} + for i := 0; i < length; i++ { + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) + } + invite.Code = string(code) + + _, err := db.Exec( + "INSERT INTO invite (code, created_at, expires_at) " + + "VALUES ($1, $2, $3)", + invite.Code, + invite.CreatedAt, + invite.ExpiresAt, + ) + if err != nil { + return nil, err + } + + return &invite, nil +} + +func DeleteInvite(db *sqlx.DB, code string) error { + _, err := db.Exec("DELETE FROM invite WHERE code=$1", code) + return err +} + +func DeleteAllInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite") + return err +} + +func DeleteExpiredInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite WHERE expires_at 0 { + err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version") + if err != nil { panic(err) } + } + + for oldDBVersion < DB_VERSION { + switch oldDBVersion { + case 0: + // default case; assume no database exists + ApplyMigration(db, "000-init") + oldDBVersion = DB_VERSION + + case 1: + // the irony is i actually have to awkwardly shove schema_version + // into the old database in order for this to work LOL + ApplyMigration(db, "001-pre-versioning") + oldDBVersion = 2 + + } + } + + fmt.Printf("Database schema up to date.\n") +} + +func ApplyMigration(db *sqlx.DB, scriptFile string) { + fmt.Printf("Applying schema migration %s...\n", scriptFile) + + bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql") + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err) + os.Exit(1) + } + script := string(bytes) + + tx, err := db.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to begin migration: %v\n", err) + os.Exit(1) + } + + _, err = tx.Exec(script) + if err != nil { + tx.Rollback() + fmt.Fprintf(os.Stderr, "FATAL: Failed to apply migration: %v\n", err) + os.Exit(1) + } + + _, err = tx.Exec( + "INSERT INTO schema_version (version, applied_at) " + + "VALUES ($1, $2)", + DB_VERSION, + time.Now(), + ) + if err != nil { + tx.Rollback() + fmt.Fprintf(os.Stderr, "FATAL: Failed to update schema version: %v\n", err) + os.Exit(1) + } + + err = tx.Commit() + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to commit transaction: %v\n", err) + os.Exit(1) + } +} diff --git a/controller/qr.go b/controller/qr.go new file mode 100644 index 0000000..7ada0f8 --- /dev/null +++ b/controller/qr.go @@ -0,0 +1,120 @@ +package controller + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/png" + + "github.com/skip2/go-qrcode" +) + +func GenerateQRCode(data string) (string, error) { + imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256) + if err != nil { + return "", err + } + base64Img := base64.StdEncoding.EncodeToString(imgBytes) + return base64Img, nil +} + +// vvv DEPRECATED vvv + +const margin = 4 + +type QRCodeECCLevel int64 +const ( + LOW QRCodeECCLevel = iota + MEDIUM + QUARTILE + HIGH +) + +func noDepsGenerateQRCode() (string, error) { + version := 1 + + size := 0 + size = 21 + version * 4 + if version > 10 { + return "", errors.New(fmt.Sprintf("QR version %d not supported", version)) + } + + img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2)) + + // fill white + for y := range size + margin * 2 { + for x := range size + margin * 2 { + img.Set(x, y, color.White) + } + } + + // draw alignment squares + drawLargeAlignmentSquare(margin, margin, img) + drawLargeAlignmentSquare(margin, margin + size - 7, img) + drawLargeAlignmentSquare(margin + size - 7, margin, img) + drawSmallAlignmentSquare(size - 5, size - 5, img) + /* + if version > 4 { + space := version * 3 - 2 + end := size / space + for y := range size / space + 1 { + for x := range size / space + 1 { + if x == 0 && y == 0 { continue } + if x == 0 && y == end { continue } + if x == end && y == 0 { continue } + if x == end && y == end { continue } + drawSmallAlignmentSquare( + x * space + margin + 4, + y * space + margin + 4, + img, + ) + } + } + } + */ + + // draw timing bits + for i := margin + 6; i < size - 4; i++ { + if (i % 2 == 0) { + img.Set(i, margin + 6, color.Black) + img.Set(margin + 6, i, color.Black) + } + } + img.Set(margin + 8, size - 4, color.Black) + + var imgBuf bytes.Buffer + err := png.Encode(&imgBuf, img) + if err != nil { + return "", err + } + + base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes()) + + return "data:image/png;base64," + base64Img, nil +} + +func drawLargeAlignmentSquare(x int, y int, img *image.Gray) { + for yi := range 7 { + for xi := range 7 { + if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) { + img.Set(x + xi, y + yi, color.Black) + } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) { + img.Set(x + xi, y + yi, color.Black) + } + } + } +} + +func drawSmallAlignmentSquare(x int, y int, img *image.Gray) { + for yi := range 5 { + for xi := range 5 { + if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) { + img.Set(x + xi, y + yi, color.Black) + } + } + } + img.Set(x + 2, y + 2, color.Black) +} diff --git a/controller/release.go b/controller/release.go index e55fef0..362669a 100644 --- a/controller/release.go +++ b/controller/release.go @@ -5,6 +5,7 @@ import ( "fmt" "arimelody-web/model" + "github.com/jmoiron/sqlx" ) @@ -223,7 +224,6 @@ func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link) return err } for _, link := range new_links { - fmt.Printf("%s: %s\n", link.Name, link.URL) _, err := tx.Exec( "INSERT INTO musiclink "+ "(release, name, url) "+ diff --git a/controller/session.go b/controller/session.go new file mode 100644 index 0000000..c9c4cbb --- /dev/null +++ b/controller/session.go @@ -0,0 +1,130 @@ +package controller + +import ( + "database/sql" + "time" + + "arimelody-web/model" + + "github.com/jmoiron/sqlx" +) + +const TOKEN_LEN = 64 + +func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) { + tokenString := GenerateAlnumString(TOKEN_LEN) + + session := model.Session{ + Token: string(tokenString), + UserAgent: userAgent, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour * 24), + } + + _, err := db.Exec("INSERT INTO session " + + "(token, user_agent, created_at, expires_at) VALUES " + + "($1, $2, $3, $4)", + session.Token, + session.UserAgent, + session.CreatedAt, + session.ExpiresAt, + ) + if err != nil { + return nil, err + } + + return &session, nil +} + +// func WriteSession(db *sqlx.DB, session *model.Session) error { +// _, err := db.Exec( +// "UPDATE session " + +// "SET account=$2,message=$3,error=$4 " + +// "WHERE token=$1", +// session.Token, +// session.Account.ID, +// session.Message, +// session.Error, +// ) +// return err +// } + +func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error { + var err error + session.Account = account + if account == nil { + _, err = db.Exec("UPDATE session SET account=NULL WHERE token=$1", session.Token) + } else { + _, err = db.Exec("UPDATE session SET account=$2 WHERE token=$1", session.Token, account.ID) + } + return err +} + +func SetSessionMessage(db *sqlx.DB, session *model.Session, message string) error { + var err error + if message == "" { + if !session.Message.Valid { return nil } + session.Message = sql.NullString{ } + _, err = db.Exec("UPDATE session SET message=NULL WHERE token=$1", session.Token) + } else { + session.Message = sql.NullString{ String: message, Valid: true } + _, err = db.Exec("UPDATE session SET message=$2 WHERE token=$1", session.Token, message) + } + return err +} + +func SetSessionError(db *sqlx.DB, session *model.Session, message string) error { + var err error + if message == "" { + if !session.Error.Valid { return nil } + session.Error = sql.NullString{ } + _, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token) + } else { + session.Error = sql.NullString{ String: message, Valid: true } + _, err = db.Exec("UPDATE session SET error=$2 WHERE token=$1", session.Token, message) + } + return err +} + +func GetSession(db *sqlx.DB, token string) (*model.Session, error) { + type dbSession struct { + model.Session + AccountID sql.NullString `db:"account"` + } + + session := dbSession{} + err := db.Get( + &session, + "SELECT * FROM session WHERE token=$1", + token, + ) + if err != nil { + return nil, err + } + + if session.AccountID.Valid { + session.Account, err = GetAccountByID(db, session.AccountID.String) + if err != nil { + return nil, err + } + } + + return &session.Session, err +} + +// func GetAllSessionsForAccount(db *sqlx.DB, accountID string) ([]model.Session, error) { +// sessions := []model.Session{} +// err := db.Select(&sessions, "SELECT * FROM session WHERE account=$1 AND expires_at>current_timestamp", accountID) +// return sessions, err +// } + +func DeleteAllSessionsForAccount(db *sqlx.DB, accountID string) error { + _, err := db.Exec("DELETE FROM session WHERE account=$1", accountID) + return err +} + +func DeleteSession(db *sqlx.DB, token string) error { + _, err := db.Exec("DELETE FROM session WHERE token=$1", token) + return err +} + diff --git a/controller/totp.go b/controller/totp.go new file mode 100644 index 0000000..88f6bc3 --- /dev/null +++ b/controller/totp.go @@ -0,0 +1,165 @@ +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 TOTP_TIME_STEP int64 = 30 +const TOTP_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() / TOTP_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(TOTP_CODE_LENGTH)) + + return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_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", TOTP_CODE_LENGTH)) + // query.Set("period", fmt.Sprintf("%d", TOTP_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 AND confirmed=true " + + "ORDER BY created_at ASC", + accountID, + ) + if err != nil { + return nil, err + } + + return totps, nil +} + +func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) { + totps, err := GetTOTPsForAccount(db, accountID) + if err != nil { + return nil, err + } + for _, method := range totps { + check := GenerateTOTP(method.Secret, 0) + if check == totp { + return &method, nil + } + // try again with offset- maybe user input the code late? + check = GenerateTOTP(method.Secret, 1) + if check == totp { + return &method, nil + } + } + // user failed all TOTP checks + // note: this state will still occur even if the account has no TOTP methods. + return nil, 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 AND name=$2", + accountID, + name, + ) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &totp, nil +} + +func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error { + _, err := db.Exec( + "UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2", + accountID, + name, + ) + return err +} + +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 +} + +func DeleteUnconfirmedTOTPs(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM totp WHERE confirmed=false") + return err +} diff --git a/controller/track.go b/controller/track.go index d302045..fa4efc1 100644 --- a/controller/track.go +++ b/controller/track.go @@ -2,6 +2,7 @@ package controller import ( "arimelody-web/model" + "github.com/jmoiron/sqlx" ) diff --git a/discord/discord.go b/discord/discord.go index 27023e7..d46f32d 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -1,38 +1,17 @@ package discord import ( + "arimelody-web/model" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" - - "arimelody-web/global" ) const API_ENDPOINT = "https://discord.com/api/v10" -var CREDENTIALS_PROVIDED = true -var CLIENT_ID = func() string { - id := global.Config.Discord.ClientID - if id == "" { - fmt.Printf("WARN: discord.client_id was not provided. Admin login will be unavailable.\n") - CREDENTIALS_PROVIDED = false - } - return id -}() -var CLIENT_SECRET = func() string { - secret := global.Config.Discord.Secret - if secret == "" { - fmt.Printf("WARN: discord.secret not provided. Admin login will be unavailable.\n") - CREDENTIALS_PROVIDED = false - } - return secret -}() -var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.Config.BaseUrl) -var REDIRECT_URI = fmt.Sprintf("https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", CLIENT_ID, OAUTH_CALLBACK_URI) - type ( AccessTokenResponse struct { AccessToken string `json:"access_token"` @@ -68,15 +47,15 @@ type ( } ) -func GetOAuthTokenFromCode(code string) (string, error) { +func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) { // let's get an oauth token! req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT), strings.NewReader(url.Values{ - "client_id": {CLIENT_ID}, - "client_secret": {CLIENT_SECRET}, + "client_id": {app.Config.Discord.ClientID}, + "client_secret": {app.Config.Discord.Secret}, "grant_type": {"authorization_code"}, "code": {code}, - "redirect_uri": {OAUTH_CALLBACK_URI}, + "redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)}, }.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -115,3 +94,15 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) { return auth_info.User, nil } + +func GetOAuthCallbackURI(baseURL string) string { + return fmt.Sprintf("%s/admin/login", baseURL) +} + +func GetRedirectURI(app *model.AppState) string { + return fmt.Sprintf( + "https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", + app.Config.Discord.ClientID, + GetOAuthCallbackURI(app.Config.BaseUrl), + ) +} diff --git a/global/funcs.go b/global/funcs.go deleted file mode 100644 index c0462db..0000000 --- a/global/funcs.go +++ /dev/null @@ -1,102 +0,0 @@ -package global - -import ( - "fmt" - "math/rand" - "net/http" - "strconv" - "time" - - "arimelody-web/colour" -) - -var PoweredByStrings = []string{ - "nerd rage", - "estrogen", - "your mother", - "awesome powers beyond comprehension", - "jared", - "the weight of my sins", - "the arc reactor", - "AA batteries", - "15 euro solar panel from ebay", - "magnets, how do they work", - "a fax machine", - "dell optiplex", - "a trans girl's nintendo wii", - "BASS", - "electricity, duh", - "seven hamsters in a big wheel", - "girls", - "mzungu hosting", - "golang", - "the state of the world right now", - "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", - "the good folks at aperture science", - "free2play CDs", - "aridoodle", - "the love of creating", - "not for the sake of art; not for the sake of money; we like painting naked people", - "30 billion dollars in VC funding", -} - -func DefaultHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Server", "arimelody.me") - w.Header().Add("Do-Not-Stab", "1") - w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett") - w.Header().Add("X-Hacker", "spare me please") - w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;") - w.Header().Add("X-Thinking-With", "Portals") - w.Header().Add( - "X-Powered-By", - PoweredByStrings[rand.Intn(len(PoweredByStrings))], - ) - next.ServeHTTP(w, r) - }) -} - -type LoggingResponseWriter struct { - http.ResponseWriter - Code int -} - -func (lrw *LoggingResponseWriter) WriteHeader(code int) { - lrw.Code = code - lrw.ResponseWriter.WriteHeader(code) -} - -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) - } - - codeColour := colour.Reset - - if lrw.Code - 600 <= 0 { codeColour = colour.Red } - if lrw.Code - 500 <= 0 { codeColour = colour.Yellow } - if lrw.Code - 400 <= 0 { codeColour = colour.White } - if lrw.Code - 300 <= 0 { codeColour = colour.Green } - - fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", - after.Format(time.UnixDate), - r.Method, - r.URL.Path, - codeColour, - lrw.Code, - colour.Reset, - elapsed, - r.Header["User-Agent"][0]) - }) -} - diff --git a/go.mod b/go.mod index 8d116d4..f8717a2 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,9 @@ require ( github.com/lib/pq v1.10.9 ) -require github.com/pelletier/go-toml/v2 v2.2.3 // indirect +require golang.org/x/crypto v0.27.0 // indirect + +require ( + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect +) diff --git a/go.sum b/go.sum index 37a12d4..15259a1 100644 --- a/go.sum +++ b/go.sum @@ -10,3 +10,7 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= diff --git a/main.go b/main.go index 3076623..7e8e06f 100644 --- a/main.go +++ b/main.go @@ -4,82 +4,416 @@ import ( "errors" "fmt" "log" + "math" + "math/rand" "net/http" "os" "path/filepath" + "strconv" + "strings" "time" "arimelody-web/admin" "arimelody-web/api" - "arimelody-web/global" + "arimelody-web/colour" + "arimelody-web/controller" + "arimelody-web/model" "arimelody-web/templates" "arimelody-web/view" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" + "golang.org/x/crypto/bcrypt" ) +// used for database migrations +const DB_VERSION = 1 + const DEFAULT_PORT int64 = 8080 func main() { + fmt.Printf("made with <3 by ari melody\n\n") + + app := model.AppState{ + Config: controller.GetConfig(), + } + // initialise database connection - if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } - if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } - if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env } - if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env } - if global.Config.DB.Host == "" { + if app.Config.DB.Host == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) } - if global.Config.DB.Name == "" { + if app.Config.DB.Name == "" { fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") os.Exit(1) } - if global.Config.DB.User == "" { + if app.Config.DB.User == "" { fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") os.Exit(1) } - if global.Config.DB.Pass == "" { + if app.Config.DB.Pass == "" { fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") os.Exit(1) } var err error - global.DB, err = sqlx.Connect( + app.DB, err = sqlx.Connect( "postgres", fmt.Sprintf( - "host=%s user=%s dbname=%s password='%s' sslmode=disable", - global.Config.DB.Host, - global.Config.DB.User, - global.Config.DB.Name, - global.Config.DB.Pass, + "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", + app.Config.DB.Host, + app.Config.DB.Port, + app.Config.DB.User, + app.Config.DB.Name, + app.Config.DB.Pass, ), ) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err) + 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() + + // 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] + + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) + totp := model.TOTP { + AccountID: account.ID, + Name: totpName, + Secret: string(secret), + } + + err = controller.CreateTOTP(app.DB, &totp) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "FATAL: 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.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: 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, "FATAL: 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.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: 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, "FATAL: 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.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: 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, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) + os.Exit(1) + } + + if totp == nil { + fmt.Fprintf(os.Stderr, "FATAL: 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 "cleanTOTP": + err := controller.DeleteUnconfirmedTOTPs(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err) + os.Exit(1) + } + fmt.Printf("Cleaned up dangling TOTP methods successfully.\n") + return + + case "createInvite": + fmt.Printf("Creating invite...\n") + invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) + os.Exit(1) + } + + fmt.Printf( + "Here you go! This code expires in %d hours: %s\n", + int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())), + invite.Code, + ) + return + + case "purgeInvites": + fmt.Printf("Deleting all invites...\n") + err := controller.DeleteAllInvites(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Invites deleted successfully.\n") + return + + case "listAccounts": + accounts, err := controller.GetAllAccounts(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err) + os.Exit(1) + } + + for _, account := range accounts { + email := "" + if account.Email.Valid { email = account.Email.String } + fmt.Printf( + "User: %s\n" + + "\tID: %s\n" + + "\tEmail: %s\n" + + "\tCreated: %s\n", + account.Username, + account.ID, + email, + account.CreatedAt, + ) + } + return + + case "changePassword": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n") + os.Exit(1) + } + + username := os.Args[2] + password := os.Args[3] + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) + os.Exit(1) + } + account.Password = string(hashedPassword) + err = controller.UpdateAccount(app.DB, account) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) + return + + case "deleteAccount": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") + os.Exit(1) + } + username := os.Args[2] + fmt.Printf("Deleting account \"%s\"...\n", username) + + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) + res := "" + fmt.Scanln(&res) + if !strings.HasPrefix(res, "y") { + return + } + + err = controller.DeleteAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) + return + + } + + // command help + fmt.Print( + "Available commands:\n\n" + + "createTOTP :\n\tCreates a timed one-time passcode method.\n" + + "listTOTP :\n\tLists an account's TOTP methods.\n" + + "deleteTOTP :\n\tDeletes an account's TOTP method.\n" + + "testTOTP :\n\tGenerates the code for an account's TOTP method.\n" + + "cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\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) + + // initial invite code + accountsCount := 0 + err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") + if err != nil { panic(err) } + if accountsCount == 0 { + _, err := app.DB.Exec("DELETE FROM invite") + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) + os.Exit(1) + } + + invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) + os.Exit(1) + } + + fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) + } + + // delete expired invites + err = controller.DeleteExpiredInvites(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) + os.Exit(1) + } + + // clean up unconfirmed TOTP methods + err = controller.DeleteUnconfirmedTOTPs(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err) os.Exit(1) } - global.DB.SetConnMaxLifetime(time.Minute * 3) - global.DB.SetMaxOpenConns(10) - global.DB.SetMaxIdleConns(10) - defer global.DB.Close() // start the web server! - mux := createServeMux() - fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port) + mux := createServeMux(&app) + fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) log.Fatal( - http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), - global.HTTPLog(global.DefaultHeaders(mux)), + http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), + HTTPLog(DefaultHeaders(mux)), )) } -func createServeMux() *http.ServeMux { +func createServeMux(app *model.AppState) *http.ServeMux { mux := http.NewServeMux() - - mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) - mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) - mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads")))) + + mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) + mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) + mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) + mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -87,7 +421,7 @@ func createServeMux() *http.ServeMux { } if r.URL.Path == "/" || r.URL.Path == "/index.html" { - err := templates.Pages["index"].Execute(w, nil) + err := templates.IndexTemplate.Execute(w, nil) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -95,7 +429,7 @@ func createServeMux() *http.ServeMux { } staticHandler("public").ServeHTTP(w, r) })) - + return mux } @@ -120,3 +454,93 @@ func staticHandler(directory string) http.Handler { http.FileServer(http.Dir(directory)).ServeHTTP(w, r) }) } + +var PoweredByStrings = []string{ + "nerd rage", + "estrogen", + "your mother", + "awesome powers beyond comprehension", + "jared", + "the weight of my sins", + "the arc reactor", + "AA batteries", + "15 euro solar panel from ebay", + "magnets, how do they work", + "a fax machine", + "dell optiplex", + "a trans girl's nintendo wii", + "BASS", + "electricity, duh", + "seven hamsters in a big wheel", + "girls", + "mzungu hosting", + "golang", + "the state of the world right now", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", + "the good folks at aperture science", + "free2play CDs", + "aridoodle", + "the love of creating", + "not for the sake of art; not for the sake of money; we like painting naked people", + "30 billion dollars in VC funding", +} + +func DefaultHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Server", "arimelody.me") + w.Header().Add("Do-Not-Stab", "1") + w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett") + w.Header().Add("X-Hacker", "spare me please") + w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;") + w.Header().Add("X-Thinking-With", "Portals") + w.Header().Add( + "X-Powered-By", + PoweredByStrings[rand.Intn(len(PoweredByStrings))], + ) + next.ServeHTTP(w, r) + }) +} + +type LoggingResponseWriter struct { + http.ResponseWriter + Status int +} + +func (lrw *LoggingResponseWriter) WriteHeader(status int) { + lrw.Status = status + lrw.ResponseWriter.WriteHeader(status) +} + +func HTTPLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + lrw := LoggingResponseWriter{w, http.StatusOK} + + next.ServeHTTP(&lrw, r) + + after := time.Now() + difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000 + elapsed := "<1" + if difference >= 1 { + elapsed = strconv.Itoa(difference) + } + + statusColour := colour.Reset + + if lrw.Status - 600 <= 0 { statusColour = colour.Red } + if lrw.Status - 500 <= 0 { statusColour = colour.Yellow } + if lrw.Status - 400 <= 0 { statusColour = colour.White } + if lrw.Status - 300 <= 0 { statusColour = colour.Green } + + fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", + after.Format(time.UnixDate), + r.Method, + r.URL.Path, + statusColour, + lrw.Status, + colour.Reset, + elapsed, + r.Header["User-Agent"][0]) + }) +} diff --git a/model/account.go b/model/account.go new file mode 100644 index 0000000..166e880 --- /dev/null +++ b/model/account.go @@ -0,0 +1,41 @@ +package model + +import ( + "database/sql" + "time" +) + +const COOKIE_TOKEN string = "AM_SESSION" + +type ( + Account struct { + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Password string `json:"password" db:"password"` + Email sql.NullString `json:"email" db:"email"` + AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + Privileges []AccountPrivilege `json:"privileges"` + } + + AccountPrivilege string +) + +const ( + Root AccountPrivilege = "root" // grants all permissions. very dangerous to grant! + + // unused for now + CreateInvites AccountPrivilege = "create_invites" + ReadAccounts AccountPrivilege = "read_accounts" + EditAccounts AccountPrivilege = "edit_accounts" + + ReadReleases AccountPrivilege = "read_releases" + EditReleases AccountPrivilege = "edit_releases" + + ReadTracks AccountPrivilege = "read_tracks" + EditTracks AccountPrivilege = "edit_tracks" + + ReadArtists AccountPrivilege = "read_artists" + EditArtists AccountPrivilege = "edit_artists" +) diff --git a/model/appstate.go b/model/appstate.go new file mode 100644 index 0000000..6a965d5 --- /dev/null +++ b/model/appstate.go @@ -0,0 +1,33 @@ +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."` + Host string `toml:"host"` + 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/invite.go b/model/invite.go new file mode 100644 index 0000000..b7a66ae --- /dev/null +++ b/model/invite.go @@ -0,0 +1,10 @@ +package model + +import "time" + +type Invite struct { + Code string `db:"code"` + CreatedByID string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` +} diff --git a/model/session.go b/model/session.go new file mode 100644 index 0000000..c1983a1 --- /dev/null +++ b/model/session.go @@ -0,0 +1,17 @@ +package model + +import ( + "database/sql" + "time" +) + +type Session struct { + Token string `json:"token" db:"token"` + UserAgent string `json:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + + Account *Account `json:"-" db:"account"` + Message sql.NullString `json:"-" db:"message"` + Error sql.NullString `json:"-" db:"error"` +} diff --git a/model/totp.go b/model/totp.go new file mode 100644 index 0000000..cfad10a --- /dev/null +++ b/model/totp.go @@ -0,0 +1,13 @@ +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"` + Confirmed bool `json:"-" db:"confirmed"` +} diff --git a/model/track.go b/model/track.go index d44c224..ca54ddd 100644 --- a/model/track.go +++ b/model/track.go @@ -12,6 +12,8 @@ type ( Description string `json:"description"` Lyrics string `json:"lyrics" db:"lyrics"` PreviewURL string `json:"previewURL" db:"preview_url"` + + Number int } ) diff --git a/public/style/footer.css b/public/style/footer.css index 72cdebb..d9682b1 100644 --- a/public/style/footer.css +++ b/public/style/footer.css @@ -1,11 +1,11 @@ footer { - border-top: 1px solid #888; + border-top: 1px solid #8888; } #footer { - width: min(calc(100% - 4rem), 720px); - margin: auto; - padding: 2rem 0; - color: #aaa; + width: min(calc(100% - 4rem), 720px); + margin: auto; + padding: 2rem 0; + color: #aaa; } diff --git a/public/style/index.css b/public/style/index.css index 363c381..f3cb761 100644 --- a/public/style/index.css +++ b/public/style/index.css @@ -91,7 +91,7 @@ hr { text-align: center; line-height: 0px; border-width: 1px 0 0 0; - border-color: #888f; + border-color: #888; margin: 1.5em 0; overflow: visible; } diff --git a/schema.sql b/schema_migration/000-init.sql similarity index 59% rename from schema.sql rename to schema_migration/000-init.sql index 15957ad..8751180 100644 --- a/schema.sql +++ b/schema_migration/000-init.sql @@ -1,8 +1,58 @@ -CREATE SCHEMA arimelody AUTHORIZATION arimelody; +-- +-- Tables +-- + +-- Accounts +CREATE TABLE arimelody.account ( + id UUID DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT, + avatar_url TEXT, + created_at TIMESTAMP DEFAULT current_timestamp +); +ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); + +-- Privilege +CREATE TABLE arimelody.privilege ( + account UUID NOT NULL, + privilege TEXT NOT NULL +); +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); + +-- Invites +CREATE TABLE arimelody.invite ( + code text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP NOT NULL +); +ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); + +-- Sessions +CREATE TABLE arimelody.session ( + token TEXT, + user_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP DEFAULT NULL, + account UUID, + message TEXT, + error TEXT +); +ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token); + +-- TOTP methods +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp + confirmed BOOLEAN DEFAULT false, +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + + --- -- Artists (should be applicable to all art) --- CREATE TABLE arimelody.artist ( id character varying(64), name text NOT NULL, @@ -11,9 +61,7 @@ CREATE TABLE arimelody.artist ( ); ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); --- -- Music releases --- CREATE TABLE arimelody.musicrelease ( id character varying(64) NOT NULL, visible bool DEFAULT false, @@ -29,9 +77,7 @@ CREATE TABLE arimelody.musicrelease ( ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); --- -- Music links (external platform links under a release) --- CREATE TABLE arimelody.musiclink ( release character varying(64) NOT NULL, name text NOT NULL, @@ -39,9 +85,7 @@ CREATE TABLE arimelody.musiclink ( ); ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); --- -- Music credits (artist credits under a release) --- CREATE TABLE arimelody.musiccredit ( release character varying(64) NOT NULL, artist character varying(64) NOT NULL, @@ -50,9 +94,7 @@ CREATE TABLE arimelody.musiccredit ( ); ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); --- -- Music tracks (tracks under a release) --- CREATE TABLE arimelody.musictrack ( id uuid DEFAULT gen_random_uuid(), title text NOT NULL, @@ -62,9 +104,7 @@ CREATE TABLE arimelody.musictrack ( ); ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); --- -- Music release/track pairs --- CREATE TABLE arimelody.musicreleasetrack ( release character varying(64) NOT NULL, track uuid NOT NULL, @@ -72,9 +112,16 @@ CREATE TABLE arimelody.musicreleasetrack ( ); ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); + + -- -- Foreign keys -- + +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; + ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql new file mode 100644 index 0000000..37de432 --- /dev/null +++ b/schema_migration/001-pre-versioning.sql @@ -0,0 +1,56 @@ +-- +-- New items +-- + +-- Accounts +CREATE TABLE arimelody.account ( + id UUID DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT, + avatar_url TEXT, + created_at TIMESTAMP DEFAULT current_timestamp +); +ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); + +-- Privilege +CREATE TABLE arimelody.privilege ( + account UUID NOT NULL, + privilege TEXT NOT NULL +); +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); + +-- Invites +CREATE TABLE arimelody.invite ( + code text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP NOT NULL +); +ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); + +-- Sessions +CREATE TABLE arimelody.session ( + token TEXT, + user_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP DEFAULT NULL, + account UUID, + message TEXT, + error TEXT +); +ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token); + +-- TOTP methods +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp + confirmed BOOLEAN DEFAULT false, +); +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; diff --git a/templates/templates.go b/templates/templates.go index 094e61d..8d1a5ca 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -5,29 +5,24 @@ import ( "path/filepath" ) -var Pages = map[string]*template.Template{ - "index": template.Must(template.ParseFiles( - filepath.Join("views", "layout.html"), - filepath.Join("views", "header.html"), - filepath.Join("views", "footer.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("views", "index.html"), - )), - "music": template.Must(template.ParseFiles( - filepath.Join("views", "layout.html"), - filepath.Join("views", "header.html"), - filepath.Join("views", "footer.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("views", "music.html"), - )), - "music-gateway": template.Must(template.ParseFiles( - filepath.Join("views", "layout.html"), - filepath.Join("views", "header.html"), - filepath.Join("views", "footer.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("views", "music-gateway.html"), - )), -} - -var Components = map[string]*template.Template{ -} +var IndexTemplate = template.Must(template.ParseFiles( + filepath.Join("views", "layout.html"), + filepath.Join("views", "header.html"), + filepath.Join("views", "footer.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("views", "index.html"), +)) +var MusicTemplate = template.Must(template.ParseFiles( + filepath.Join("views", "layout.html"), + filepath.Join("views", "header.html"), + filepath.Join("views", "footer.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("views", "music.html"), +)) +var MusicGatewayTemplate = template.Must(template.ParseFiles( + filepath.Join("views", "layout.html"), + filepath.Join("views", "header.html"), + filepath.Join("views", "footer.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("views", "music-gateway.html"), +)) diff --git a/view/music.go b/view/music.go index f38e1c2..7b86270 100644 --- a/view/music.go +++ b/view/music.go @@ -4,39 +4,37 @@ import ( "fmt" "net/http" - "arimelody-web/admin" "arimelody-web/controller" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/templates" ) // HTTP HANDLER METHODS -func MusicHandler() http.Handler { +func MusicHandler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - ServeCatalog().ServeHTTP(w, r) + ServeCatalog(app).ServeHTTP(w, r) return } - release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true) + release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true) if err != nil { http.NotFound(w, r) return } - ServeGateway(release).ServeHTTP(w, r) + ServeGateway(app, release).ServeHTTP(w, r) })) return mux } -func ServeCatalog() http.Handler { +func ServeCatalog(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(global.DB, true, 0, true) + releases, err := controller.GetAllReleases(app.DB, true, 0, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -49,31 +47,39 @@ func ServeCatalog() http.Handler { } } - err = templates.Pages["music"].Execute(w, releases) + err = templates.MusicTemplate.Execute(w, releases) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func ServeGateway(release *model.Release) http.Handler { +func ServeGateway(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return + privileged := false + if !release.Visible { + session := r.Context().Value("session").(*model.Session) + if session != nil && session.Account != nil { + // TODO: check privilege on release + privileged = true + } + + if !privileged { + http.NotFound(w, r) + return + } } response := *release - if authorised || release.IsReleased() { + if release.IsReleased() || privileged { response.Tracks = release.Tracks response.Credits = release.Credits response.Links = release.Links } - err := templates.Pages["music-gateway"].Execute(w, response) + err := templates.MusicGatewayTemplate.Execute(w, response) if err != nil { fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)