diff --git a/.dockerignore b/.dockerignore index e2d5600..785d79e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,11 +3,10 @@ .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 9bdf788..cccde2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ uploads/ docker-compose*.yml !docker-compose.example.yml config*.toml -arimelody-web diff --git a/Dockerfile b/Dockerfile index 0e0d2a2..278f01a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web # --- -FROM scratch +FROM build-stage AS build-release-stage WORKDIR /app diff --git a/README.md b/README.md index 7e7860c..df0c351 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. 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. +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`). the configuration may be overridden using environment variables in the format `ARIMELODY__`. for example, `db.host` in the config may @@ -32,17 +32,8 @@ be overridden with `ARIMELODY_DB_HOST`. the location of the configuration file can also be overridden with `ARIMELODY_CONFIG`. -### command arguments +## database -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`. +the server requires a postgres database to run. you can use the +[schema.sql](schema.sql) provided in this repo to generate the required tables. +automatic schema building/migration may come in a future update. diff --git a/admin/accounthttp.go b/admin/accounthttp.go deleted file mode 100644 index 5e3f4b6..0000000 --- a/admin/accounthttp.go +++ /dev/null @@ -1,389 +0,0 @@ -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 new file mode 100644 index 0000000..4c07fa0 --- /dev/null +++ b/admin/admin.go @@ -0,0 +1,58 @@ +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 6dfbbfd..ba26240 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -5,15 +5,16 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveArtist(app *model.AppState) http.Handler { +func serveArtist() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - artist, err := controller.GetArtist(app.DB, id) + artist, err := controller.GetArtist(global.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -24,26 +25,19 @@ func serveArtist(app *model.AppState) http.Handler { return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - type ArtistResponse struct { - Session *model.Session - Artist *model.Artist - Credits []*model.Credit + type Artist struct { + *model.Artist + Credits []*model.Credit } - session := r.Context().Value("session").(*model.Session) - - err = artistTemplate.Execute(w, ArtistResponse{ - Session: session, - Artist: artist, - Credits: credits, - }) + err = pages["artist"].Execute(w, Artist{ 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 94dc268..999740a 100644 --- a/admin/components/credits/editcredits.html +++ b/admin/components/credits/editcredits.html @@ -52,6 +52,7 @@ 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 d03f80a..dba57a6 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,21 +3,21 @@

Editing: Tracks

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

    Delete
    diff --git a/admin/http.go b/admin/http.go index 6fd8f59..19afa92 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,7 +2,6 @@ package admin import ( "context" - "database/sql" "fmt" "net/http" "os" @@ -10,392 +9,229 @@ import ( "strings" "time" + "arimelody-web/discord" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" - - "golang.org/x/crypto/bcrypt" ) -func Handler(app *model.AppState) http.Handler { - mux := http.NewServeMux() - - 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("/", requireAccount(app, AdminIndexHandler(app))) - - // response wrapper to make sure a session cookie exists - return enforceSession(app, mux) +type loginData struct { + DiscordURI string + Token string } -func AdminIndexHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func Handler() http.Handler { + mux := http.NewServeMux() + + mux.Handle("/login", LoginHandler()) + mux.Handle("/logout", MustAuthorise(LogoutHandler())) + 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) { if r.URL.Path != "/" { http.NotFound(w, r) return } - session := r.Context().Value("session").(*model.Session) + session := GetSession(r) + if session == nil { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := controller.GetAllReleases(global.DB, false, 0, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) + fmt.Printf("FATAL: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(app.DB) + artists, err := controller.GetAllArtists(global.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) + fmt.Printf("FATAL: Failed to pull artists: %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 - } + 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 + } type IndexData struct { - Session *model.Session Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } - err = indexTemplate.Execute(w, IndexData{ - Session: session, + err = pages["index"].Execute(w, IndexData{ 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) + 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{} + } + + 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:] + } + } + // finally, is it in the cookie? + if token == "" { + cookie, err := r.Cookie("token") if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) + 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) + } + sessions = new_sessions + continue + } + + if s.Token == token { + session = s + break + } + } + + return session +} + +func LoginHandler() 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) + return + } + + code := r.URL.Query().Get("code") + + if code == "" { + pages["login"].Execute(w, loginData{DiscordURI: discord.REDIRECT_URI}) + return + } + + auth_token, err := discord.GetOAuthTokenFromCode(code) + if err != nil { + fmt.Printf("Failed to retrieve discord access token: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - }) -} -func registerAccountHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - if session.Account != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - 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) - } - } - - if r.Method == http.MethodGet { - render() - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - err := r.ParseForm() + discord_user, err := discord.GetDiscordUserFromAuth(auth_token) if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + fmt.Printf("Failed to retrieve discord user information: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 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() + 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) 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(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - session := r.Context().Value("session").(*model.Session) - - 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 - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - credentials := LoginRequest{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - TOTP: r.Form.Get("totp"), - } - - account, err := controller.GetAccountByUsername(app.DB, credentials.Username) - if err != nil { - 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 - } - - 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! - controller.SetSessionAccount(app.DB, session, account) - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin", http.StatusFound) + 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 + } }) } -func logoutHandler(app *model.AppState) http.Handler { +func LogoutHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } - session := r.Context().Value("session").(*model.Session) - err := controller.DeleteSession(app.DB, session.Token) + 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) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Printf("Error rendering admin logout page: %s\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) }) } @@ -419,53 +255,3 @@ 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 be5052b..54d0fd6 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -5,18 +5,17 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func serveRelease(app *model.AppState) http.Handler { +func serveRelease() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - session := r.Context().Value("session").(*model.Session) - - release, err := controller.GetRelease(app.DB, releaseID, true) + release, err := controller.GetRelease(global.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -27,16 +26,22 @@ func serveRelease(app *model.AppState) 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(app, release).ServeHTTP(w, r) + serveAddCredit(release).ServeHTTP(w, r) return case "newcredit": - serveNewCredit(app).ServeHTTP(w, r) + serveNewCredit().ServeHTTP(w, r) return case "editlinks": serveEditLinks(release).ServeHTTP(w, r) @@ -45,25 +50,17 @@ func serveRelease(app *model.AppState) http.Handler { serveEditTracks(release).ServeHTTP(w, r) return case "addtrack": - serveAddTrack(app, release).ServeHTTP(w, r) + serveAddTrack(release).ServeHTTP(w, r) return case "newtrack": - serveNewTrack(app).ServeHTTP(w, r) + serveNewTrack().ServeHTTP(w, r) return } http.NotFound(w, r) return } - type ReleaseResponse struct { - Session *model.Session - Release *model.Release - } - - err = releaseTemplate.Execute(w, ReleaseResponse{ - Session: session, - Release: release, - }) + err = pages["release"].Execute(w, 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) @@ -74,7 +71,7 @@ func serveRelease(app *model.AppState) 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 := editCreditsTemplate.Execute(w, release) + err := components["editcredits"].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) @@ -82,9 +79,9 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { +func serveAddCredit(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) + artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -97,7 +94,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = addCreditTemplate.Execute(w, response{ + err = components["addcredit"].Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -108,10 +105,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewCredit(app *model.AppState) http.Handler { +func serveNewCredit() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := controller.GetArtist(global.DB, artistID) if err != nil { fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -123,7 +120,7 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = newCreditTemplate.Execute(w, artist) + err = components["newcredit"].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) @@ -134,7 +131,7 @@ func serveNewCredit(app *model.AppState) 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 := editLinksTemplate.Execute(w, release) + err := components["editlinks"].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) @@ -145,16 +142,7 @@ 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") - - 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 }, - }) + err := components["edittracks"].Execute(w, release) 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) @@ -162,9 +150,9 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { +func serveAddTrack(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) + tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -177,7 +165,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = addTrackTemplate.Execute(w, response{ + err = components["addtrack"].Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -189,10 +177,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewTrack(app *model.AppState) http.Handler { +func serveNewTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(app.DB, trackID) + track, err := controller.GetTrack(global.DB, trackID) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -204,7 +192,7 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = newTrackTemplate.Execute(w, track) + err = components["newtrack"].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 877b5da..0d1269c 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -24,7 +24,7 @@ nav { justify-content: left; background: #f8f8f8; - border-radius: 4px; + border-radius: .5em; border: 1px solid #808080; } nav .icon { @@ -43,7 +43,7 @@ nav .title { color: inherit; } -.nav-item { +nav a { width: auto; height: 100%; @@ -53,17 +53,16 @@ nav .title { display: flex; line-height: 2em; + text-decoration: none; + + color: inherit; } -.nav-item:hover { +nav a:hover { background: #00000010; text-decoration: none; } -nav a { - text-decoration: none; - color: inherit; -} nav #logout { - /* margin-left: auto; */ + margin-left: auto; } main { @@ -85,15 +84,6 @@ a img.icon { height: .8em; } -code { - background: #303030; - color: #f0f0f0; - padding: .23em .3em; - border-radius: 4px; -} - - - .card { margin-bottom: 1em; } @@ -102,6 +92,13 @@ code { margin: 0 0 .5em 0; } +/* +.card h3, +.card p { + margin: 0; +} +*/ + .card-title { margin-bottom: 1em; display: flex; @@ -117,112 +114,8 @@ code { 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 deleted file mode 100644 index 7a4d34a..0000000 --- a/admin/static/edit-account.css +++ /dev/null @@ -1,48 +0,0 @@ -@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 deleted file mode 100644 index e69de29..0000000 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 5627e64..e481b68 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -66,6 +66,54 @@ input[type="text"]:focus { border-color: #808080; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .artist-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index aa70e34..9feb9ad 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -109,6 +109,58 @@ input[type="text"] { padding: 0; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.new { + background: #c4ff6a; + border-color: #84b141; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .release-actions { margin-top: auto; display: flex; @@ -160,7 +212,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -170,7 +222,7 @@ dialog div.dialog-actions { } .card.credits .credit .artist-avatar { - border-radius: 8px; + border-radius: .5em; } .card.credits .credit .artist-name { @@ -196,7 +248,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -215,7 +267,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: 8px; + border-radius: .5em; } #editcredits .credit .credit-info { @@ -228,14 +280,12 @@ 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: 0 0 0 .25em; + margin-left: .25em; padding: .2em .4em; flex-grow: 1; font-family: inherit; @@ -243,9 +293,6 @@ 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; @@ -374,10 +421,8 @@ 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; @@ -400,7 +445,7 @@ dialog div.dialog-actions { flex-direction: column; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 600b680..6e87397 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -67,6 +67,54 @@ h1 { border-color: #808080; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .track-actions { margin-top: 1em; display: flex; diff --git a/admin/static/index.css b/admin/static/index.css index 9fcd731..ec426af 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,5 +1,23 @@ @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; @@ -8,7 +26,7 @@ align-items: center; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -31,7 +49,7 @@ flex-direction: column; gap: .5em; - border-radius: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -80,3 +98,4 @@ .track .empty { opacity: 0.75; } + diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css index 638eac0..ee67de7 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: 8px; + border-radius: .5em; background: #f8f8f8f8; border: 1px solid #808080; } @@ -50,7 +50,7 @@ padding: .5em; display: block; - border-radius: 8px; + border-radius: .5em; text-decoration: none; color: #f0f0f0; background: #303030; @@ -73,7 +73,7 @@ padding: .3em .5em; display: inline-block; - border-radius: 4px; + border-radius: .3em; background: #e0e0e0; transition: color .1s, background .1s; diff --git a/admin/templates.go b/admin/templates.go index 49c118b..b7aaf9e 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,90 +1,55 @@ package admin import ( - "html/template" - "path/filepath" + "html/template" + "path/filepath" ) -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"), -)) +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 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"), -)) + "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 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"), -)) + "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 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"), -)) +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 editLinksTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "links", "editlinks.html"), -)) + "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.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"), -)) + "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"))), +} diff --git a/admin/trackhttp.go b/admin/trackhttp.go index a92f81a..148b7d8 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -5,15 +5,16 @@ import ( "net/http" "strings" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/controller" ) -func serveTrack(app *model.AppState) http.Handler { +func serveTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slices := strings.Split(r.URL.Path[1:], "/") id := slices[0] - track, err := controller.GetTrack(app.DB, id) + track, err := controller.GetTrack(global.DB, id) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -24,26 +25,19 @@ func serveTrack(app *model.AppState) http.Handler { return } - releases, err := controller.GetTrackReleases(app.DB, track.ID, true) + releases, err := controller.GetTrackReleases(global.DB, track.ID, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - type TrackResponse struct { - Session *model.Session - Track *model.Track + type Track struct { + *model.Track Releases []*model.Release } - session := r.Context().Value("session").(*model.Session) - - err = trackTemplate.Execute(w, TrackResponse{ - Session: session, - Track: track, - Releases: releases, - }) + err = pages["track"].Execute(w, Track{ 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 deleted file mode 100644 index 6c17088..0000000 --- a/admin/views/edit-account.html +++ /dev/null @@ -1,84 +0,0 @@ -{{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 b0cfb41..b54361a 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -1,6 +1,6 @@ {{define "head"}} -Editing {{.Artist.Name}} - ari melody 💫 - +Editing {{.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 02447e1..d106bb5 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -1,27 +1,28 @@ {{define "head"}} -Editing {{.Release.Title}} - ari melody 💫 - +Editing {{.Title}} - ari melody 💫 + + {{end}} {{define "content"}}
    -
    +
    - +

    - +

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

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

    +

    Credits ({{len .Credits}})

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

    There are no credits.

    {{end}}
    -

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

    +

    Links ({{len .Links}})

    Edit
    -

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

    +

    Tracklist ({{len .Tracks}})

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

    {{.Add $i 1}} diff --git a/admin/views/edit-track.html b/admin/views/edit-track.html index 56e0ae4..2b0fed5 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

    + >{{.Description}}

    Lyrics

    + >{{.Lyrics}}
    diff --git a/admin/views/index.html b/admin/views/index.html index 2b9c897..8f42e0e 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 8c34c8e..ca9ce1f 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -17,25 +17,9 @@
    diff --git a/admin/views/login-totp.html b/admin/views/login-totp.html deleted file mode 100644 index d959e3c..0000000 --- a/admin/views/login-totp.html +++ /dev/null @@ -1,42 +0,0 @@ -{{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 fbb7294..f615746 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -1,50 +1,32 @@ {{define "head"}} Login - ari melody 💫 - + {{end}} {{define "content"}}
    - {{if .Session.Message.Valid}} -

    {{html .Session.Message.String}}

    + {{if .Token}} + + +

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

    + + {{else}} + +

    Log in with Discord.

    + {{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 f127fd6..1999377 100644 --- a/admin/views/logout.html +++ b/admin/views/logout.html @@ -12,10 +12,13 @@ p a { {{define "content"}}
    - +

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

    diff --git a/admin/views/register.html b/admin/views/register.html deleted file mode 100644 index 37f0947..0000000 --- a/admin/views/register.html +++ /dev/null @@ -1,61 +0,0 @@ -{{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 deleted file mode 100644 index 7d305ec..0000000 --- a/admin/views/totp-confirm.html +++ /dev/null @@ -1,48 +0,0 @@ -{{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 deleted file mode 100644 index e74c970..0000000 --- a/admin/views/totp-setup.html +++ /dev/null @@ -1,20 +0,0 @@ -{{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 50b1c63..4757a42 100644 --- a/api/api.go +++ b/api/api.go @@ -1,27 +1,23 @@ package api import ( - "context" - "errors" "fmt" "net/http" - "os" "strings" + "arimelody-web/admin" + "arimelody-web/global" "arimelody-web/controller" - "arimelody-web/model" ) -func Handler(app *model.AppState) http.Handler { +func Handler() http.Handler { mux := http.NewServeMux() - // 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(app.DB, artistID) + artist, err := controller.GetArtist(global.DB, artistID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -35,13 +31,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist/{id} - ServeArtist(app, artist).ServeHTTP(w, r) + ServeArtist(artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) + admin.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) + admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -50,10 +46,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/artist - ServeAllArtists(app).ServeHTTP(w, r) + ServeAllArtists().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - requireAccount(app, CreateArtist(app)).ServeHTTP(w, r) + admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -63,7 +59,7 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var releaseID = strings.Split(r.URL.Path[1:], "/")[0] - release, err := controller.GetRelease(app.DB, releaseID, true) + release, err := controller.GetRelease(global.DB, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -77,13 +73,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music/{id} - ServeRelease(app, release).ServeHTTP(w, r) + ServeRelease(release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) + admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) + admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -92,10 +88,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music - ServeCatalog(app).ServeHTTP(w, r) + ServeCatalog().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - requireAccount(app, CreateRelease(app)).ServeHTTP(w, r) + admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -105,7 +101,7 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackID = strings.Split(r.URL.Path[1:], "/")[0] - track, err := controller.GetTrack(app.DB, trackID) + track, err := controller.GetTrack(global.DB, trackID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -119,13 +115,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) + admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) + admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) + admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -134,10 +130,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) + admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - requireAccount(app, CreateTrack(app)).ServeHTTP(w, r) + admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -145,51 +141,3 @@ func Handler(app *model.AppState) 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 51c9d62..c793e23 100644 --- a/api/artist.go +++ b/api/artist.go @@ -10,16 +10,18 @@ import ( "strings" "time" + "arimelody-web/admin" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeAllArtists(app *model.AppState) http.Handler { +func ServeAllArtists() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} - artists, err := controller.GetAllArtists(app.DB) + artists, err := controller.GetAllArtists(global.DB) if err != nil { - fmt.Printf("WARN: Failed to serve all artists: %s\n", err) + fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -34,7 +36,7 @@ func ServeAllArtists(app *model.AppState) http.Handler { }) } -func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { +func ServeArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type ( creditJSON struct { @@ -51,12 +53,12 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { } ) - session := r.Context().Value("session").(*model.Session) - show_hidden_releases := session != nil && session.Account != nil + show_hidden_releases := admin.GetSession(r) != nil - dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) + var dbCredits []*model.Credit + dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) if err != nil { - fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) + fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -86,7 +88,7 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { }) } -func CreateArtist(app *model.AppState) http.Handler { +func CreateArtist() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artist model.Artist err := json.NewDecoder(r.Body).Decode(&artist) @@ -101,13 +103,13 @@ func CreateArtist(app *model.AppState) http.Handler { } if artist.Name == "" { artist.Name = artist.ID } - err = controller.CreateArtist(app.DB, &artist) + err = controller.CreateArtist(global.DB, &artist) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) return } - fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) + fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -116,11 +118,11 @@ func CreateArtist(app *model.AppState) http.Handler { }) } -func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { +func UpdateArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := json.NewDecoder(r.Body).Decode(&artist) if err != nil { - fmt.Printf("WARN: Failed to update artist: %s\n", err) + fmt.Printf("FATAL: Failed to update artist: %s\n", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -130,7 +132,7 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { } else { if strings.Contains(artist.Avatar, ";base64,") { var artworkDirectory = filepath.Join("uploads", "avatar") - filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID) + filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID) // clean up files with this ID and different extensions err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { @@ -149,27 +151,27 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { } } - err = controller.UpdateArtist(app.DB, artist) + err = controller.UpdateArtist(global.DB, artist) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err) + fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler { +func DeleteArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteArtist(app.DB, artist.ID) + err := controller.DeleteArtist(global.DB, artist.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err) + fmt.Printf("FATAL: 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 4e7372f..2288153 100644 --- a/api/release.go +++ b/api/release.go @@ -10,25 +10,19 @@ import ( "strings" "time" + "arimelody-web/admin" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) -func ServeRelease(app *model.AppState, release *model.Release) http.Handler { +func ServeRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - privileged := false - if !release.Visible { - 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 - } + authorised := admin.GetSession(r) != nil + if !authorised && !release.Visible { + http.NotFound(w, r) + return } type ( @@ -59,18 +53,18 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { Links: make(map[string]string), } - if release.IsReleased() || privileged { + if authorised || release.IsReleased() { // get credits - credits, err := controller.GetReleaseCredits(app.DB, release.ID) + credits, err := controller.GetReleaseCredits(global.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } for _, credit := range credits { - artist, err := controller.GetArtist(app.DB, credit.Artist.ID) + artist, err := controller.GetArtist(global.DB, credit.Artist.ID) if err != nil { - fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -83,9 +77,9 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { } // get tracks - tracks, err := controller.GetReleaseTracks(app.DB, release.ID) + tracks, err := controller.GetReleaseTracks(global.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -98,9 +92,9 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { } // get links - links, err := controller.GetReleaseLinks(app.DB, release.ID) + links, err := controller.GetReleaseLinks(global.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -120,9 +114,9 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { }) } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := controller.GetAllReleases(global.DB, false, 0, true) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -140,19 +134,11 @@ func ServeCatalog(app *model.AppState) http.Handler { } catalog := []Release{} - session := r.Context().Value("session").(*model.Session) + authorised := admin.GetSession(r) != nil for _, release := range releases { - if !release.Visible { - privileged := false - if session != nil && session.Account != nil { - // TODO: check privilege on release - privileged = true - } - if !privileged { - continue - } + if !release.Visible && !authorised { + continue } - artists := []string{} for _, credit := range release.Credits { if !credit.Primary { continue } @@ -181,7 +167,7 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func CreateRelease(app *model.AppState) http.Handler { +func CreateRelease() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -209,13 +195,13 @@ func CreateRelease(app *model.AppState) http.Handler { if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } - err = controller.CreateRelease(app.DB, &release) + err = controller.CreateRelease(global.DB, &release) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) return } - fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -232,7 +218,7 @@ func CreateRelease(app *model.AppState) http.Handler { }) } -func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { +func UpdateRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.NotFound(w, r) @@ -244,11 +230,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { if len(segments) == 2 { switch segments[1] { case "tracks": - UpdateReleaseTracks(app, release).ServeHTTP(w, r) + UpdateReleaseTracks(release).ServeHTTP(w, r) case "credits": - UpdateReleaseCredits(app, release).ServeHTTP(w, r) + UpdateReleaseCredits(release).ServeHTTP(w, r) case "links": - UpdateReleaseLinks(app, release).ServeHTTP(w, r) + UpdateReleaseLinks(release).ServeHTTP(w, r) } return } @@ -270,7 +256,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { } else { if strings.Contains(release.Artwork, ";base64,") { var artworkDirectory = filepath.Join("uploads", "musicart") - filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID) + filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID) // clean up files with this ID and different extensions err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { @@ -289,19 +275,19 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { } } - err = controller.UpdateRelease(app.DB, release) + err = controller.UpdateRelease(global.DB, release) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseTracks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackIDs = []string{} err := json.NewDecoder(r.Body).Decode(&trackIDs) @@ -310,19 +296,19 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl return } - err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs) + err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type creditJSON struct { Artist string @@ -347,7 +333,7 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand }) } - err = controller.UpdateReleaseCredits(app.DB, release.ID, credits) + err = controller.UpdateReleaseCredits(global.DB, release.ID, credits) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest) @@ -357,13 +343,13 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseLinks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.NotFound(w, r) @@ -377,27 +363,27 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle return } - err = controller.UpdateReleaseLinks(app.DB, release.ID, links) + err = controller.UpdateReleaseLinks(global.DB, release.ID, links) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func DeleteRelease(app *model.AppState, release *model.Release) http.Handler { +func DeleteRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := controller.DeleteRelease(app.DB, release.ID) + err := controller.DeleteRelease(global.DB, release.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err) + fmt.Printf("FATAL: 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 c342e08..f6d5578 100644 --- a/api/track.go +++ b/api/track.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" ) @@ -16,7 +17,7 @@ type ( } ) -func ServeAllTracks(app *model.AppState) http.Handler { +func ServeAllTracks() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type Track struct { ID string `json:"id"` @@ -25,9 +26,9 @@ func ServeAllTracks(app *model.AppState) http.Handler { var tracks = []Track{} var dbTracks = []*model.Track{} - dbTracks, err := controller.GetAllTracks(app.DB) + dbTracks, err := controller.GetAllTracks(global.DB) if err != nil { - fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) + fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -43,17 +44,17 @@ func ServeAllTracks(app *model.AppState) http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(tracks) if err != nil { - fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) + fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func ServeTrack(app *model.AppState, track *model.Track) http.Handler { +func ServeTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) + dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) if err != nil { - fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) + fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -67,13 +68,13 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(Track{ track, releases }) if err != nil { - fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err) + fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func CreateTrack(app *model.AppState) http.Handler { +func CreateTrack() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -92,9 +93,9 @@ func CreateTrack(app *model.AppState) http.Handler { return } - id, err := controller.CreateTrack(app.DB, &track) + id, err := controller.CreateTrack(global.DB, &track) if err != nil { - fmt.Printf("WARN: Failed to create track: %s\n", err) + fmt.Printf("FATAL: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -105,7 +106,7 @@ func CreateTrack(app *model.AppState) http.Handler { }) } -func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { +func UpdateTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || r.URL.Path == "/" { http.NotFound(w, r) @@ -123,9 +124,9 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { return } - err = controller.UpdateTrack(app.DB, track) + err = controller.UpdateTrack(global.DB, track) if err != nil { - fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err) + fmt.Printf("Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -140,7 +141,7 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { }) } -func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { +func DeleteTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path == "/" { http.NotFound(w, r) @@ -148,9 +149,9 @@ func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { } var trackID = r.URL.Path[1:] - err := controller.DeleteTrack(app.DB, trackID) + err := controller.DeleteTrack(global.DB, trackID) if err != nil { - fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err) + fmt.Printf("Failed to delete track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/uploads.go b/api/uploads.go index ddcf6ee..6b1c496 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,7 +1,7 @@ package api import ( - "arimelody-web/model" + "arimelody-web/global" "bufio" "encoding/base64" "errors" @@ -11,12 +11,12 @@ import ( "strings" ) -func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { +func HandleImageUpload(data *string, directory string, filename string) (string, error) { split := strings.Split(*data, ";base64,") header := split[0] imageData, err := base64.StdEncoding.DecodeString(split[1]) ext, _ := strings.CutPrefix(header, "data:image/") - directory = filepath.Join(app.Config.DataDirectory, directory) + directory = filepath.Join(global.Config.DataDirectory, directory) switch ext { case "png": diff --git a/controller/account.go b/controller/account.go deleted file mode 100644 index 0cf3364..0000000 --- a/controller/account.go +++ /dev/null @@ -1,126 +0,0 @@ -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 1a613aa..c52b78d 100644 --- a/controller/artist.go +++ b/controller/artist.go @@ -2,7 +2,6 @@ package controller import ( "arimelody-web/model" - "github.com/jmoiron/sqlx" ) diff --git a/controller/controller.go b/controller/controller.go deleted file mode 100644 index 44194e4..0000000 --- a/controller/controller.go +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f30db64..0000000 --- a/controller/invite.go +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 7ada0f8..0000000 --- a/controller/qr.go +++ /dev/null @@ -1,120 +0,0 @@ -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 362669a..e55fef0 100644 --- a/controller/release.go +++ b/controller/release.go @@ -5,7 +5,6 @@ import ( "fmt" "arimelody-web/model" - "github.com/jmoiron/sqlx" ) @@ -224,6 +223,7 @@ 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 deleted file mode 100644 index c9c4cbb..0000000 --- a/controller/session.go +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 88f6bc3..0000000 --- a/controller/totp.go +++ /dev/null @@ -1,165 +0,0 @@ -package controller - -import ( - "arimelody-web/model" - "crypto/hmac" - "crypto/rand" - "crypto/sha1" - "encoding/base32" - "encoding/binary" - "fmt" - "math" - "net/url" - "os" - "strings" - "time" - - "github.com/jmoiron/sqlx" -) - -const TOTP_SECRET_LENGTH = 32 -const 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 fa4efc1..d302045 100644 --- a/controller/track.go +++ b/controller/track.go @@ -2,7 +2,6 @@ package controller import ( "arimelody-web/model" - "github.com/jmoiron/sqlx" ) diff --git a/discord/discord.go b/discord/discord.go index d46f32d..27023e7 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -1,17 +1,38 @@ 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"` @@ -47,15 +68,15 @@ type ( } ) -func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) { +func GetOAuthTokenFromCode(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": {app.Config.Discord.ClientID}, - "client_secret": {app.Config.Discord.Secret}, + "client_id": {CLIENT_ID}, + "client_secret": {CLIENT_SECRET}, "grant_type": {"authorization_code"}, "code": {code}, - "redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)}, + "redirect_uri": {OAUTH_CALLBACK_URI}, }.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -94,15 +115,3 @@ 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/controller/config.go b/global/config.go similarity index 51% rename from controller/config.go rename to global/config.go index 8772b6b..d8c9f3d 100644 --- a/controller/config.go +++ b/global/config.go @@ -1,32 +1,48 @@ -package controller +package global import ( "errors" "fmt" "os" "strconv" + "strings" - "arimelody-web/model" - + "github.com/jmoiron/sqlx" "github.com/pelletier/go-toml/v2" ) -func GetConfig() model.Config { +type ( + dbConfig struct { + Host string `toml:"host"` + Name string `toml:"name"` + User string `toml:"user"` + Pass string `toml:"pass"` + } + + discordConfig struct { + AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."` + ClientID string `toml:"client_id"` + Secret string `toml:"secret"` + } + + config struct { + BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` + Port int64 `toml:"port"` + DataDirectory string `toml:"data_dir"` + DB dbConfig `toml:"db"` + Discord discordConfig `toml:"discord"` + } +) + +var Config = func() config { configFile := os.Getenv("ARIMELODY_CONFIG") if configFile == "" { configFile = "config.toml" } - config := model.Config{ + config := config{ BaseUrl: "https://arimelody.me", - 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) @@ -41,22 +57,23 @@ func GetConfig() model.Config { err = toml.Unmarshal([]byte(data), &config) if err != nil { - panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err)) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) + os.Exit(1) } err = handleConfigOverrides(&config) if err != nil { - panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err)) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) + os.Exit(1) } return config -} +}() -func handleConfigOverrides(config *model.Config) error { +func handleConfigOverrides(config *config) error { var err error if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } - 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()) } @@ -64,10 +81,6 @@ func handleConfigOverrides(config *model.Config) error { if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env } if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env } - if env, has := os.LookupEnv("ARIMELODY_DB_PORT"); has { - config.DB.Port, err = strconv.ParseInt(env, 10, 0) - if err != nil { return errors.New("ARIMELODY_DB_PORT: " + err.Error()) } - } if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env } if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env } if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env } @@ -78,3 +91,31 @@ func handleConfigOverrides(config *model.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/global/funcs.go b/global/funcs.go new file mode 100644 index 0000000..c0462db --- /dev/null +++ b/global/funcs.go @@ -0,0 +1,102 @@ +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 f8717a2..8d116d4 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,4 @@ require ( github.com/lib/pq v1.10.9 ) -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 -) +require github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index 15259a1..37a12d4 100644 --- a/go.sum +++ b/go.sum @@ -10,7 +10,3 @@ 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 7e8e06f..3076623 100644 --- a/main.go +++ b/main.go @@ -4,416 +4,82 @@ import ( "errors" "fmt" "log" - "math" - "math/rand" "net/http" "os" "path/filepath" - "strconv" - "strings" "time" "arimelody-web/admin" "arimelody-web/api" - "arimelody-web/colour" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/global" "arimelody-web/templates" "arimelody-web/view" "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 app.Config.DB.Host == "" { + if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } + if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } + if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env } + if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env } + if global.Config.DB.Host == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.Name == "" { + if global.Config.DB.Name == "" { fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.User == "" { + if global.Config.DB.User == "" { fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") os.Exit(1) } - if app.Config.DB.Pass == "" { + if global.Config.DB.Pass == "" { fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") os.Exit(1) } var err error - app.DB, err = sqlx.Connect( + global.DB, err = sqlx.Connect( "postgres", fmt.Sprintf( - "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", - app.Config.DB.Host, - app.Config.DB.Port, - app.Config.DB.User, - app.Config.DB.Name, - app.Config.DB.Pass, + "host=%s user=%s dbname=%s password='%s' sslmode=disable", + global.Config.DB.Host, + global.Config.DB.User, + global.Config.DB.Name, + global.Config.DB.Pass, ), ) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) - os.Exit(1) - } - app.DB.SetConnMaxLifetime(time.Minute * 3) - app.DB.SetMaxOpenConns(10) - app.DB.SetMaxIdleConns(10) - defer app.DB.Close() - - // 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) + fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %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(&app) - fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) + mux := createServeMux() + fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port) log.Fatal( - http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), - HTTPLog(DefaultHeaders(mux)), + http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), + global.HTTPLog(global.DefaultHeaders(mux)), )) } -func createServeMux(app *model.AppState) *http.ServeMux { +func createServeMux() *http.ServeMux { mux := http.NewServeMux() - - mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) - mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) - mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) + + mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) + mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) + mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) + mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads")))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -421,7 +87,7 @@ func createServeMux(app *model.AppState) *http.ServeMux { } if r.URL.Path == "/" || r.URL.Path == "/index.html" { - err := templates.IndexTemplate.Execute(w, nil) + err := templates.Pages["index"].Execute(w, nil) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -429,7 +95,7 @@ func createServeMux(app *model.AppState) *http.ServeMux { } staticHandler("public").ServeHTTP(w, r) })) - + return mux } @@ -454,93 +120,3 @@ func staticHandler(directory string) http.Handler { http.FileServer(http.Dir(directory)).ServeHTTP(w, r) }) } - -var PoweredByStrings = []string{ - "nerd rage", - "estrogen", - "your mother", - "awesome powers beyond comprehension", - "jared", - "the weight of my sins", - "the arc reactor", - "AA batteries", - "15 euro solar panel from ebay", - "magnets, how do they work", - "a fax machine", - "dell optiplex", - "a trans girl's nintendo wii", - "BASS", - "electricity, duh", - "seven hamsters in a big wheel", - "girls", - "mzungu hosting", - "golang", - "the state of the world right now", - "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", - "the good folks at aperture science", - "free2play CDs", - "aridoodle", - "the love of creating", - "not for the sake of art; not for the sake of money; we like painting naked people", - "30 billion dollars in VC funding", -} - -func DefaultHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Server", "arimelody.me") - w.Header().Add("Do-Not-Stab", "1") - w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett") - w.Header().Add("X-Hacker", "spare me please") - w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;") - w.Header().Add("X-Thinking-With", "Portals") - w.Header().Add( - "X-Powered-By", - PoweredByStrings[rand.Intn(len(PoweredByStrings))], - ) - next.ServeHTTP(w, r) - }) -} - -type LoggingResponseWriter struct { - http.ResponseWriter - Status int -} - -func (lrw *LoggingResponseWriter) WriteHeader(status int) { - lrw.Status = status - lrw.ResponseWriter.WriteHeader(status) -} - -func HTTPLog(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - lrw := LoggingResponseWriter{w, http.StatusOK} - - next.ServeHTTP(&lrw, r) - - after := time.Now() - difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000 - elapsed := "<1" - if difference >= 1 { - elapsed = strconv.Itoa(difference) - } - - statusColour := colour.Reset - - if lrw.Status - 600 <= 0 { statusColour = colour.Red } - if lrw.Status - 500 <= 0 { statusColour = colour.Yellow } - if lrw.Status - 400 <= 0 { statusColour = colour.White } - if lrw.Status - 300 <= 0 { statusColour = colour.Green } - - fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", - after.Format(time.UnixDate), - r.Method, - r.URL.Path, - statusColour, - lrw.Status, - colour.Reset, - elapsed, - r.Header["User-Agent"][0]) - }) -} diff --git a/model/account.go b/model/account.go deleted file mode 100644 index 166e880..0000000 --- a/model/account.go +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 6a965d5..0000000 --- a/model/appstate.go +++ /dev/null @@ -1,33 +0,0 @@ -package model - -import "github.com/jmoiron/sqlx" - -type ( - DBConfig struct { - Host string `toml:"host"` - Port int64 `toml:"port"` - Name string `toml:"name"` - User string `toml:"user"` - Pass string `toml:"pass"` - } - - DiscordConfig struct { - AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."` - ClientID string `toml:"client_id"` - Secret string `toml:"secret"` - } - - Config struct { - BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` - 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 deleted file mode 100644 index b7a66ae..0000000 --- a/model/invite.go +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index c1983a1..0000000 --- a/model/session.go +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index cfad10a..0000000 --- a/model/totp.go +++ /dev/null @@ -1,13 +0,0 @@ -package model - -import ( - "time" -) - -type TOTP struct { - Name string `json:"name" db:"name"` - AccountID string `json:"accountID" db:"account"` - Secret string `json:"-" db:"secret"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - Confirmed bool `json:"-" db:"confirmed"` -} diff --git a/model/track.go b/model/track.go index ca54ddd..d44c224 100644 --- a/model/track.go +++ b/model/track.go @@ -12,8 +12,6 @@ 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 d9682b1..72cdebb 100644 --- a/public/style/footer.css +++ b/public/style/footer.css @@ -1,11 +1,11 @@ footer { - border-top: 1px solid #8888; + border-top: 1px solid #888; } #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 f3cb761..363c381 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: #888; + border-color: #888f; margin: 1.5em 0; overflow: visible; } diff --git a/schema_migration/000-init.sql b/schema.sql similarity index 59% rename from schema_migration/000-init.sql rename to schema.sql index 8751180..15957ad 100644 --- a/schema_migration/000-init.sql +++ b/schema.sql @@ -1,58 +1,8 @@ +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, @@ -61,7 +11,9 @@ 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, @@ -77,7 +29,9 @@ 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, @@ -85,7 +39,9 @@ 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, @@ -94,7 +50,9 @@ 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, @@ -104,7 +62,9 @@ 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, @@ -112,16 +72,9 @@ 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 deleted file mode 100644 index 37de432..0000000 --- a/schema_migration/001-pre-versioning.sql +++ /dev/null @@ -1,56 +0,0 @@ --- --- 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 8d1a5ca..094e61d 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -5,24 +5,29 @@ import ( "path/filepath" ) -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"), -)) +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{ +} diff --git a/view/music.go b/view/music.go index 7b86270..f38e1c2 100644 --- a/view/music.go +++ b/view/music.go @@ -4,37 +4,39 @@ import ( "fmt" "net/http" + "arimelody-web/admin" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/model" "arimelody-web/templates" ) // HTTP HANDLER METHODS -func MusicHandler(app *model.AppState) http.Handler { +func MusicHandler() http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - ServeCatalog(app).ServeHTTP(w, r) + ServeCatalog().ServeHTTP(w, r) return } - release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true) + release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true) if err != nil { http.NotFound(w, r) return } - ServeGateway(app, release).ServeHTTP(w, r) + ServeGateway(release).ServeHTTP(w, r) })) return mux } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, true, 0, true) + releases, err := controller.GetAllReleases(global.DB, true, 0, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -47,39 +49,31 @@ func ServeCatalog(app *model.AppState) http.Handler { } } - err = templates.MusicTemplate.Execute(w, releases) + err = templates.Pages["music"].Execute(w, releases) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } -func ServeGateway(app *model.AppState, release *model.Release) http.Handler { +func ServeGateway(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - privileged := false - if !release.Visible { - 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 - } + authorised := admin.GetSession(r) != nil + if !authorised && !release.Visible { + http.NotFound(w, r) + return } response := *release - if release.IsReleased() || privileged { + if authorised || release.IsReleased() { response.Tracks = release.Tracks response.Credits = release.Credits response.Links = release.Links } - err := templates.MusicGatewayTemplate.Execute(w, response) + err := templates.Pages["music-gateway"].Execute(w, response) if err != nil { fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)