From e69cf78e57e2bc2dc11d9f073e0fe8e08bcba6f3 Mon Sep 17 00:00:00 2001 From: ari melody Date: Thu, 12 Sep 2024 09:46:40 +0100 Subject: [PATCH 01/30] add artists list to /api/v1/music --- .air.toml | 2 +- api/release.go | 7 +++++++ music/controller/release.go | 20 ++++++++++---------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.air.toml b/.air.toml index ea2b4d5..f8a1acf 100644 --- a/.air.toml +++ b/.air.toml @@ -7,7 +7,7 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ." delay = 1000 - exclude_dir = ["admin\\static", "public", "uploads", "test"] + exclude_dir = ["admin/static", "public", "uploads", "test", "db"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false diff --git a/api/release.go b/api/release.go index 763383f..3027a8c 100644 --- a/api/release.go +++ b/api/release.go @@ -27,6 +27,7 @@ func ServeCatalog() http.Handler { type Release struct { ID string `json:"id"` Title string `json:"title"` + Artists []string `json:"artists"` ReleaseType model.ReleaseType `json:"type" db:"type"` ReleaseDate time.Time `json:"releaseDate" db:"release_date"` Artwork string `json:"artwork"` @@ -40,9 +41,15 @@ func ServeCatalog() http.Handler { if !release.Visible && !authorised { continue } + artists := []string{} + for _, credit := range release.Credits { + if !credit.Primary { continue } + artists = append(artists, credit.Artist.Name) + } catalog = append(catalog, Release{ ID: release.ID, Title: release.Title, + Artists: artists, ReleaseType: release.ReleaseType, ReleaseDate: release.ReleaseDate, Artwork: release.Artwork, diff --git a/music/controller/release.go b/music/controller/release.go index 4bd2cd6..2aeb236 100644 --- a/music/controller/release.go +++ b/music/controller/release.go @@ -66,17 +66,17 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod return nil, err } - if full { - for _, release := range releases { - // get credits - credits, err := GetReleaseCredits(db, release.ID) - if err != nil { - return nil, errors.New(fmt.Sprintf("Credits: %s", err)) - } - for _, credit := range credits { - release.Credits = append(release.Credits, credit) - } + for _, release := range releases { + // get credits + credits, err := GetReleaseCredits(db, release.ID) + if err != nil { + return nil, errors.New(fmt.Sprintf("Credits: %s", err)) + } + for _, credit := range credits { + release.Credits = append(release.Credits, credit) + } + if full { // get tracks tracks, err := GetReleaseTracks(db, release.ID) if err != nil { From 1846203076f5995fd3aaaf4b5c9e63fe3957ceac Mon Sep 17 00:00:00 2001 From: ari melody Date: Thu, 12 Sep 2024 09:56:22 +0100 Subject: [PATCH 02/30] hide hidden releases from unauthorised /api/v1/artist/{id} --- admin/artisthttp.go | 2 +- api/artist.go | 5 ++++- music/controller/artist.go | 12 ++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 31a7c9c..4f66798 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -25,7 +25,7 @@ func serveArtist() http.Handler { return } - credits, err := music.GetArtistCredits(global.DB, artist.ID) + credits, err := music.GetArtistCredits(global.DB, artist.ID, true) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/api/artist.go b/api/artist.go index 2d5f5e6..00f185d 100644 --- a/api/artist.go +++ b/api/artist.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "arimelody-web/admin" "arimelody-web/global" db "arimelody-web/music/controller" music "arimelody-web/music/controller" @@ -46,8 +47,10 @@ func ServeArtist(artist *model.Artist) http.Handler { } ) + show_hidden_releases := admin.GetSession(r) != nil + var dbCredits []*model.Credit - dbCredits, err := db.GetArtistCredits(global.DB, artist.ID) + dbCredits, err := db.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) if err != nil { fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/music/controller/artist.go b/music/controller/artist.go index 2ae92e7..bc9e656 100644 --- a/music/controller/artist.go +++ b/music/controller/artist.go @@ -44,15 +44,15 @@ func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, err return artists, nil } -func GetArtistCredits(db *sqlx.DB, artistID string) ([]*model.Credit, error) { - rows, err := db.Query( - "SELECT release.id,release.title,release.artwork,artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ +func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) { + var query string = "SELECT release.id,release.title,release.artwork,artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ "FROM musiccredit "+ "JOIN musicrelease AS release ON release=release.id "+ "JOIN artist ON artist=artist.id "+ - "WHERE artist=$1 "+ - "ORDER BY release_date DESC", - artistID) + "WHERE artist=$1 " + if !show_hidden { query += "AND visible=true " } + query += "ORDER BY release_date DESC" + rows, err := db.Query(query, artistID) if err != nil { return nil, err } From f7edece0af6c312886f5bf923499f70da1c4cf52 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 23 Sep 2024 00:57:23 +0100 Subject: [PATCH 03/30] API login/register/delete-account, automatic db schema init --- .dockerignore | 2 + account/controller/account.go | 60 ++++++++++++ account/model/account.go | 54 +++++++++++ admin/admin.go | 2 +- api/account.go | 175 ++++++++++++++++++++++++++++++++++ api/api.go | 6 ++ discord/discord.go | 4 +- global/data.go | 11 ++- go.mod | 2 + go.sum | 2 + main.go | 169 +++++++++++++++++++++++++++++++- 11 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 account/controller/account.go create mode 100644 account/model/account.go create mode 100644 api/account.go diff --git a/.dockerignore b/.dockerignore index 0e34f0f..fc62ba5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,8 @@ uploads/* test/ tmp/ +db/ docker-compose.yml +docker-compose-test.yml Dockerfile schema.sql diff --git a/account/controller/account.go b/account/controller/account.go new file mode 100644 index 0000000..267c43b --- /dev/null +++ b/account/controller/account.go @@ -0,0 +1,60 @@ +package controller + +import ( + "arimelody-web/account/model" + + "github.com/jmoiron/sqlx" +) + +func GetAccount(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 { + 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 { + return nil, err + } + + return &account, nil +} + +func CreateAccount(db *sqlx.DB, account *model.Account) error { + _, err := db.Exec( + "INSERT INTO account (username, password, email, avatar_url) " + + "VALUES ($1, $2, $3, $4)", + 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/account/model/account.go b/account/model/account.go new file mode 100644 index 0000000..db32451 --- /dev/null +++ b/account/model/account.go @@ -0,0 +1,54 @@ +package model + +import ( + "math/rand" + "time" +) + +type ( + Account struct { + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Password []byte `json:"password" db:"password"` + Email string `json:"email" db:"email"` + AvatarURL string `json:"avatar_url" db:"avatar_url"` + Privileges []AccountPrivilege `json:"privileges"` + } + + AccountPrivilege string + + Invite struct { + Code string `db:"code"` + CreatedByID string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + } +) + +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" +) + +var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func GenerateInviteCode(length int) []byte { + code := []byte{} + for i := 0; i < length; i++ { + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) + } + return code +} diff --git a/admin/admin.go b/admin/admin.go index 3c2aaae..d2bf80c 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -31,7 +31,7 @@ var ADMIN_BYPASS = func() bool { var ADMIN_ID_DISCORD = func() string { id := os.Getenv("DISCORD_ADMIN") if id == "" { - fmt.Printf("WARN: Discord admin ID (DISCORD_ADMIN) was not provided. Admin login will be unavailable.\n") + // fmt.Printf("WARN: Discord admin ID (DISCORD_ADMIN) was not provided.\n") } return id }() diff --git a/api/account.go b/api/account.go new file mode 100644 index 0000000..37cf9a1 --- /dev/null +++ b/api/account.go @@ -0,0 +1,175 @@ +package api + +import ( + "arimelody-web/account/controller" + "arimelody-web/account/model" + "arimelody-web/global" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func handleLogin() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + } + + credentials := LoginRequest{} + err := json.NewDecoder(r.Body).Decode(&credentials) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + account, err := controller.GetAccount(global.DB, credentials.Username) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + if err != nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + + // TODO: sessions and tokens + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) + }) +} + +func handleAccountRegistration() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Code string `json:"code"` + } + + credentials := RegisterRequest{} + err := json.NewDecoder(r.Body).Decode(&credentials) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // make sure code exists in DB + invite := model.Invite{} + err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.Error(w, "Invalid invite code", http.StatusBadRequest) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if time.Now().After(invite.ExpiresAt) { + http.Error(w, "Invalid invite code", http.StatusBadRequest) + _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + account := model.Account{ + Username: credentials.Username, + Password: hashedPassword, + Email: credentials.Email, + AvatarURL: "/img/default-avatar.png", + } + err = controller.CreateAccount(global.DB, &account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte("Account created successfully\n")) + }) +} + +func handleDeleteAccount() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + } + + credentials := LoginRequest{} + err := json.NewDecoder(r.Body).Decode(&credentials) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + account, err := controller.GetAccount(global.DB, credentials.Username) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + if err != nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + + err = controller.DeleteAccount(global.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Account deleted successfully\n")) + }) +} diff --git a/api/api.go b/api/api.go index 699cf1c..bbeddb3 100644 --- a/api/api.go +++ b/api/api.go @@ -14,6 +14,12 @@ import ( func Handler() http.Handler { mux := http.NewServeMux() + // ACCOUNT ENDPOINTS + + mux.Handle("/v1/login", handleLogin()) + mux.Handle("/v1/register", handleAccountRegistration()) + mux.Handle("/v1/delete-account", handleDeleteAccount()) + // ARTIST ENDPOINTS mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/discord/discord.go b/discord/discord.go index c3b3cec..e517418 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -18,7 +18,7 @@ var CREDENTIALS_PROVIDED = true var CLIENT_ID = func() string { id := os.Getenv("DISCORD_CLIENT") if id == "" { - fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided. Admin login will be unavailable.\n") + // fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided.\n") CREDENTIALS_PROVIDED = false } return id @@ -26,7 +26,7 @@ var CLIENT_ID = func() string { var CLIENT_SECRET = func() string { secret := os.Getenv("DISCORD_SECRET") if secret == "" { - fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n") + // fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided.\n") CREDENTIALS_PROVIDED = false } return secret diff --git a/global/data.go b/global/data.go index fb641fc..11c2814 100644 --- a/global/data.go +++ b/global/data.go @@ -34,7 +34,7 @@ var Args = func() map[string]string { return args }() -var HTTP_DOMAIN = func() string { +var HTTP_DOMAIN = func() string { domain := os.Getenv("HTTP_DOMAIN") if domain == "" { return "https://arimelody.me" @@ -42,4 +42,13 @@ var HTTP_DOMAIN = func() string { return domain }() +var APP_SECRET = func() string { + secret := os.Getenv("ARIMELODY_SECRET") + if secret == "" { + fmt.Fprintln(os.Stderr, "FATAL: ARIMELODY_SECRET was not provided. Cannot continue.") + os.Exit(1) + } + return secret +}() + var DB *sqlx.DB diff --git a/go.mod b/go.mod index e2beaf4..bcb2e16 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 ) + +require golang.org/x/crypto v0.27.0 // indirect diff --git a/go.sum b/go.sum index f4ce337..352b928 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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 c103696..5f7e258 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "time" + "arimelody-web/account/model" "arimelody-web/admin" "arimelody-web/api" "arimelody-web/global" @@ -27,9 +28,9 @@ func main() { if dbHost == "" { dbHost = "127.0.0.1" } var err error - global.DB, err = sqlx.Connect("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") + global.DB, err = initDB("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) os.Exit(1) } global.DB.SetConnMaxLifetime(time.Minute * 3) @@ -37,6 +38,41 @@ func main() { global.DB.SetMaxIdleConns(10) defer global.DB.Close() + _, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP") + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) + os.Exit(1) + } + + accountsCount := 0 + global.DB.Get(&accountsCount, "SELECT count(*) FROM account") + if accountsCount == 0 { + code := model.GenerateInviteCode(8) + + tx, err := global.DB.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err) + os.Exit(1) + } + _, err = tx.Exec("DELETE FROM invite") + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) + os.Exit(1) + } + _, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute)) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) + os.Exit(1) + } + err = tx.Commit() + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) + os.Exit(1) + } + + fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") + } + // start the web server! mux := createServeMux() port := DEFAULT_PORT @@ -44,9 +80,134 @@ func main() { log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), global.HTTPLog(mux))) } +func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) { + db, err := sqlx.Connect(driverName, dataSourceName) + if err != nil { return nil, err } + + // ensure tables exist + // account + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS account (" + + "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + + "username text NOT NULL UNIQUE, " + + "password text NOT NULL, " + + "email text, " + + "avatar_url text)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) } + + // privilege + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS privilege (" + + "account uuid NOT NULL, " + + "privilege text NOT NULL, " + + "CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " + + "CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) } + + // totp + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS totp (" + + "account uuid NOT NULL, " + + "name text NOT NULL, " + + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "CONSTRAINT totp_pk PRIMARY KEY (account, name), " + + "CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } + + // invites + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS invite (" + + "code text NOT NULL PRIMARY KEY, " + + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "expires_at TIMESTAMP NOT NULL)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } + + // artist + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS artist (" + + "id character varying(64) PRIMARY KEY, " + + "name text NOT NULL, " + + "website text, " + + "avatar text)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) } + + // musicrelease + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS musicrelease (" + + "id character varying(64) PRIMARY KEY, " + + "visible bool DEFAULT false, " + + "title text NOT NULL, " + + "description text, " + + "type text, " + + "release_date TIMESTAMP NOT NULL, " + + "artwork text, " + + "buyname text, " + + "buylink text, " + + "copyright text, " + + "copyrightURL text)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) } + + // musiclink + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS public.musiclink (" + + "release character varying(64) NOT NULL, " + + "name text NOT NULL, " + + "url text NOT NULL, " + + "CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " + + "CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) } + + // musiccredit + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS public.musiccredit (" + + "release character varying(64) NOT NULL, " + + "artist character varying(64) NOT NULL, " + + "role text NOT NULL, " + + "is_primary boolean DEFAULT false, " + + "CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " + + "CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + + "CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) } + + // musictrack + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS public.musictrack (" + + "id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " + + "title text NOT NULL, " + + "description text, " + + "lyrics text, " + + "preview_url text)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) } + + // musicreleasetrack + _, err = db.Exec( + "CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" + + "release character varying(64) NOT NULL, " + + "track uuid NOT NULL, " + + "number integer NOT NULL, " + + "CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " + + "CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + + "CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)", + ) + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) } + + // TODO: automatic database migration + + return db, nil +} + func createServeMux() *http.ServeMux { mux := http.NewServeMux() - + mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) mux.Handle("/music/", http.StripPrefix("/music", musicView.Handler())) @@ -61,7 +222,7 @@ func createServeMux() *http.ServeMux { } staticHandler("public").ServeHTTP(w, r) })) - + return mux } From e2ec7311091b7d540217085b4720626e13b9e47e Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 20 Oct 2024 20:05:30 +0100 Subject: [PATCH 04/30] add more detail to credits on /api/v1/artist/{id} --- api/artist.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/api/artist.go b/api/artist.go index 00f185d..90bd18a 100644 --- a/api/artist.go +++ b/api/artist.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "arimelody-web/admin" "arimelody-web/global" @@ -38,8 +39,12 @@ func ServeArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type ( creditJSON struct { - Role string `json:"role"` - Primary bool `json:"primary"` + ID string `json:"id"` + Title string `json:"title"` + ReleaseDate time.Time `json:"releaseDate" db:"release_date"` + Artwork string `json:"artwork"` + Role string `json:"role"` + Primary bool `json:"primary"` } artistJSON struct { *model.Artist @@ -60,6 +65,10 @@ func ServeArtist(artist *model.Artist) http.Handler { var credits = map[string]creditJSON{} for _, credit := range dbCredits { credits[credit.Release.ID] = creditJSON{ + ID: credit.Release.ID, + Title: credit.Release.Title, + ReleaseDate: credit.Release.ReleaseDate, + Artwork: credit.Release.Artwork, Role: credit.Role, Primary: credit.Primary, } From 34dd280fbac48e81969aa31684e02f928ae0efeb Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 1 Nov 2024 21:03:08 +0000 Subject: [PATCH 05/30] moved accounts to MVC directories --- api/account.go | 4 ++-- {account/controller => controller}/account.go | 13 ++++++++++++- main.go | 4 ++-- {account/model => model}/account.go | 11 ----------- 4 files changed, 16 insertions(+), 16 deletions(-) rename {account/controller => controller}/account.go (80%) rename {account/model => model}/account.go (80%) diff --git a/api/account.go b/api/account.go index 37cf9a1..37c4c7f 100644 --- a/api/account.go +++ b/api/account.go @@ -1,8 +1,8 @@ package api import ( - "arimelody-web/account/controller" - "arimelody-web/account/model" + "arimelody-web/controller" + "arimelody-web/model" "arimelody-web/global" "encoding/json" "fmt" diff --git a/account/controller/account.go b/controller/account.go similarity index 80% rename from account/controller/account.go rename to controller/account.go index 267c43b..a34cd27 100644 --- a/account/controller/account.go +++ b/controller/account.go @@ -1,7 +1,8 @@ package controller import ( - "arimelody-web/account/model" + "arimelody-web/model" + "math/rand" "github.com/jmoiron/sqlx" ) @@ -58,3 +59,13 @@ func DeleteAccount(db *sqlx.DB, accountID string) error { _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) return err } + +var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func GenerateInviteCode(length int) []byte { + code := []byte{} + for i := 0; i < length; i++ { + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) + } + return code +} diff --git a/main.go b/main.go index ad6db3c..552a83d 100644 --- a/main.go +++ b/main.go @@ -9,11 +9,11 @@ import ( "path/filepath" "time" - "arimelody-web/account/model" "arimelody-web/admin" "arimelody-web/api" "arimelody-web/global" "arimelody-web/view" + "arimelody-web/controller" "arimelody-web/templates" "github.com/jmoiron/sqlx" @@ -47,7 +47,7 @@ func main() { accountsCount := 0 global.DB.Get(&accountsCount, "SELECT count(*) FROM account") if accountsCount == 0 { - code := model.GenerateInviteCode(8) + code := controller.GenerateInviteCode(8) tx, err := global.DB.Begin() if err != nil { diff --git a/account/model/account.go b/model/account.go similarity index 80% rename from account/model/account.go rename to model/account.go index db32451..16b0e3b 100644 --- a/account/model/account.go +++ b/model/account.go @@ -1,7 +1,6 @@ package model import ( - "math/rand" "time" ) @@ -42,13 +41,3 @@ const ( ReadArtists AccountPrivilege = "read_artists" EditArtists AccountPrivilege = "edit_artists" ) - -var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -func GenerateInviteCode(length int) []byte { - code := []byte{} - for i := 0; i < length; i++ { - code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) - } - return code -} From ea3a386601c102b685c19364d839dc811bf11c37 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 9 Nov 2024 22:58:35 +0000 Subject: [PATCH 06/30] update docker compose: restart unless stopped --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index e29b006..69f9247 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: DISCORD_ADMIN: # your discord user ID. DISCORD_CLIENT: # your discord OAuth client ID. DISCORD_SECRET: # your discord OAuth secret. + depends_on: + - db + restart: unless-stopped db: image: postgres:16.1-alpine3.18 volumes: @@ -20,3 +23,4 @@ services: POSTGRES_DB: arimelody POSTGRES_USER: arimelody POSTGRES_PASSWORD: fuckingpassword + restart: unless-stopped From d5f1fcb5e06df729a741584d693e981626c39022 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 10:34:39 +0000 Subject: [PATCH 07/30] this is immensely broken but i swear i'll fix it later --- .dockerignore | 1 + .gitignore | 2 +- Dockerfile | 2 +- admin/admin.go | 8 +- admin/artisthttp.go | 8 +- admin/http.go | 44 ++++--- admin/releasehttp.go | 14 +- admin/templates.go | 5 + admin/trackhttp.go | 8 +- admin/views/create-account.html | 107 +++++++++++++++ admin/views/login.html | 72 ++++++++++- api/api.go | 11 +- api/artist.go | 15 +-- api/release.go | 114 ++++++++++++++-- api/track.go | 14 +- {music/controller => controller}/artist.go | 7 +- {music/controller => controller}/release.go | 4 +- {music/controller => controller}/track.go | 4 +- discord/discord.go | 18 +-- main.go | 4 +- {music/model => model}/artist.go | 0 {music/model => model}/credit.go | 0 {music/model => model}/link.go | 0 {music/model => model}/release.go | 0 {music/model => model}/track.go | 0 music/view/release.go | 136 -------------------- {music/view => view}/music.go | 38 +++++- views/music.html | 26 ++-- 28 files changed, 409 insertions(+), 253 deletions(-) create mode 100644 admin/views/create-account.html rename {music/controller => controller}/artist.go (94%) rename {music/controller => controller}/release.go (99%) rename {music/controller => controller}/track.go (98%) rename {music/model => model}/artist.go (100%) rename {music/model => model}/credit.go (100%) rename {music/model => model}/link.go (100%) rename {music/model => model}/release.go (100%) rename {music/model => model}/track.go (100%) delete mode 100644 music/view/release.go rename {music/view => view}/music.go (51%) diff --git a/.dockerignore b/.dockerignore index 0e34f0f..785d79e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ uploads/* test/ tmp/ +res/ docker-compose.yml Dockerfile schema.sql diff --git a/.gitignore b/.gitignore index 108e0e4..781b36e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ db/ tmp/ test/ -uploads/* +uploads/ docker-compose-test.yml diff --git a/Dockerfile b/Dockerfile index 278f01a..0e0d2a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web # --- -FROM build-stage AS build-release-stage +FROM scratch WORKDIR /app diff --git a/admin/admin.go b/admin/admin.go index 3c2aaae..f02274e 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -28,13 +28,7 @@ var ADMIN_BYPASS = func() bool { return false }() -var ADMIN_ID_DISCORD = func() string { - id := os.Getenv("DISCORD_ADMIN") - if id == "" { - fmt.Printf("WARN: Discord admin ID (DISCORD_ADMIN) was not provided. Admin login will be unavailable.\n") - } - return id -}() +var ADMIN_ID_DISCORD = os.Getenv("DISCORD_ADMIN") var sessions []*Session diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 4f66798..ba26240 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -6,15 +6,15 @@ import ( "strings" "arimelody-web/global" - "arimelody-web/music/model" - "arimelody-web/music/controller" + "arimelody-web/model" + "arimelody-web/controller" ) 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 := music.GetArtist(global.DB, id) + artist, err := controller.GetArtist(global.DB, id) if err != nil { if artist == nil { http.NotFound(w, r) @@ -25,7 +25,7 @@ func serveArtist() http.Handler { return } - credits, err := music.GetArtistCredits(global.DB, artist.ID, true) + credits, err := controller.GetArtistCredits(global.DB, artist.ID, true) if err != nil { fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/http.go b/admin/http.go index 35ff3f6..df348db 100644 --- a/admin/http.go +++ b/admin/http.go @@ -11,8 +11,8 @@ import ( "arimelody-web/discord" "arimelody-web/global" - musicDB "arimelody-web/music/controller" - musicModel "arimelody-web/music/model" + "arimelody-web/controller" + "arimelody-web/model" ) type loginData struct { @@ -24,6 +24,7 @@ func Handler() http.Handler { mux := http.NewServeMux() mux.Handle("/login", LoginHandler()) + mux.Handle("/create-account", createAccountHandler()) mux.Handle("/logout", MustAuthorise(LogoutHandler())) mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) @@ -41,21 +42,21 @@ func Handler() http.Handler { return } - releases, err := musicDB.GetAllReleases(global.DB, false, 0, true) + releases, err := controller.GetAllReleases(global.DB, false, 0, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := musicDB.GetAllArtists(global.DB) + artists, err := controller.GetAllArtists(global.DB) if err != nil { fmt.Printf("FATAL: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := musicDB.GetOrphanTracks(global.DB) + 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) @@ -63,9 +64,9 @@ func Handler() http.Handler { } type IndexData struct { - Releases []*musicModel.Release - Artists []*musicModel.Artist - Tracks []*musicModel.Track + Releases []*model.Release + Artists []*model.Artist + Tracks []*model.Track } err = pages["index"].Execute(w, IndexData{ @@ -149,14 +150,14 @@ func GetSession(r *http.Request) *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 - } - - fmt.Println(discord.CLIENT_ID) - fmt.Println(discord.API_ENDPOINT) - fmt.Println(discord.REDIRECT_URI) + // if !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" { + // http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + // return + // } + // + // fmt.Println(discord.CLIENT_ID) + // fmt.Println(discord.API_ENDPOINT) + // fmt.Println(discord.REDIRECT_URI) code := r.URL.Query().Get("code") @@ -239,6 +240,17 @@ func LogoutHandler() http.Handler { }) } +func createAccountHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := pages["create-account"].Execute(w, nil) + if err != nil { + fmt.Printf("Error rendering admin crearte account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) diff --git a/admin/releasehttp.go b/admin/releasehttp.go index bacaf01..54d0fd6 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -6,8 +6,8 @@ import ( "strings" "arimelody-web/global" - db "arimelody-web/music/controller" - "arimelody-web/music/model" + "arimelody-web/controller" + "arimelody-web/model" ) func serveRelease() http.Handler { @@ -15,7 +15,7 @@ func serveRelease() http.Handler { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - release, err := db.GetRelease(global.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) @@ -81,7 +81,7 @@ func serveEditCredits(release *model.Release) http.Handler { func serveAddCredit(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := db.GetArtistsNotOnRelease(global.DB, release.ID) + artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -108,7 +108,7 @@ func serveAddCredit(release *model.Release) 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 := db.GetArtist(global.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) @@ -152,7 +152,7 @@ func serveEditTracks(release *model.Release) http.Handler { func serveAddTrack(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := db.GetTracksNotOnRelease(global.DB, release.ID) + tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID) if err != nil { fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -180,7 +180,7 @@ func serveAddTrack(release *model.Release) 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 := db.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(global.DB, trackID) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/templates.go b/admin/templates.go index b7aaf9e..e91313a 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -18,6 +18,11 @@ var pages = map[string]*template.Template{ filepath.Join("views", "prideflag.html"), filepath.Join("admin", "views", "login.html"), )), + "create-account": template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "create-account.html"), + )), "logout": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"), diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 732d34a..148b7d8 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -6,15 +6,15 @@ import ( "strings" "arimelody-web/global" - "arimelody-web/music/model" - "arimelody-web/music/controller" + "arimelody-web/model" + "arimelody-web/controller" ) 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 := music.GetTrack(global.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) @@ -25,7 +25,7 @@ func serveTrack() http.Handler { return } - releases, err := music.GetTrackReleases(global.DB, track.ID, true) + releases, err := controller.GetTrackReleases(global.DB, track.ID, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/views/create-account.html b/admin/views/create-account.html new file mode 100644 index 0000000..1976d27 --- /dev/null +++ b/admin/views/create-account.html @@ -0,0 +1,107 @@ +{{define "head"}} +Register - ari melody 💫 + + + +{{end}} + +{{define "content"}} +
+ {{if .Success}} + + +

+ {{.Message}} + You should be redirected to /admin in 5 seconds. +

+ + {{else}} + + {{if .Message}} +

{{.Message}}

+ {{end}} + +
+
+ + + + + + + + + + + +
+ + +
+ + {{end}} +
+{{end}} diff --git a/admin/views/login.html b/admin/views/login.html index f615746..2c3e9bf 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -10,6 +10,61 @@ p a { a.discord { color: #5865F2; } + +form { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +form div { + width: 20rem; +} + +form button { + margin-top: 1rem; +} + +label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} +input { + width: 100%; + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} + +button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} {{end}} @@ -25,7 +80,22 @@ a.discord { {{else}} -

Log in with Discord.

+ + +
+
+ + + + + + + + +
+ + +
{{end}} diff --git a/api/api.go b/api/api.go index 699cf1c..4757a42 100644 --- a/api/api.go +++ b/api/api.go @@ -7,8 +7,7 @@ import ( "arimelody-web/admin" "arimelody-web/global" - music "arimelody-web/music/controller" - musicView "arimelody-web/music/view" + "arimelody-web/controller" ) func Handler() http.Handler { @@ -18,7 +17,7 @@ func Handler() http.Handler { 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 := music.GetArtist(global.DB, artistID) + artist, err := controller.GetArtist(global.DB, artistID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -60,7 +59,7 @@ func Handler() http.Handler { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var releaseID = strings.Split(r.URL.Path[1:], "/")[0] - release, err := music.GetRelease(global.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) @@ -74,7 +73,7 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/music/{id} - musicView.ServeRelease(release).ServeHTTP(w, r) + ServeRelease(release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) @@ -102,7 +101,7 @@ func Handler() http.Handler { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackID = strings.Split(r.URL.Path[1:], "/")[0] - track, err := music.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(global.DB, trackID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) diff --git a/api/artist.go b/api/artist.go index 90bd18a..9c88bc1 100644 --- a/api/artist.go +++ b/api/artist.go @@ -12,15 +12,14 @@ import ( "arimelody-web/admin" "arimelody-web/global" - db "arimelody-web/music/controller" - music "arimelody-web/music/controller" - "arimelody-web/music/model" + "arimelody-web/controller" + "arimelody-web/model" ) func ServeAllArtists() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} - artists, err := db.GetAllArtists(global.DB) + artists, err := controller.GetAllArtists(global.DB) if err != nil { fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -55,7 +54,7 @@ func ServeArtist(artist *model.Artist) http.Handler { show_hidden_releases := admin.GetSession(r) != nil var dbCredits []*model.Credit - dbCredits, err := db.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) + dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) if err != nil { fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -100,7 +99,7 @@ func CreateArtist() http.Handler { } if artist.Name == "" { artist.Name = artist.ID } - err = music.CreateArtist(global.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) @@ -148,7 +147,7 @@ func UpdateArtist(artist *model.Artist) http.Handler { } } - err = music.UpdateArtist(global.DB, artist) + err = controller.UpdateArtist(global.DB, artist) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -162,7 +161,7 @@ func UpdateArtist(artist *model.Artist) http.Handler { func DeleteArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := music.DeleteArtist(global.DB, artist.ID) + err := controller.DeleteArtist(global.DB, artist.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) diff --git a/api/release.go b/api/release.go index 3027a8c..e13bc93 100644 --- a/api/release.go +++ b/api/release.go @@ -12,13 +12,109 @@ import ( "arimelody-web/admin" "arimelody-web/global" - music "arimelody-web/music/controller" - "arimelody-web/music/model" + "arimelody-web/controller" + "arimelody-web/model" ) +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 + authorised := admin.GetSession(r) != nil + if !authorised && !release.Visible { + http.NotFound(w, r) + return + } + + type ( + Track struct { + Title string `json:"title"` + Description string `json:"description"` + Lyrics string `json:"lyrics"` + } + + Credit struct { + *model.Artist + Role string `json:"role"` + Primary bool `json:"primary"` + } + + Release struct { + *model.Release + Tracks []Track `json:"tracks"` + Credits []Credit `json:"credits"` + Links map[string]string `json:"links"` + } + ) + + response := Release{ + Release: release, + Tracks: []Track{}, + Credits: []Credit{}, + Links: make(map[string]string), + } + + if authorised || release.IsReleased() { + // get credits + credits, err := controller.GetReleaseCredits(global.DB, release.ID) + if err != nil { + 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(global.DB, credit.Artist.ID) + if err != nil { + fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + response.Credits = append(response.Credits, Credit{ + Artist: artist, + Role: credit.Role, + Primary: credit.Primary, + }) + } + + // get tracks + tracks, err := controller.GetReleaseTracks(global.DB, release.ID) + if err != nil { + fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + for _, track := range tracks { + response.Tracks = append(response.Tracks, Track{ + Title: track.Title, + Description: track.Description, + Lyrics: track.Lyrics, + }) + } + + // get links + links, err := controller.GetReleaseLinks(global.DB, release.ID) + if err != nil { + fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + for _, link := range links { + response.Links[link.Name] = link.URL + } + } + + w.Header().Add("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := music.GetAllReleases(global.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 @@ -95,7 +191,7 @@ func CreateRelease() http.Handler { if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } - err = music.CreateRelease(global.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) @@ -173,7 +269,7 @@ func UpdateRelease(release *model.Release) http.Handler { } } - err = music.UpdateRelease(global.DB, release) + err = controller.UpdateRelease(global.DB, release) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -194,7 +290,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler { return } - err = music.UpdateReleaseTracks(global.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) @@ -231,7 +327,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler { }) } - err = music.UpdateReleaseCredits(global.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) @@ -261,7 +357,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler { return } - err = music.UpdateReleaseLinks(global.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) @@ -275,7 +371,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler { func DeleteRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := music.DeleteRelease(global.DB, release.ID) + err := controller.DeleteRelease(global.DB, release.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) diff --git a/api/track.go b/api/track.go index fd4018b..71a67e9 100644 --- a/api/track.go +++ b/api/track.go @@ -6,8 +6,8 @@ import ( "net/http" "arimelody-web/global" - music "arimelody-web/music/controller" - "arimelody-web/music/model" + "arimelody-web/controller" + "arimelody-web/model" ) type ( @@ -26,7 +26,7 @@ func ServeAllTracks() http.Handler { var tracks = []Track{} var dbTracks = []*model.Track{} - dbTracks, err := music.GetAllTracks(global.DB) + dbTracks, err := controller.GetAllTracks(global.DB) if err != nil { fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -50,7 +50,7 @@ func ServeAllTracks() http.Handler { func ServeTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dbReleases, err := music.GetTrackReleases(global.DB, track.ID, false) + dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) if err != nil { 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) @@ -89,7 +89,7 @@ func CreateTrack() http.Handler { return } - id, err := music.CreateTrack(global.DB, &track) + id, err := controller.CreateTrack(global.DB, &track) if err != nil { fmt.Printf("FATAL: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -120,7 +120,7 @@ func UpdateTrack(track *model.Track) http.Handler { return } - err = music.UpdateTrack(global.DB, track) + err = controller.UpdateTrack(global.DB, track) if err != nil { fmt.Printf("Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -143,7 +143,7 @@ func DeleteTrack(track *model.Track) http.Handler { } var trackID = r.URL.Path[1:] - err := music.DeleteTrack(global.DB, trackID) + err := controller.DeleteTrack(global.DB, trackID) if err != nil { fmt.Printf("Failed to delete track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/music/controller/artist.go b/controller/artist.go similarity index 94% rename from music/controller/artist.go rename to controller/artist.go index bc9e656..c52b78d 100644 --- a/music/controller/artist.go +++ b/controller/artist.go @@ -1,7 +1,7 @@ -package music +package controller import ( - "arimelody-web/music/model" + "arimelody-web/model" "github.com/jmoiron/sqlx" ) @@ -45,7 +45,7 @@ func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, err } func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) { - var query string = "SELECT release.id,release.title,release.artwork,artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ + var query string = "SELECT release.id,title,artwork,release_date,artist.id,name,website,avatar,role,is_primary "+ "FROM musiccredit "+ "JOIN musicrelease AS release ON release=release.id "+ "JOIN artist ON artist=artist.id "+ @@ -69,6 +69,7 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model. &credit.Release.ID, &credit.Release.Title, &credit.Release.Artwork, + &credit.Release.ReleaseDate, &credit.Artist.ID, &credit.Artist.Name, &credit.Artist.Website, diff --git a/music/controller/release.go b/controller/release.go similarity index 99% rename from music/controller/release.go rename to controller/release.go index 2aeb236..e55fef0 100644 --- a/music/controller/release.go +++ b/controller/release.go @@ -1,10 +1,10 @@ -package music +package controller import ( "errors" "fmt" - "arimelody-web/music/model" + "arimelody-web/model" "github.com/jmoiron/sqlx" ) diff --git a/music/controller/track.go b/controller/track.go similarity index 98% rename from music/controller/track.go rename to controller/track.go index 3187553..d302045 100644 --- a/music/controller/track.go +++ b/controller/track.go @@ -1,7 +1,7 @@ -package music +package controller import ( - "arimelody-web/music/model" + "arimelody-web/model" "github.com/jmoiron/sqlx" ) diff --git a/discord/discord.go b/discord/discord.go index c3b3cec..0dbaebc 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -15,22 +15,8 @@ import ( const API_ENDPOINT = "https://discord.com/api/v10" var CREDENTIALS_PROVIDED = true -var CLIENT_ID = func() string { - id := os.Getenv("DISCORD_CLIENT") - if id == "" { - fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided. Admin login will be unavailable.\n") - CREDENTIALS_PROVIDED = false - } - return id -}() -var CLIENT_SECRET = func() string { - secret := os.Getenv("DISCORD_SECRET") - if secret == "" { - fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n") - CREDENTIALS_PROVIDED = false - } - return secret -}() +var CLIENT_ID = os.Getenv("DISCORD_CLIENT") +var CLIENT_SECRET = os.Getenv("DISCORD_SECRET") var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.HTTP_DOMAIN) 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) diff --git a/main.go b/main.go index c103696..f87d36e 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ import ( "arimelody-web/admin" "arimelody-web/api" "arimelody-web/global" - musicView "arimelody-web/music/view" + "arimelody-web/view" "arimelody-web/templates" "github.com/jmoiron/sqlx" @@ -49,7 +49,7 @@ func createServeMux() *http.ServeMux { mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) - mux.Handle("/music/", http.StripPrefix("/music", musicView.Handler())) + mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler("uploads"))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "/index.html" { diff --git a/music/model/artist.go b/model/artist.go similarity index 100% rename from music/model/artist.go rename to model/artist.go diff --git a/music/model/credit.go b/model/credit.go similarity index 100% rename from music/model/credit.go rename to model/credit.go diff --git a/music/model/link.go b/model/link.go similarity index 100% rename from music/model/link.go rename to model/link.go diff --git a/music/model/release.go b/model/release.go similarity index 100% rename from music/model/release.go rename to model/release.go diff --git a/music/model/track.go b/model/track.go similarity index 100% rename from music/model/track.go rename to model/track.go diff --git a/music/view/release.go b/music/view/release.go deleted file mode 100644 index fcb2b29..0000000 --- a/music/view/release.go +++ /dev/null @@ -1,136 +0,0 @@ -package view - -import ( - "encoding/json" - "fmt" - "net/http" - - "arimelody-web/admin" - "arimelody-web/global" - "arimelody-web/music/model" - db "arimelody-web/music/controller" - "arimelody-web/templates" -) - -type ( - Track struct { - Title string `json:"title"` - Description string `json:"description"` - Lyrics string `json:"lyrics"` - } - - Credit struct { - *model.Artist - Role string `json:"role"` - Primary bool `json:"primary"` - } - - Release struct { - *model.Release - Tracks []Track `json:"tracks"` - Credits []Credit `json:"credits"` - Links map[string]string `json:"links"` - } -) - -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 - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return - } - - response := Release{ - Release: release, - Tracks: []Track{}, - Credits: []Credit{}, - Links: make(map[string]string), - } - - if authorised || release.IsReleased() { - // get credits - credits, err := db.GetReleaseCredits(global.DB, release.ID) - if err != nil { - 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 := db.GetArtist(global.DB, credit.Artist.ID) - if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - response.Credits = append(response.Credits, Credit{ - Artist: artist, - Role: credit.Role, - Primary: credit.Primary, - }) - } - - // get tracks - tracks, err := db.GetReleaseTracks(global.DB, release.ID) - if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - for _, track := range tracks { - response.Tracks = append(response.Tracks, Track{ - Title: track.Title, - Description: track.Description, - Lyrics: track.Lyrics, - }) - } - - // get links - links, err := db.GetReleaseLinks(global.DB, release.ID) - if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - for _, link := range links { - response.Links[link.Name] = link.URL - } - } - - w.Header().Add("Content-Type", "application/json") - err := json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} - -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 - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return - } - - response := *release - - if authorised || release.IsReleased() { - response.Tracks = release.Tracks - response.Credits = release.Credits - response.Links = release.Links - } - - err := templates.Pages["music-gateway"].Execute(w, response) - - if err != nil { - fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} diff --git a/music/view/music.go b/view/music.go similarity index 51% rename from music/view/music.go rename to view/music.go index 6297a42..f38e1c2 100644 --- a/music/view/music.go +++ b/view/music.go @@ -4,15 +4,16 @@ import ( "fmt" "net/http" + "arimelody-web/admin" + "arimelody-web/controller" "arimelody-web/global" - music "arimelody-web/music/controller" - "arimelody-web/music/model" + "arimelody-web/model" "arimelody-web/templates" ) // HTTP HANDLER METHODS -func Handler() http.Handler { +func MusicHandler() http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -21,7 +22,7 @@ func Handler() http.Handler { return } - release, err := music.GetRelease(global.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 @@ -35,7 +36,7 @@ func Handler() http.Handler { func ServeCatalog() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := music.GetAllReleases(global.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) @@ -54,3 +55,30 @@ func ServeCatalog() 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 + authorised := admin.GetSession(r) != nil + if !authorised && !release.Visible { + http.NotFound(w, r) + return + } + + response := *release + + if authorised || release.IsReleased() { + response.Tracks = release.Tracks + response.Credits = release.Credits + response.Links = release.Links + } + + err := templates.Pages["music-gateway"].Execute(w, response) + + if err != nil { + fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/views/music.html b/views/music.html index 7473dc2..6f84672 100644 --- a/views/music.html +++ b/views/music.html @@ -59,11 +59,11 @@

- yes! well, in most cases... + yes!* in most cases...

- from Dream (2022) onward, all of my self-released songs are - licensed under Creative Commons Attribution-ShareAlike 4.0. + all of my self-released songs are licensed under + Creative Commons Attribution-ShareAlike 4.0. anyone may use and remix these songs freely, so long as they provide credit back to me and link back to this license! please note that all derivative works must inherit this license.

@@ -71,23 +71,17 @@ a great example of some credit text would be as follows:

- music used: mellodoot - Dream
- https://arimelody.me/music/dream
- licensed under CC BY-SA 4.0. + music used: ari melody - free2play
+ https://arimelody.me/music/free2play
+ licensed under CC BY-SA 4.0.

- for any songs prior to this, they were all either released by me (in which case, i honestly - don't mind), or in collaboration with chill people who i don't see having an issue with it. - do be sure to ask them about it, though! + if the song you want to use is not released by me (i.e. under a record label), their usage rights + will likely trump whatever i'd otherwise have in mind. i'll try to negotiate some nice terms, though!

- in the event the song you want to use is released under some other label, their usage rights - will more than likely trump whatever i'd otherwise have in mind. i'll try to negotiate some - nice terms, though! ;3 -

-

- i love the idea of other creators using my songs in their work, so if you do happen to use - my stuff in a work you're particularly proud of, feel free to send it my way! + i believe that encouraging creative use of artistic works is better than stifling any use at all. + if you do happen to use my work in something you're particularly proud of, feel free to send it my way!

> ari@arimelody.me From 570cdf6ce2249b8060bdc67bec9b685fce25c938 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 18:54:03 +0000 Subject: [PATCH 08/30] schema migration and account fixes very close to rolling this out! just need to address some security concerns first --- README.md | 16 +- admin/admin.go | 38 --- admin/http.go | 180 +++++++++++--- admin/views/create-account.html | 21 +- admin/views/layout.html | 4 + admin/views/login.html | 6 +- api/account.go | 89 ++++--- api/artist.go | 4 +- api/release.go | 4 +- controller/account.go | 83 ++++--- controller/invite.go | 67 +++++ controller/migrator.go | 86 +++++++ global/config.go | 31 +-- global/funcs.go | 22 +- main.go | 257 ++++++++------------ model/account.go | 13 +- model/invite.go | 10 + schema.sql => schema_migration/000-init.sql | 35 +-- schema_migration/001-pre-versioning.sql | 65 +++++ view/music.go | 2 +- 20 files changed, 641 insertions(+), 392 deletions(-) delete mode 100644 admin/admin.go create mode 100644 controller/invite.go create mode 100644 controller/migrator.go create mode 100644 model/invite.go rename schema.sql => schema_migration/000-init.sql (95%) create mode 100644 schema_migration/001-pre-versioning.sql diff --git a/README.md b/README.md index df0c351..0873ff6 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff! ## running the server should be run once to generate a default `config.toml` file. -configure as needed. note that a valid DB connection is required, and the admin -panel will be disabled without valid discord app credentials (this can however -be bypassed by running the server with `-adminBypass`). +configure as needed. a valid DB connection is required to run this website. +if no admin users exist, an invite code will be provided. invite codes are +the only way to create admin accounts at this time. the configuration may be overridden using environment variables in the format `ARIMELODY__`. for example, `db.host` in the config may @@ -32,6 +32,16 @@ be overridden with `ARIMELODY_DB_HOST`. the location of the configuration file can also be overridden with `ARIMELODY_CONFIG`. +## command arguments + +by default, `arimelody-web` will spin up a web server as usual. instead, +arguments may be supplied to run administrative actions. the web server doesn't +need to be up for this, making this ideal for some offline maintenance. + +- `createInvite`: Creates an invite code to register new accounts. +- `purgeInvites`: Deletes all available invite codes. +- `deleteAccount `: Deletes an account with a given `username`. + ## database the server requires a postgres database to run. you can use the diff --git a/admin/admin.go b/admin/admin.go deleted file mode 100644 index 92ba8d5..0000000 --- a/admin/admin.go +++ /dev/null @@ -1,38 +0,0 @@ -package admin - -import ( - "fmt" - "time" - - "arimelody-web/controller" - "arimelody-web/global" - "arimelody-web/model" -) - -type ( - Session struct { - Token string - Account *model.Account - Expires time.Time - } -) - -const TOKEN_LENGTH = 64 - -var ADMIN_BYPASS = func() bool { - if global.Args["adminBypass"] == "true" { - fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)") - return true - } - return false -}() - -var sessions []*Session - -func createSession(account *model.Account, expires time.Time) Session { - return Session{ - Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)), - Account: account, - Expires: expires, - } -} diff --git a/admin/http.go b/admin/http.go index d71213f..4dbc66b 100644 --- a/admin/http.go +++ b/admin/http.go @@ -26,8 +26,9 @@ func Handler() http.Handler { mux := http.NewServeMux() mux.Handle("/login", LoginHandler()) - mux.Handle("/create-account", createAccountHandler()) + mux.Handle("/register", createAccountHandler()) mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) + // TODO: /admin/account mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) @@ -96,7 +97,7 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { account, err := controller.GetAccountByRequest(db, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) return } if account == nil { @@ -117,7 +118,7 @@ func LoginHandler() http.Handler { account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) return } if account != nil { @@ -141,7 +142,6 @@ func LoginHandler() http.Handler { err := r.ParseForm() if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -151,21 +151,25 @@ func LoginHandler() http.Handler { Password string `json:"password"` TOTP string `json:"totp"` } - data := LoginRequest{ + credentials := LoginRequest{ Username: r.Form.Get("username"), Password: r.Form.Get("password"), TOTP: r.Form.Get("totp"), } - account, err := controller.GetAccount(global.DB, data.Username) + account, err := controller.GetAccount(global.DB, credentials.Username) if err != nil { - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + if account == nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) + http.Error(w, "Invalid username or password", http.StatusBadRequest) return } @@ -174,7 +178,7 @@ func LoginHandler() http.Handler { // login success! token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -209,24 +213,12 @@ func LogoutHandler() http.Handler { return } - token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + tokenStr := controller.GetTokenFromRequest(global.DB, r) - if token_str == "" { - cookie, err := r.Cookie(global.COOKIE_TOKEN) + if len(tokenStr) > 0 { + err := controller.DeleteToken(global.DB, tokenStr) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if cookie != nil { - token_str = cookie.Value - } - } - - if len(token_str) > 0 { - err := controller.DeleteToken(global.DB, token_str) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -248,9 +240,141 @@ func LogoutHandler() http.Handler { func createAccountHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := pages["create-account"].Execute(w, TemplateData{}) + checkAccount, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Printf("Error rendering create account page: %s\n", err) + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if checkAccount != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type CreateAccountResponse struct { + Account *model.Account + Message string + } + + render := func(data CreateAccountResponse) { + err := pages["create-account"].Execute(w, data) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + + if r.Method == http.MethodGet { + render(CreateAccountResponse{}) + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err = r.ParseForm() + if err != nil { + render(CreateAccountResponse{ + Message: "Malformed data.", + }) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure code exists in DB + invite, err := controller.GetInvite(global.DB, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + render(CreateAccountResponse{ + Message: "Invalid invite code.", + }) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: credentials.Email, + AvatarURL: "/img/default-avatar.png", + } + err = controller.CreateAccount(global.DB, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + render(CreateAccountResponse{ + Message: "An account with that username already exists.", + }) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + err = controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + + // registration success! + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + // gracefully redirect user to login page + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + err = pages["login"].Execute(w, TemplateData{ + Account: &account, + Token: token.Token, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/admin/views/create-account.html b/admin/views/create-account.html index 1976d27..5d92627 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -65,26 +65,23 @@ button:active { background: #d0d0d0; border-color: #808080; } + +#error { + background: #ffa9b8; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; +} {{end}} {{define "content"}}

- {{if .Success}} - - -

- {{.Message}} - You should be redirected to /admin in 5 seconds. -

- - {{else}} - {{if .Message}}

{{.Message}}

{{end}} -
+
@@ -101,7 +98,5 @@ button:active { - - {{end}}
{{end}} diff --git a/admin/views/layout.html b/admin/views/layout.html index dd12bf5..0a33b72 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -28,6 +28,10 @@ + {{else}} + {{end}} diff --git a/admin/views/login.html b/admin/views/login.html index de69be8..16c0fcc 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -43,6 +43,10 @@ input { font-family: inherit; color: inherit; } +input[disabled] { + opacity: .5; + cursor: not-allowed; +} button { padding: .5em .8em; @@ -89,7 +93,7 @@ button:active { - +
diff --git a/api/account.go b/api/account.go index 37c4c7f..3ce52c8 100644 --- a/api/account.go +++ b/api/account.go @@ -35,25 +35,35 @@ func handleLogin() http.HandlerFunc { account, err := controller.GetAccount(global.DB, credentials.Username) if err != nil { - if strings.Contains(err.Error(), "no rows") { - http.Error(w, "Invalid username or password", http.StatusBadRequest) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + if account == nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - // TODO: sessions and tokens + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + type LoginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } - w.WriteHeader(http.StatusOK) - w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) + err = json.NewEncoder(w).Encode(LoginResponse{ + Token: token.Token, + ExpiresAt: token.ExpiresAt, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } @@ -68,7 +78,7 @@ func handleAccountRegistration() http.HandlerFunc { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` - Code string `json:"code"` + Invite string `json:"invite"` } credentials := RegisterRequest{} @@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc { } // make sure code exists in DB - invite := model.Invite{} - err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code) + invite, err := controller.GetInvite(global.DB, credentials.Invite) if err != nil { - if strings.Contains(err.Error(), "no rows") { - http.Error(w, "Invalid invite code", http.StatusBadRequest) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + if invite == nil { + http.Error(w, "Invalid invite code", http.StatusBadRequest) + return + } if time.Now().After(invite.ExpiresAt) { + err := controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } http.Error(w, "Invalid invite code", http.StatusBadRequest) - _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } account := model.Account{ Username: credentials.Username, - Password: hashedPassword, + Password: string(hashedPassword), Email: credentials.Email, AvatarURL: "/img/default-avatar.png", } err = controller.CreateAccount(global.DB, &account) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + http.Error(w, "An account with that username already exists", http.StatusBadRequest) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } + err = controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - w.WriteHeader(http.StatusCreated) - w.Write([]byte("Account created successfully\n")) + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + type LoginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + + err = json.NewEncoder(w).Encode(LoginResponse{ + Token: token.Token, + ExpiresAt: token.ExpiresAt, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } @@ -151,20 +176,22 @@ func handleDeleteAccount() http.HandlerFunc { http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { - http.Error(w, "Invalid username or password", http.StatusBadRequest) + http.Error(w, "Invalid password", http.StatusBadRequest) return } - err = controller.DeleteAccount(global.DB, account.ID) + // TODO: check TOTP + + err = controller.DeleteAccount(global.DB, account.Username) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/api/artist.go b/api/artist.go index 6158fb6..c46db59 100644 --- a/api/artist.go +++ b/api/artist.go @@ -54,7 +54,7 @@ func ServeArtist(artist *model.Artist) http.Handler { account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -62,7 +62,7 @@ func ServeArtist(artist *model.Artist) http.Handler { dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) if err != nil { - fmt.Printf("WARN: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/api/release.go b/api/release.go index 5f9d590..d17fb5f 100644 --- a/api/release.go +++ b/api/release.go @@ -22,7 +22,7 @@ func ServeRelease(release *model.Release) http.Handler { if !release.Visible { account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -148,7 +148,7 @@ func ServeCatalog() http.Handler { catalog := []Release{} account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/controller/account.go b/controller/account.go index 856d493..362e297 100644 --- a/controller/account.go +++ b/controller/account.go @@ -5,7 +5,6 @@ import ( "arimelody-web/model" "errors" "fmt" - "math/rand" "net/http" "strings" @@ -17,6 +16,9 @@ func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { 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 } @@ -28,6 +30,9 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) { 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 } @@ -41,7 +46,7 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) if err != nil { - if err.Error() == "sql: no rows in result set" { + if strings.Contains(err.Error(), "no rows") { return nil, nil } return nil, err @@ -50,24 +55,28 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { return &account, nil } -func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { +func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - - if tokenStr == "" { - cookie, err := r.Cookie(global.COOKIE_TOKEN) - if err != nil { - // not logged in - return nil, nil - } - tokenStr = cookie.Value + if len(tokenStr) > 0 { + return tokenStr } + cookie, err := r.Cookie(global.COOKIE_TOKEN) + if err != nil { + return "" + } + return cookie.Value +} + +func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { + tokenStr := GetTokenFromRequest(db, r) + token, err := GetToken(db, tokenStr) if err != nil { - if strings.HasPrefix(err.Error(), "sql: no rows") { + if strings.Contains(err.Error(), "no rows") { return nil, nil } - return nil, errors.New(fmt.Sprintf("GetToken: %s", err.Error())) + return nil, errors.New("GetToken: " + err.Error()) } // does user-agent match the token? @@ -83,42 +92,36 @@ func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { } func CreateAccount(db *sqlx.DB, account *model.Account) error { - _, err := db.Exec( - "INSERT INTO account (username, password, email, avatar_url) " + - "VALUES ($1, $2, $3, $4)", - account.Username, - account.Password, - account.Email, - account.AvatarURL) + 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) + "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) +func DeleteAccount(db *sqlx.DB, username string) error { + _, err := db.Exec("DELETE FROM account WHERE username=$1", username) return err } - -var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -func GenerateInviteCode(length int) []byte { - code := []byte{} - for i := 0; i < length; i++ { - code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) - } - return code -} diff --git a/controller/invite.go b/controller/invite.go new file mode 100644 index 0000000..f30db64 --- /dev/null +++ b/controller/invite.go @@ -0,0 +1,67 @@ +package controller + +import ( + "arimelody-web/model" + "math/rand" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) { + invite := model.Invite{} + + err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &invite, nil +} + +func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) { + invite := model.Invite{ + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(lifetime), + } + + code := []byte{} + for i := 0; i < length; i++ { + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) + } + invite.Code = string(code) + + _, err := db.Exec( + "INSERT INTO invite (code, created_at, expires_at) " + + "VALUES ($1, $2, $3)", + invite.Code, + invite.CreatedAt, + invite.ExpiresAt, + ) + if err != nil { + return nil, err + } + + return &invite, nil +} + +func DeleteInvite(db *sqlx.DB, code string) error { + _, err := db.Exec("DELETE FROM invite WHERE code=$1", code) + return err +} + +func DeleteAllInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite") + return err +} + +func DeleteExpiredInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite WHERE expires_at 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 index 4bb3a15..49edb01 100644 --- a/global/funcs.go +++ b/global/funcs.go @@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler { type LoggingResponseWriter struct { http.ResponseWriter - Code int + Status int } -func (lrw *LoggingResponseWriter) WriteHeader(code int) { - lrw.Code = code - lrw.ResponseWriter.WriteHeader(code) +func (lrw *LoggingResponseWriter) WriteHeader(status int) { + lrw.Status = status + lrw.ResponseWriter.WriteHeader(status) } func HTTPLog(next http.Handler) http.Handler { @@ -81,19 +81,19 @@ func HTTPLog(next http.Handler) http.Handler { elapsed = strconv.Itoa(difference) } - codeColour := colour.Reset + statusColour := 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 } + 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, - codeColour, - lrw.Code, + statusColour, + lrw.Status, colour.Reset, elapsed, r.Header["User-Agent"][0]) diff --git a/main.go b/main.go index f50da24..2f0cb43 100644 --- a/main.go +++ b/main.go @@ -7,22 +7,28 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "arimelody-web/admin" "arimelody-web/api" - "arimelody-web/global" - "arimelody-web/view" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/templates" + "arimelody-web/view" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) +// 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") + // initialise database connection if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } @@ -65,39 +71,107 @@ func main() { global.DB.SetMaxIdleConns(10) defer global.DB.Close() - _, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP") - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) - os.Exit(1) + // handle command arguments + if len(os.Args) > 1 { + arg := os.Args[1] + + switch arg { + case "createInvite": + fmt.Printf("Creating invite...\n") + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) + return + + case "purgeInvites": + fmt.Printf("Deleting all invites...\n") + err := controller.DeleteAllInvites(global.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Invites deleted successfully.\n") + return + + case "deleteAccount": + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") + os.Exit(1) + } + username := os.Args[2] + fmt.Printf("Deleting account \"%s\"...\n", username) + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "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(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) + return + + } + + fmt.Printf( + "Available commands:\n\n" + + "createInvite:\n\tCreates an invite code to register new accounts.\n" + + "purgeInvites:\n\tDeletes all available invite codes.\n" + + "deleteAccount :\n\tDeletes an account with a given `username`.\n", + ) + return } - accountsCount := 0 - global.DB.Get(&accountsCount, "SELECT count(*) FROM account") - if accountsCount == 0 { - code := controller.GenerateInviteCode(8) + // handle DB migrations + controller.CheckDBVersionAndMigrate(global.DB) - tx, err := global.DB.Begin() - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err) - os.Exit(1) - } - _, err = tx.Exec("DELETE FROM invite") + // initial invite code + accountsCount := 0 + err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") + if err != nil { panic(err) } + if accountsCount == 0 { + _, err := global.DB.Exec("DELETE FROM invite") if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) os.Exit(1) } - _, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute)) + + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) - os.Exit(1) - } - err = tx.Commit() - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) os.Exit(1) } - fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") + fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl) + } + + // delete expired invites + err = controller.DeleteExpiredInvites(global.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) + os.Exit(1) } // start the web server! @@ -109,141 +183,6 @@ func main() { )) } -func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) { - db, err := sqlx.Connect(driverName, dataSourceName) - if err != nil { return nil, err } - - // ensure tables exist - // account - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS account (" + - "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + - "username text NOT NULL UNIQUE, " + - "password text NOT NULL, " + - "email text, " + - "avatar_url text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) } - - // privilege - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS privilege (" + - "account uuid NOT NULL, " + - "privilege text NOT NULL, " + - "CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " + - "CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) } - - // totp - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS totp (" + - "account uuid NOT NULL, " + - "name text NOT NULL, " + - "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "CONSTRAINT totp_pk PRIMARY KEY (account, name), " + - "CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } - - // invites - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS invite (" + - "code text NOT NULL PRIMARY KEY, " + - "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "expires_at TIMESTAMP NOT NULL)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } - - // account token - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS token (" + - "token TEXT PRIMARY KEY," + - "account UUID REFERENCES account(id) ON DELETE CASCADE NOT NULL," + - "user_agent TEXT NOT NULL," + - "created_at TIMESTAMP NOT NULL DEFAULT current_timestamp)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create token table: %s\n", err.Error())) } - - // artist - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS artist (" + - "id character varying(64) PRIMARY KEY, " + - "name text NOT NULL, " + - "website text, " + - "avatar text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) } - - // musicrelease - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS musicrelease (" + - "id character varying(64) PRIMARY KEY, " + - "visible bool DEFAULT false, " + - "title text NOT NULL, " + - "description text, " + - "type text, " + - "release_date TIMESTAMP NOT NULL, " + - "artwork text, " + - "buyname text, " + - "buylink text, " + - "copyright text, " + - "copyrightURL text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) } - - // musiclink - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musiclink (" + - "release character varying(64) NOT NULL, " + - "name text NOT NULL, " + - "url text NOT NULL, " + - "CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " + - "CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) } - - // musiccredit - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musiccredit (" + - "release character varying(64) NOT NULL, " + - "artist character varying(64) NOT NULL, " + - "role text NOT NULL, " + - "is_primary boolean DEFAULT false, " + - "CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " + - "CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + - "CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) } - - // musictrack - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musictrack (" + - "id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " + - "title text NOT NULL, " + - "description text, " + - "lyrics text, " + - "preview_url text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) } - - // musicreleasetrack - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" + - "release character varying(64) NOT NULL, " + - "track uuid NOT NULL, " + - "number integer NOT NULL, " + - "CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " + - "CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + - "CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) } - - // TODO: automatic database migration - - return db, nil -} - func createServeMux() *http.ServeMux { mux := http.NewServeMux() diff --git a/model/account.go b/model/account.go index 16b0e3b..03e95c5 100644 --- a/model/account.go +++ b/model/account.go @@ -1,27 +1,16 @@ package model -import ( - "time" -) - type ( Account struct { ID string `json:"id" db:"id"` Username string `json:"username" db:"username"` - Password []byte `json:"password" db:"password"` + Password string `json:"password" db:"password"` Email string `json:"email" db:"email"` AvatarURL string `json:"avatar_url" db:"avatar_url"` Privileges []AccountPrivilege `json:"privileges"` } AccountPrivilege string - - Invite struct { - Code string `db:"code"` - CreatedByID string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` - } ) const ( diff --git a/model/invite.go b/model/invite.go new file mode 100644 index 0000000..b7a66ae --- /dev/null +++ b/model/invite.go @@ -0,0 +1,10 @@ +package model + +import "time" + +type Invite struct { + Code string `db:"code"` + CreatedByID string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` +} diff --git a/schema.sql b/schema_migration/000-init.sql similarity index 95% rename from schema.sql rename to schema_migration/000-init.sql index f835044..cd11a5e 100644 --- a/schema.sql +++ b/schema_migration/000-init.sql @@ -1,8 +1,16 @@ -CREATE SCHEMA arimelody AUTHORIZATION arimelody; +CREATE SCHEMA arimelody; + +-- Schema verison +CREATE TABLE arimelody.schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT current_timestamp +); -- --- Acounts +-- Tables -- + +-- Accounts CREATE TABLE arimelody.account ( id uuid DEFAULT gen_random_uuid(), username text NOT NULL UNIQUE, @@ -12,18 +20,14 @@ CREATE TABLE arimelody.account ( ); 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); --- -- TOTP --- CREATE TABLE arimelody.totp ( account uuid NOT NULL, name text NOT NULL, @@ -31,9 +35,7 @@ CREATE TABLE arimelody.totp ( ); ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); --- -- Invites --- CREATE TABLE arimelody.invite ( code text NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -41,9 +43,7 @@ CREATE TABLE arimelody.invite ( ); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); --- -- Tokens --- CREATE TABLE arimelody.token ( token TEXT, account UUID NOT NULL, @@ -54,9 +54,7 @@ CREATE TABLE arimelody.token ( ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); --- -- Artists (should be applicable to all art) --- CREATE TABLE arimelody.artist ( id character varying(64), name text NOT NULL, @@ -65,9 +63,7 @@ CREATE TABLE arimelody.artist ( ); ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); --- -- Music releases --- CREATE TABLE arimelody.musicrelease ( id character varying(64) NOT NULL, visible bool DEFAULT false, @@ -83,9 +79,7 @@ CREATE TABLE arimelody.musicrelease ( ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); --- -- Music links (external platform links under a release) --- CREATE TABLE arimelody.musiclink ( release character varying(64) NOT NULL, name text NOT NULL, @@ -93,9 +87,7 @@ CREATE TABLE arimelody.musiclink ( ); ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); --- -- Music credits (artist credits under a release) --- CREATE TABLE arimelody.musiccredit ( release character varying(64) NOT NULL, artist character varying(64) NOT NULL, @@ -104,9 +96,7 @@ CREATE TABLE arimelody.musiccredit ( ); ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); --- -- Music tracks (tracks under a release) --- CREATE TABLE arimelody.musictrack ( id uuid DEFAULT gen_random_uuid(), title text NOT NULL, @@ -116,9 +106,7 @@ CREATE TABLE arimelody.musictrack ( ); ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); --- -- Music release/track pairs --- CREATE TABLE arimelody.musicreleasetrack ( release character varying(64) NOT NULL, track uuid NOT NULL, @@ -126,9 +114,12 @@ 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.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql new file mode 100644 index 0000000..fc730a0 --- /dev/null +++ b/schema_migration/001-pre-versioning.sql @@ -0,0 +1,65 @@ +-- +-- Migration +-- + +-- Move existing tables to new schema +ALTER TABLE public.artist SET SCHEMA arimelody; +ALTER TABLE public.musicrelease SET SCHEMA arimelody; +ALTER TABLE public.musiclink SET SCHEMA arimelody; +ALTER TABLE public.musiccredit SET SCHEMA arimelody; +ALTER TABLE public.musictrack SET SCHEMA arimelody; +ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody; + + + +-- +-- New items +-- + +-- Acounts +CREATE TABLE arimelody.account ( + id uuid DEFAULT gen_random_uuid(), + username text NOT NULL UNIQUE, + password text NOT NULL, + email text, + avatar_url text +); +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); + +-- TOTP +CREATE TABLE arimelody.totp ( + account uuid NOT NULL, + name text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + +-- Invites +CREATE TABLE arimelody.invite ( + code text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); +ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); + +-- Tokens +CREATE TABLE arimelody.token ( + token TEXT, + account UUID NOT NULL, + user_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP DEFAULT NULL +); +ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); + +-- Foreign keys +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; diff --git a/view/music.go b/view/music.go index 95fe38f..3799182 100644 --- a/view/music.go +++ b/view/music.go @@ -63,7 +63,7 @@ func ServeGateway(release *model.Release) http.Handler { if !release.Visible { account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } From be5cd05d08e63ff9dac0d0f452b7687e179ecfdd Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 19:02:26 +0000 Subject: [PATCH 09/30] disable api account endpoints (for now) --- api/api.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/api.go b/api/api.go index af6c6a7..30ef73e 100644 --- a/api/api.go +++ b/api/api.go @@ -15,9 +15,17 @@ func Handler() http.Handler { // ACCOUNT ENDPOINTS + /* + // temporarily disabling these + // accounts should really be handled via the frontend rn, and juggling + // two different token bearer methods kinda sucks!! + // i'll look into generating API tokens on the frontend in the future + // TODO: generate API keys on the frontend + mux.Handle("/v1/login", handleLogin()) mux.Handle("/v1/register", handleAccountRegistration()) mux.Handle("/v1/delete-account", handleDeleteAccount()) + */ // ARTIST ENDPOINTS From 5e493554dc6fe2b847f8efb02ab1ab5c8b606302 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 19:11:16 +0000 Subject: [PATCH 10/30] listAccounts command --- README.md | 1 + controller/account.go | 11 ++++++++++ main.go | 27 +++++++++++++++++++++++-- model/account.go | 14 ++++++++----- schema_migration/000-init.sql | 3 ++- schema_migration/001-pre-versioning.sql | 3 ++- 6 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0873ff6..81bc52f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ need to be up for this, making this ideal for some offline maintenance. - `createInvite`: Creates an invite code to register new accounts. - `purgeInvites`: Deletes all available invite codes. +- `listAccounts`: Lists all active accounts. - `deleteAccount `: Deletes an account with a given `username`. ## database diff --git a/controller/account.go b/controller/account.go index 362e297..3547d35 100644 --- a/controller/account.go +++ b/controller/account.go @@ -11,6 +11,17 @@ import ( "github.com/jmoiron/sqlx" ) +func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) { + var accounts = []model.Account{} + + err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC") + if err != nil { + return nil, err + } + + return accounts, nil +} + func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { var account = model.Account{} diff --git a/main.go b/main.go index 2f0cb43..fb83494 100644 --- a/main.go +++ b/main.go @@ -98,6 +98,27 @@ func main() { fmt.Printf("Invites deleted successfully.\n") return + case "listAccounts": + accounts, err := controller.GetAllAccounts(global.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err) + os.Exit(1) + } + + for _, account := range accounts { + fmt.Printf( + "User: %s\n" + + "\tID: %s\n" + + "\tEmail: %s\n" + + "\tCreated: %s\n", + account.Username, + account.ID, + account.Email, + account.CreatedAt, + ) + } + return + case "deleteAccount": if len(os.Args) < 2 { fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") @@ -108,7 +129,7 @@ func main() { account, err := controller.GetAccount(global.DB, username) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } @@ -135,10 +156,12 @@ func main() { } - fmt.Printf( + // command help + fmt.Print( "Available commands:\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 diff --git a/model/account.go b/model/account.go index 03e95c5..031cae9 100644 --- a/model/account.go +++ b/model/account.go @@ -1,12 +1,16 @@ package model +import "time" + type ( Account struct { - ID string `json:"id" db:"id"` - Username string `json:"username" db:"username"` - Password string `json:"password" db:"password"` - Email string `json:"email" db:"email"` - AvatarURL string `json:"avatar_url" db:"avatar_url"` + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Password string `json:"password" db:"password"` + Email string `json:"email" db:"email"` + AvatarURL string `json:"avatar_url" db:"avatar_url"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + Privileges []AccountPrivilege `json:"privileges"` } diff --git a/schema_migration/000-init.sql b/schema_migration/000-init.sql index cd11a5e..00a7eb2 100644 --- a/schema_migration/000-init.sql +++ b/schema_migration/000-init.sql @@ -16,7 +16,8 @@ CREATE TABLE arimelody.account ( username text NOT NULL UNIQUE, password text NOT NULL, email text, - avatar_url text + avatar_url text, + created_at TIMESTAMP DEFAULT current_timestamp ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql index fc730a0..8f5e210 100644 --- a/schema_migration/001-pre-versioning.sql +++ b/schema_migration/001-pre-versioning.sql @@ -22,7 +22,8 @@ CREATE TABLE arimelody.account ( username text NOT NULL UNIQUE, password text NOT NULL, email text, - avatar_url text + avatar_url text, + created_at TIMESTAMP DEFAULT current_timestamp ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); From ae254dd731a7b0ce0581ab3699e97c9c09ed7029 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 23:49:54 +0000 Subject: [PATCH 11/30] totp codes don't seem to sync but they're here!! --- admin/views/edit-account.html | 63 +++++++++++ admin/views/login.html | 6 +- controller/totp.go | 108 ++++++++++++++++++ main.go | 144 +++++++++++++++++++++++- model/totp.go | 12 ++ schema_migration/000-init.sql | 20 ++-- schema_migration/001-pre-versioning.sql | 19 ++-- 7 files changed, 345 insertions(+), 27 deletions(-) create mode 100644 admin/views/edit-account.html create mode 100644 controller/totp.go create mode 100644 model/totp.go diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html new file mode 100644 index 0000000..4096d60 --- /dev/null +++ b/admin/views/edit-account.html @@ -0,0 +1,63 @@ +{{define "head"}} +Account Settings - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+

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

+ +
+

Change Password

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

MFA Devices

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

{{.Name}}

+

{{.CreatedAt}}

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

You have no MFA devices.

+ {{end}} + + Add MFA Device +
+ +
+

Danger Zone

+
+
+

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

+ +
+ +
+ + +{{end}} diff --git a/admin/views/login.html b/admin/views/login.html index 16c0fcc..f7edc9b 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -87,13 +87,13 @@ button:active {
- + - + - +
diff --git a/controller/totp.go b/controller/totp.go new file mode 100644 index 0000000..008634b --- /dev/null +++ b/controller/totp.go @@ -0,0 +1,108 @@ +package controller + +import ( + "arimelody-web/model" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "fmt" + "math" + "net/url" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +const TIME_STEP int64 = 30 +const CODE_LENGTH = 6 + +func GenerateTOTP(secret string, timeStepOffset int) string { + counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) + + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write(counterBytes) + hash := mac.Sum(nil) + + offset := hash[len(hash) - 1] & 0x0f + binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) + code := binaryCode % int32(math.Pow10(CODE_LENGTH)) + + return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) +} + +func GenerateTOTPURI(username string, secret string) string { + url := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username), + } + + query := url.Query() + query.Set("secret", secret) + query.Set("issuer", "arimelody.me") + query.Set("algorithm", "SHA1") + query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) + query.Set("period", fmt.Sprintf("%d", TIME_STEP)) + url.RawQuery = query.Encode() + + return url.String() +} + +func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) { + totps := []model.TOTP{} + + err := db.Select( + &totps, + "SELECT * FROM totp " + + "WHERE account=$1 " + + "ORDER BY created_at ASC", + accountID, + ) + if err != nil { + return nil, err + } + + return totps, nil +} + +func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { + totp := model.TOTP{} + + err := db.Get( + &totp, + "SELECT * FROM totp " + + "WHERE account=$1", + accountID, + ) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &totp, nil +} + +func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error { + _, err := db.Exec( + "INSERT INTO totp (account, name, secret) " + + "VALUES ($1,$2,$3)", + totp.AccountID, + totp.Name, + totp.Secret, + ) + return err +} + +func DeleteTOTP(db *sqlx.DB, accountID string, name string) error { + _, err := db.Exec( + "DELETE FROM totp WHERE account=$1 AND name=$2", + accountID, + name, + ) + return err +} diff --git a/main.go b/main.go index fb83494..aa3cff6 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "arimelody-web/api" "arimelody-web/controller" "arimelody-web/global" + "arimelody-web/model" "arimelody-web/templates" "arimelody-web/view" @@ -30,10 +31,6 @@ func main() { fmt.Printf("made with <3 by ari melody\n\n") // initialise database connection - if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } - if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } - if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env } - if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env } if global.Config.DB.Host == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) @@ -76,6 +73,136 @@ func main() { arg := os.Args[1] switch arg { + case "createTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + secret := controller.GenerateAlnumString(32) + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totp := model.TOTP { + AccountID: account.ID, + Name: totpName, + Secret: string(secret), + } + + err = controller.CreateTOTP(global.DB, &totp) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) + os.Exit(1) + } + + url := controller.GenerateTOTPURI(account.Username, totp.Secret) + fmt.Printf("%s\n", url) + return + + case "deleteTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + err = controller.DeleteTOTP(global.DB, account.ID, totpName) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) + os.Exit(1) + } + + fmt.Printf("TOTP method \"%s\" deleted.\n", totpName) + return + + case "listTOTP": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totps, err := controller.GetTOTPsForAccount(global.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err) + os.Exit(1) + } + + for i, totp := range totps { + fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) + } + if len(totps) == 0 { + fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username) + } + return + + case "testTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totp, err := controller.GetTOTP(global.DB, account.ID, totpName) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err) + os.Exit(1) + } + + if totp == nil { + fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) + os.Exit(1) + } + + code := controller.GenerateTOTP(totp.Secret, 0) + fmt.Printf("%s\n", code) + return + case "createInvite": fmt.Printf("Creating invite...\n") invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) @@ -120,8 +247,8 @@ func main() { return case "deleteAccount": - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") os.Exit(1) } username := os.Args[2] @@ -159,6 +286,11 @@ func main() { // command help fmt.Print( "Available commands:\n\n" + + "createTOTP :\n\tCreates a timed one-time passcode method.\n" + + "listTOTP :\n\tLists an account's TOTP methods.\n" + + "deleteTOTP :\n\tDeletes an account's TOTP method.\n" + + "testTOTP :\n\tGenerates the code for an account's TOTP method.\n" + + "\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" + "listAccounts:\n\tLists all active accounts.\n", diff --git a/model/totp.go b/model/totp.go new file mode 100644 index 0000000..8d8422f --- /dev/null +++ b/model/totp.go @@ -0,0 +1,12 @@ +package model + +import ( + "time" +) + +type TOTP struct { + Name string `json:"name" db:"name"` + AccountID string `json:"accountID" db:"account"` + Secret string `json:"-" db:"secret"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/schema_migration/000-init.sql b/schema_migration/000-init.sql index 00a7eb2..2c6e5b1 100644 --- a/schema_migration/000-init.sql +++ b/schema_migration/000-init.sql @@ -28,14 +28,6 @@ CREATE TABLE arimelody.privilege ( ); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); --- TOTP -CREATE TABLE arimelody.totp ( - account uuid NOT NULL, - name text NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); - -- Invites CREATE TABLE arimelody.invite ( code text NOT NULL, @@ -54,6 +46,16 @@ CREATE TABLE arimelody.token ( ); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); +-- TOTPs +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + + -- Artists (should be applicable to all art) CREATE TABLE arimelody.artist ( @@ -122,8 +124,8 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM -- ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql index 8f5e210..62bc15b 100644 --- a/schema_migration/001-pre-versioning.sql +++ b/schema_migration/001-pre-versioning.sql @@ -34,14 +34,6 @@ CREATE TABLE arimelody.privilege ( ); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); --- TOTP -CREATE TABLE arimelody.totp ( - account uuid NOT NULL, - name text NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); - -- Invites CREATE TABLE arimelody.invite ( code text NOT NULL, @@ -60,7 +52,16 @@ CREATE TABLE arimelody.token ( ); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); +-- TOTPs +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + -- Foreign keys ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; From 7044f7344be363678a230b090cf7b31af794f119 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 21 Jan 2025 00:20:07 +0000 Subject: [PATCH 12/30] very rough updates to admin pages, reduced reliance on global.DB --- admin/accounthttp.go | 38 ++++++++++++++++++++++++ admin/http.go | 52 ++++++++++++++++----------------- admin/static/edit-account.css | 41 ++++++++++++++++++++++++++ admin/static/index.css | 45 ++++++++++++++++++++++++++++ admin/templates.go | 5 ++++ admin/views/create-account.html | 24 +-------------- admin/views/edit-account.html | 5 ++-- admin/views/edit-artist.html | 1 - admin/views/edit-release.html | 1 - admin/views/edit-track.html | 2 +- admin/views/login.html | 22 -------------- api/api.go | 33 +++++++++++---------- api/track.go | 4 +-- main.go | 6 ++-- view/music.go | 19 ++++++------ 15 files changed, 192 insertions(+), 106 deletions(-) create mode 100644 admin/accounthttp.go create mode 100644 admin/static/edit-account.css diff --git a/admin/accounthttp.go b/admin/accounthttp.go new file mode 100644 index 0000000..56aa247 --- /dev/null +++ b/admin/accounthttp.go @@ -0,0 +1,38 @@ +package admin + +import ( + "fmt" + "net/http" + + "arimelody-web/controller" + "arimelody-web/model" + + "github.com/jmoiron/sqlx" +) + +func AccountHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account := r.Context().Value("account").(*model.Account) + + totps, err := controller.GetTOTPsForAccount(db, account.ID) + if err != nil { + fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + type AccountResponse struct { + Account *model.Account + TOTPs []model.TOTP + } + + err = pages["account"].Execute(w, AccountResponse{ + Account: account, + TOTPs: totps, + }) + if err != nil { + fmt.Printf("WARN: Failed to render admin account page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + diff --git a/admin/http.go b/admin/http.go index 4dbc66b..9eae2b7 100644 --- a/admin/http.go +++ b/admin/http.go @@ -22,24 +22,24 @@ type TemplateData struct { Token string } -func Handler() http.Handler { +func Handler(db *sqlx.DB) http.Handler { mux := http.NewServeMux() - mux.Handle("/login", LoginHandler()) - mux.Handle("/register", createAccountHandler()) - mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) - // TODO: /admin/account + mux.Handle("/login", LoginHandler(db)) + mux.Handle("/register", createAccountHandler(db)) + mux.Handle("/logout", RequireAccount(db, LogoutHandler(db))) + mux.Handle("/account", RequireAccount(db, AccountHandler(db))) mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) - mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) - mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack()))) + mux.Handle("/release/", RequireAccount(db, http.StripPrefix("/release", serveRelease()))) + mux.Handle("/artist/", RequireAccount(db, http.StripPrefix("/artist", serveArtist()))) + mux.Handle("/track/", RequireAccount(db, http.StripPrefix("/track", serveTrack()))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - account, err := controller.GetAccountByRequest(global.DB, r) + account, err := controller.GetAccountByRequest(db, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) } @@ -48,21 +48,21 @@ func Handler() http.Handler { return } - releases, err := controller.GetAllReleases(global.DB, false, 0, true) + releases, err := controller.GetAllReleases(db, false, 0, true) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(global.DB) + artists, err := controller.GetAllArtists(db) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := controller.GetOrphanTracks(global.DB) + tracks, err := controller.GetOrphanTracks(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) @@ -112,10 +112,10 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { }) } -func LoginHandler() http.Handler { +func LoginHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - account, err := controller.GetAccountByRequest(global.DB, r) + account, err := controller.GetAccountByRequest(db, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) @@ -157,7 +157,7 @@ func LoginHandler() http.Handler { TOTP: r.Form.Get("totp"), } - account, err := controller.GetAccount(global.DB, credentials.Username) + account, err := controller.GetAccount(db, credentials.Username) if err != nil { http.Error(w, "Invalid username or password", http.StatusBadRequest) return @@ -176,7 +176,7 @@ func LoginHandler() http.Handler { // TODO: check TOTP // login success! - token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -206,17 +206,17 @@ func LoginHandler() http.Handler { }) } -func LogoutHandler() http.Handler { +func LogoutHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } - tokenStr := controller.GetTokenFromRequest(global.DB, r) + tokenStr := controller.GetTokenFromRequest(db, r) if len(tokenStr) > 0 { - err := controller.DeleteToken(global.DB, tokenStr) + err := controller.DeleteToken(db, tokenStr) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -238,9 +238,9 @@ func LogoutHandler() http.Handler { }) } -func createAccountHandler() http.Handler { +func createAccountHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checkAccount, err := controller.GetAccountByRequest(global.DB, r) + checkAccount, err := controller.GetAccountByRequest(db, r) if err != nil { fmt.Printf("WARN: Failed to fetch account: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -297,7 +297,7 @@ func createAccountHandler() http.Handler { } // make sure code exists in DB - invite, err := controller.GetInvite(global.DB, credentials.Invite) + invite, err := controller.GetInvite(db, credentials.Invite) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) render(CreateAccountResponse{ @@ -307,7 +307,7 @@ func createAccountHandler() http.Handler { } if invite == nil || time.Now().After(invite.ExpiresAt) { if invite != nil { - err := controller.DeleteInvite(global.DB, invite.Code) + err := controller.DeleteInvite(db, invite.Code) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } } render(CreateAccountResponse{ @@ -331,7 +331,7 @@ func createAccountHandler() http.Handler { Email: credentials.Email, AvatarURL: "/img/default-avatar.png", } - err = controller.CreateAccount(global.DB, &account) + err = controller.CreateAccount(db, &account) if err != nil { if strings.HasPrefix(err.Error(), "pq: duplicate key") { render(CreateAccountResponse{ @@ -346,11 +346,11 @@ func createAccountHandler() http.Handler { return } - err = controller.DeleteInvite(global.DB, invite.Code) + err = controller.DeleteInvite(db, invite.Code) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } // registration success! - token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) // gracefully redirect user to login page diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css new file mode 100644 index 0000000..7c75b66 --- /dev/null +++ b/admin/static/edit-account.css @@ -0,0 +1,41 @@ +@import url("/admin/static/index.css"); + +form#change-password { + width: 100%; + display: flex; + flex-direction: column; + align-items: start; +} + +form div { + width: 20rem; +} + +form button { + margin-top: 1rem; +} + +label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} +input { + width: 100%; + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} + +#error { + background: #ffa9b8; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; +} diff --git a/admin/static/index.css b/admin/static/index.css index ec426af..1411eff 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -99,3 +99,48 @@ opacity: 0.75; } + + +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; +} diff --git a/admin/templates.go b/admin/templates.go index e91313a..1fa7a65 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -28,6 +28,11 @@ var pages = map[string]*template.Template{ filepath.Join("views", "prideflag.html"), filepath.Join("admin", "views", "logout.html"), )), + "account": template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), + )), "release": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), diff --git a/admin/views/create-account.html b/admin/views/create-account.html index 5d92627..b0aff03 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -1,7 +1,7 @@ {{define "head"}} Register - ari melody 💫 - + {{end}} diff --git a/api/api.go b/api/api.go index 30ef73e..26e2255 100644 --- a/api/api.go +++ b/api/api.go @@ -6,11 +6,12 @@ import ( "strings" "arimelody-web/admin" - "arimelody-web/global" "arimelody-web/controller" + + "github.com/jmoiron/sqlx" ) -func Handler() http.Handler { +func Handler(db *sqlx.DB) http.Handler { mux := http.NewServeMux() // ACCOUNT ENDPOINTS @@ -31,7 +32,7 @@ func Handler() http.Handler { mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artistID = strings.Split(r.URL.Path[1:], "/")[0] - artist, err := controller.GetArtist(global.DB, artistID) + artist, err := controller.GetArtist(db, artistID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -48,10 +49,10 @@ func Handler() http.Handler { ServeArtist(artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r) + admin.RequireAccount(db, UpdateArtist(artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r) + admin.RequireAccount(db, DeleteArtist(artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -63,7 +64,7 @@ func Handler() http.Handler { ServeAllArtists().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r) + admin.RequireAccount(db, CreateArtist()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -73,7 +74,7 @@ func Handler() http.Handler { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var releaseID = strings.Split(r.URL.Path[1:], "/")[0] - release, err := controller.GetRelease(global.DB, releaseID, true) + release, err := controller.GetRelease(db, releaseID, true) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -90,10 +91,10 @@ func Handler() http.Handler { ServeRelease(release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r) + admin.RequireAccount(db, UpdateRelease(release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r) + admin.RequireAccount(db, DeleteRelease(release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -105,7 +106,7 @@ func Handler() http.Handler { ServeCatalog().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r) + admin.RequireAccount(db, CreateRelease()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -115,7 +116,7 @@ func Handler() http.Handler { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackID = strings.Split(r.URL.Path[1:], "/")[0] - track, err := controller.GetTrack(global.DB, trackID) + track, err := controller.GetTrack(db, trackID) if err != nil { if strings.Contains(err.Error(), "no rows") { http.NotFound(w, r) @@ -129,13 +130,13 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(db, ServeTrack(track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(db, UpdateTrack(track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(db, DeleteTrack(track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -144,10 +145,10 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r) + admin.RequireAccount(db, ServeAllTracks()).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r) + admin.RequireAccount(db, CreateTrack()).ServeHTTP(w, r) default: http.NotFound(w, r) } diff --git a/api/track.go b/api/track.go index ebbaa10..8727b4f 100644 --- a/api/track.go +++ b/api/track.go @@ -126,7 +126,7 @@ func UpdateTrack(track *model.Track) http.Handler { err = controller.UpdateTrack(global.DB, track) if err != nil { - fmt.Printf("Failed to update track %s: %s\n", track.ID, err) + fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -151,7 +151,7 @@ func DeleteTrack(track *model.Track) http.Handler { var trackID = r.URL.Path[1:] err := controller.DeleteTrack(global.DB, trackID) if err != nil { - fmt.Printf("Failed to delete track %s: %s\n", trackID, err) + fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/main.go b/main.go index aa3cff6..7cf3363 100644 --- a/main.go +++ b/main.go @@ -341,9 +341,9 @@ func main() { func createServeMux() *http.ServeMux { mux := http.NewServeMux() - mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) - mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) - mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) + mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(global.DB))) + mux.Handle("/api/", http.StripPrefix("/api", api.Handler(global.DB))) + mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(global.DB))) 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 { diff --git a/view/music.go b/view/music.go index 3799182..227aa5c 100644 --- a/view/music.go +++ b/view/music.go @@ -6,37 +6,38 @@ import ( "os" "arimelody-web/controller" - "arimelody-web/global" "arimelody-web/model" "arimelody-web/templates" + + "github.com/jmoiron/sqlx" ) // HTTP HANDLER METHODS -func MusicHandler() http.Handler { +func MusicHandler(db *sqlx.DB) http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - ServeCatalog().ServeHTTP(w, r) + ServeCatalog(db).ServeHTTP(w, r) return } - release, err := controller.GetRelease(global.DB, r.URL.Path[1:], true) + release, err := controller.GetRelease(db, r.URL.Path[1:], true) if err != nil { http.NotFound(w, r) return } - ServeGateway(release).ServeHTTP(w, r) + ServeGateway(db, release).ServeHTTP(w, r) })) return mux } -func ServeCatalog() http.Handler { +func ServeCatalog(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(global.DB, true, 0, true) + releases, err := controller.GetAllReleases(db, true, 0, true) if err != nil { fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -56,12 +57,12 @@ func ServeCatalog() http.Handler { }) } -func ServeGateway(release *model.Release) http.Handler { +func ServeGateway(db *sqlx.DB, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases privileged := false if !release.Visible { - account, err := controller.GetAccountByRequest(global.DB, r) + account, err := controller.GetAccountByRequest(db, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) From 39b332b477e85d8688b8d58a50c6339a53f87fe6 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 21 Jan 2025 00:30:43 +0000 Subject: [PATCH 13/30] working TOTP codes YIPPEE --- controller/totp.go | 23 ++++++++++++++++++++++- main.go | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/controller/totp.go b/controller/totp.go index 008634b..18616da 100644 --- a/controller/totp.go +++ b/controller/totp.go @@ -3,26 +3,35 @@ package controller import ( "arimelody-web/model" "crypto/hmac" + "crypto/rand" "crypto/sha1" + "encoding/base32" "encoding/binary" "fmt" "math" "net/url" + "os" "strings" "time" "github.com/jmoiron/sqlx" ) +const TOTP_SECRET_LENGTH = 32 const TIME_STEP int64 = 30 const CODE_LENGTH = 6 func GenerateTOTP(secret string, timeStepOffset int) string { + decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") + } + counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) counterBytes := make([]byte, 8) binary.BigEndian.PutUint64(counterBytes, uint64(counter)) - mac := hmac.New(sha1.New, []byte(secret)) + mac := hmac.New(sha1.New, []byte(decodedSecret)) mac.Write(counterBytes) hash := mac.Sum(nil) @@ -33,6 +42,18 @@ func GenerateTOTP(secret string, timeStepOffset int) string { return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) } +func GenerateTOTPSecret(length int) string { + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + panic("FATAL: Failed to generate random TOTP bytes") + } + + secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes) + + return strings.ToUpper(secret) +} + func GenerateTOTPURI(username string, secret string) string { url := url.URL{ Scheme: "otpauth", diff --git a/main.go b/main.go index 7cf3363..e257da5 100644 --- a/main.go +++ b/main.go @@ -80,7 +80,7 @@ func main() { } username := os.Args[2] totpName := os.Args[3] - secret := controller.GenerateAlnumString(32) + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) account, err := controller.GetAccount(global.DB, username) if err != nil { From 686eea09a5f99558e37b534a081a7835bde1f964 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 21 Jan 2025 01:01:33 +0000 Subject: [PATCH 14/30] more account settings page improvements, among others --- admin/accounthttp.go | 307 ++++++++++++++++++++++++++++++++ admin/http.go | 278 ----------------------------- admin/static/admin.css | 62 +++++++ admin/static/edit-account.css | 24 +++ admin/static/edit-account.js | 0 admin/static/edit-artist.css | 48 ----- admin/static/edit-release.css | 52 ------ admin/static/edit-track.css | 48 ----- admin/static/index.css | 46 ----- admin/views/create-account.html | 9 +- admin/views/edit-account.html | 11 +- admin/views/login.html | 6 +- 12 files changed, 407 insertions(+), 484 deletions(-) create mode 100644 admin/static/edit-account.js diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 56aa247..f0990df 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -3,13 +3,24 @@ package admin import ( "fmt" "net/http" + "os" + "strings" + "time" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/model" "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" ) +type TemplateData struct { + Account *model.Account + Message string + Token string +} + func AccountHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { account := r.Context().Value("account").(*model.Account) @@ -36,3 +47,299 @@ func AccountHandler(db *sqlx.DB) http.Handler { }) } +func LoginHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + account, err := controller.GetAccountByRequest(db, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + return + } + if account != nil { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + err = pages["login"].Execute(w, TemplateData{}) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + return + } + + type LoginResponse struct { + Account *model.Account + Token string + Message string + } + + render := func(data LoginResponse) { + err := pages["login"].Execute(w, data) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + if r.Method != http.MethodPost { + http.NotFound(w, r); + return + } + + err := r.ParseForm() + if err != nil { + render(LoginResponse{ Message: "Malformed request." }) + return + } + + type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TOTP string `json:"totp"` + } + credentials := LoginRequest{ + Username: r.Form.Get("username"), + Password: r.Form.Get("password"), + TOTP: r.Form.Get("totp"), + } + + account, err := controller.GetAccount(db, credentials.Username) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + if account == nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + + totps, err := controller.GetTOTPsForAccount(db, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + render(LoginResponse{ Message: "Something went wrong. Please try again." }) + return + } + if len(totps) > 0 { + success := false + for _, totp := range totps { + check := controller.GenerateTOTP(totp.Secret, 0) + if check == credentials.TOTP { + success = true + break + } + } + if !success { + render(LoginResponse{ Message: "Invalid TOTP" }) + return + } + } else { + // TODO: user should be prompted to add 2FA method + } + + // login success! + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + render(LoginResponse{ Message: "Something went wrong. Please try again." }) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + render(LoginResponse{ Account: account, Token: token.Token }) + }) +} + +func LogoutHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + tokenStr := controller.GetTokenFromRequest(db, r) + + if len(tokenStr) > 0 { + err := controller.DeleteToken(db, tokenStr) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = "" + cookie.Expires = time.Now() + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/admin/login", http.StatusFound) + }) +} + +func createAccountHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkAccount, err := controller.GetAccountByRequest(db, r) + if err != nil { + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if checkAccount != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type CreateAccountResponse struct { + Account *model.Account + Message string + } + + render := func(data CreateAccountResponse) { + err := pages["create-account"].Execute(w, data) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + + if r.Method == http.MethodGet { + render(CreateAccountResponse{}) + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err = r.ParseForm() + if err != nil { + render(CreateAccountResponse{ + Message: "Malformed data.", + }) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure code exists in DB + invite, err := controller.GetInvite(db, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(db, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + render(CreateAccountResponse{ + Message: "Invalid invite code.", + }) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: credentials.Email, + AvatarURL: "/img/default-avatar.png", + } + err = controller.CreateAccount(db, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + render(CreateAccountResponse{ + Message: "An account with that username already exists.", + }) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + err = controller.DeleteInvite(db, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + + // registration success! + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + // gracefully redirect user to login page + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + err = pages["login"].Execute(w, TemplateData{ + Account: &account, + Token: token.Token, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/admin/http.go b/admin/http.go index 9eae2b7..d118647 100644 --- a/admin/http.go +++ b/admin/http.go @@ -6,22 +6,13 @@ import ( "net/http" "os" "path/filepath" - "strings" - "time" "arimelody-web/controller" - "arimelody-web/global" "arimelody-web/model" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" ) -type TemplateData struct { - Account *model.Account - Token string -} - func Handler(db *sqlx.DB) http.Handler { mux := http.NewServeMux() @@ -112,275 +103,6 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { }) } -func LoginHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - account, err := controller.GetAccountByRequest(db, r) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) - return - } - if account != nil { - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - err = pages["login"].Execute(w, TemplateData{}) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r); - return - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - credentials := LoginRequest{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - TOTP: r.Form.Get("totp"), - } - - account, err := controller.GetAccount(db, credentials.Username) - if err != nil { - http.Error(w, "Invalid username or password", http.StatusBadRequest) - return - } - if account == nil { - http.Error(w, "Invalid username or password", http.StatusBadRequest) - return - } - - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) - if err != nil { - http.Error(w, "Invalid username or password", http.StatusBadRequest) - return - } - - // TODO: check TOTP - - // login success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, TemplateData{ - Account: account, - Token: token.Token, - }) - if err != nil { - fmt.Printf("Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} - -func LogoutHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.NotFound(w, r) - return - } - - tokenStr := controller.GetTokenFromRequest(db, r) - - if len(tokenStr) > 0 { - err := controller.DeleteToken(db, tokenStr) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = "" - cookie.Expires = time.Now() - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - http.Redirect(w, r, "/admin/login", http.StatusFound) - }) -} - -func createAccountHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checkAccount, err := controller.GetAccountByRequest(db, r) - if err != nil { - fmt.Printf("WARN: Failed to fetch account: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if checkAccount != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - type CreateAccountResponse struct { - Account *model.Account - Message string - } - - render := func(data CreateAccountResponse) { - err := pages["create-account"].Execute(w, data) - if err != nil { - fmt.Printf("WARN: Error rendering create account page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - } - - if r.Method == http.MethodGet { - render(CreateAccountResponse{}) - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - err = r.ParseForm() - if err != nil { - render(CreateAccountResponse{ - Message: "Malformed data.", - }) - return - } - - type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Invite string `json:"invite"` - } - credentials := RegisterRequest{ - Username: r.Form.Get("username"), - Email: r.Form.Get("email"), - Password: r.Form.Get("password"), - Invite: r.Form.Get("invite"), - } - - // make sure code exists in DB - invite, err := controller.GetInvite(db, credentials.Invite) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - if invite == nil || time.Now().After(invite.ExpiresAt) { - if invite != nil { - err := controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - } - render(CreateAccountResponse{ - Message: "Invalid invite code.", - }) - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - account := model.Account{ - Username: credentials.Username, - Password: string(hashedPassword), - Email: credentials.Email, - AvatarURL: "/img/default-avatar.png", - } - err = controller.CreateAccount(db, &account) - if err != nil { - if strings.HasPrefix(err.Error(), "pq: duplicate key") { - render(CreateAccountResponse{ - Message: "An account with that username already exists.", - }) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - err = controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - - // registration success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - // gracefully redirect user to login page - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, TemplateData{ - Account: &account, - Token: token.Token, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} - func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) diff --git a/admin/static/admin.css b/admin/static/admin.css index 510ee8b..32f69bb 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -124,3 +124,65 @@ a img.icon { font-size: 12px; } } + + + +#error { + background: #ffa9b8; + border: 1px solid #dc5959; + padding: 1em; + border-radius: 4px; +} + + + +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.new { + background: #c4ff6a; + border-color: #84b141; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} +a.delete { + color: #d22828; +} diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 7c75b66..625db13 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -39,3 +39,27 @@ input { padding: 1em; border-radius: 4px; } + +.mfa-device { + padding: .75em; + background: #f8f8f8f8; + border: 1px solid #808080; + border-radius: .5em; + margin-bottom: .5em; + display: flex; + justify-content: space-between; +} + +.mfa-device div { + display: flex; + flex-direction: column; + justify-content: center; +} + +.mfa-device p { + margin: 0; +} + +.mfa-device .mfa-device-name { + font-weight: bold; +} diff --git a/admin/static/edit-account.js b/admin/static/edit-account.js new file mode 100644 index 0000000..e69de29 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index e481b68..793b989 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -66,54 +66,6 @@ input[type="text"]:focus { border-color: #808080; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .artist-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 9feb9ad..10eada3 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -109,58 +109,6 @@ input[type="text"] { padding: 0; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.new { - background: #c4ff6a; - border-color: #84b141; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .release-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 6e87397..8a05089 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -67,54 +67,6 @@ h1 { border-color: #808080; } -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} - -a.delete { - color: #d22828; -} - .track-actions { margin-top: 1em; display: flex; diff --git a/admin/static/index.css b/admin/static/index.css index 1411eff..9d38940 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -98,49 +98,3 @@ .track .empty { opacity: 0.75; } - - - -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; -} diff --git a/admin/views/create-account.html b/admin/views/create-account.html index b0aff03..8d59c0f 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -1,7 +1,7 @@ {{define "head"}} Register - ari melody 💫 - + {{end}} diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html index 2b1737e..4d89052 100644 --- a/admin/views/edit-account.html +++ b/admin/views/edit-account.html @@ -35,15 +35,20 @@ {{if .TOTPs}} {{range .TOTPs}}
-

{{.Name}}

-

{{.CreatedAt}}

+
+

{{.Name}}

+

Added: {{.CreatedAt}}

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

You have no MFA devices.

{{end}} - Add MFA Device +
diff --git a/admin/views/login.html b/admin/views/login.html index c2209c6..7744e91 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -1,7 +1,7 @@ {{define "head"}} Login - ari melody 💫 - + {{end}} diff --git a/admin/views/register.html b/admin/views/register.html index 8899fd9..94170c9 100644 --- a/admin/views/register.html +++ b/admin/views/register.html @@ -11,7 +11,7 @@ a.discord { color: #5865F2; } -form { +form#register { width: 100%; display: flex; flex-direction: column; @@ -26,22 +26,8 @@ form button { margin-top: 1rem; } -label { - width: 100%; - margin: 1rem 0 .5rem 0; - display: block; - color: #10101080; -} input { width: 100%; - margin: .5rem 0; - padding: .3rem .5rem; - display: block; - border-radius: 4px; - border: 1px solid #808080; - font-size: inherit; - font-family: inherit; - color: inherit; } {{end}} @@ -52,7 +38,7 @@ input {

{{html .Session.Error.String}}

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

{{html .Session.Error.String}}

+ {{end}} + + +

Your TOTP secret: {{.TOTP.Secret}}

+ + + +

+ Please store this into your two-factor authentication app or + password manager, then enter your code below: +

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

{{html .Session.Error.String}}

+ {{end}} + +
+ + + + +
+
+{{end}} diff --git a/controller/totp.go b/controller/totp.go index 83a5b1c..02f1c4b 100644 --- a/controller/totp.go +++ b/controller/totp.go @@ -17,9 +17,9 @@ import ( "github.com/jmoiron/sqlx" ) -const TOTP_SECRET_LENGTH = 64 -const TIME_STEP int64 = 30 -const CODE_LENGTH = 6 +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) @@ -27,7 +27,7 @@ func GenerateTOTP(secret string, timeStepOffset int) string { fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") } - counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) + counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset) counterBytes := make([]byte, 8) binary.BigEndian.PutUint64(counterBytes, uint64(counter)) @@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string { offset := hash[len(hash) - 1] & 0x0f binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) - code := binaryCode % int32(math.Pow10(CODE_LENGTH)) + code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH)) - return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) + return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code) } func GenerateTOTPSecret(length int) string { @@ -65,8 +65,8 @@ func GenerateTOTPURI(username string, secret string) string { query.Set("secret", secret) query.Set("issuer", "arimelody.me") query.Set("algorithm", "SHA1") - query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) - query.Set("period", fmt.Sprintf("%d", TIME_STEP)) + 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() @@ -98,7 +98,11 @@ func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOT for _, method := range totps { check := GenerateTOTP(method.Secret, 0) if check == totp { - // return the whole TOTP method as it may be useful for logging + return &method, nil + } + // try again with offset- maybe user input the code late? + check = GenerateTOTP(method.Secret, 1) + if check == totp { return &method, nil } } From e004491b553f041b9f59a453fd94e1377b9cb419 Mon Sep 17 00:00:00 2001 From: ari melody Date: Thu, 23 Jan 2025 13:53:06 +0000 Subject: [PATCH 24/30] TOTP fully functioning, account settings done! --- admin/accounthttp.go | 23 ++++++++++-- admin/http.go | 71 +++++++++++++++++++++++++++-------- admin/static/admin.css | 16 ++++---- admin/templates.go | 5 +++ admin/views/edit-account.html | 6 +-- admin/views/login-totp.html | 42 +++++++++++++++++++++ admin/views/login.html | 17 ++------- admin/views/register.html | 6 ++- admin/views/totp-confirm.html | 2 +- admin/views/totp-setup.html | 2 +- controller/totp.go | 1 - 11 files changed, 143 insertions(+), 48 deletions(-) create mode 100644 admin/views/login-totp.html diff --git a/admin/accounthttp.go b/admin/accounthttp.go index b5deca2..9402410 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -30,15 +30,30 @@ func accountIndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - totps, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) + 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 accountResponse struct { - Session *model.Session - TOTPs []model.TOTP + 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 diff --git a/admin/http.go b/admin/http.go index 7dd5207..4d32aa9 100644 --- a/admin/http.go +++ b/admin/http.go @@ -284,26 +284,65 @@ func loginHandler(app *model.AppState) http.Handler { return } - 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 + 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 = pages["login-totp"].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 { - 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 - fmt.Printf( - "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", - time.Now().Format(time.UnixDate), - account.Username, - totpMethod.Name, - ) // login success! controller.SetSessionAccount(app.DB, session, account) diff --git a/admin/static/admin.css b/admin/static/admin.css index cbb827e..a6e0bc2 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -85,6 +85,15 @@ a img.icon { height: .8em; } +code { + background: #303030; + color: #f0f0f0; + padding: .23em .3em; + border-radius: 4px; +} + + + .card { margin-bottom: 1em; } @@ -93,13 +102,6 @@ a img.icon { margin: 0 0 .5em 0; } -/* -.card h3, -.card p { - margin: 0; -} -*/ - .card-title { margin-bottom: 1em; display: flex; diff --git a/admin/templates.go b/admin/templates.go index 3bae106..d9a74ca 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -18,6 +18,11 @@ var pages = map[string]*template.Template{ filepath.Join("views", "prideflag.html"), filepath.Join("admin", "views", "login.html"), )), + "login-totp": template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), + )), "register": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"), diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html index b1d083a..6c17088 100644 --- a/admin/views/edit-account.html +++ b/admin/views/edit-account.html @@ -40,11 +40,11 @@ {{range .TOTPs}}
-

{{.Name}}

-

Added: {{.CreatedAt}}

+

{{.TOTP.Name}}

+

Added: {{.CreatedAtString}}

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

Two-Factor Authentication

+ +
+ + + + +
+ + +
+
+{{end}} diff --git a/admin/views/login.html b/admin/views/login.html index b77af83..fbb7294 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -3,14 +3,6 @@ {{end}} @@ -42,15 +34,14 @@ input { {{end}}
+

Log In

+
- + - - -
diff --git a/admin/views/register.html b/admin/views/register.html index 94170c9..37f0947 100644 --- a/admin/views/register.html +++ b/admin/views/register.html @@ -27,7 +27,7 @@ form button { } input { - width: 100%; + width: calc(100% - 1rem - 2px); } {{end}} @@ -39,9 +39,11 @@ input { {{end}} +

Create Account

+
- + diff --git a/admin/views/totp-confirm.html b/admin/views/totp-confirm.html index af6b6e1..ac39e52 100644 --- a/admin/views/totp-confirm.html +++ b/admin/views/totp-confirm.html @@ -26,7 +26,7 @@ code {

- + diff --git a/admin/views/totp-setup.html b/admin/views/totp-setup.html index 62b9daf..e74c970 100644 --- a/admin/views/totp-setup.html +++ b/admin/views/totp-setup.html @@ -12,7 +12,7 @@
- +
diff --git a/controller/totp.go b/controller/totp.go index 02f1c4b..bc71747 100644 --- a/controller/totp.go +++ b/controller/totp.go @@ -92,7 +92,6 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) { func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) { totps, err := GetTOTPsForAccount(db, accountID) if err != nil { - // user has no TOTP methods return nil, err } for _, method := range totps { From 9a27dbdc3716be63389a8b551867c09e556bdc12 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jan 2025 01:04:57 +0000 Subject: [PATCH 25/30] fixed style of inputs on release edit page (whoops!) --- admin/static/admin.css | 4 ++-- admin/static/edit-account.css | 9 +++++---- admin/static/edit-release.css | 9 ++++++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/admin/static/admin.css b/admin/static/admin.css index a6e0bc2..877b5da 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -206,13 +206,13 @@ form { width: 100%; display: block; } -label { +form label { width: 100%; margin: 1rem 0 .5rem 0; display: block; color: #10101080; } -input { +form input { margin: .5rem 0; padding: .3rem .5rem; display: block; diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 37351b2..7a4d34a 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -5,10 +5,11 @@ div.card { } label { - width: 100%; - margin: 1rem 0 .5rem 0; - display: block; - color: #10101080; + width: auto; + margin: 0; + display: flex; + align-items: center; + color: inherit; } input { width: min(20rem, calc(100% - 1rem)); diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 63d399e..aa70e34 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -228,12 +228,14 @@ dialog div.dialog-actions { } #editcredits .credit .credit-info .credit-attribute label { + width: auto; + margin: 0; display: flex; align-items: center; } #editcredits .credit .credit-info .credit-attribute input[type="text"] { - margin-left: .25em; + margin: 0 0 0 .25em; padding: .2em .4em; flex-grow: 1; font-family: inherit; @@ -241,6 +243,9 @@ dialog div.dialog-actions { border-radius: 4px; color: inherit; } +#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { + margin: 0 .3em; +} #editcredits .credit .artist-name { font-weight: bold; @@ -369,8 +374,10 @@ dialog div.dialog-actions { #editlinks td input[type="text"] { width: calc(100% - .6em); height: 100%; + margin: 0; padding: 0 .3em; border: none; + border-radius: 0; outline: none; cursor: pointer; background: none; From 090de0554bbfd64ab68fb560e8ad1559dd9ee312 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jan 2025 01:33:14 +0000 Subject: [PATCH 26/30] fix bug causing edit tracks component to crash --- admin/components/tracks/edittracks.html | 10 +++++----- admin/releasehttp.go | 11 ++++++++++- model/track.go | 2 ++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index 0500532..d03f80a 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,20 +3,20 @@

Editing: Tracks

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

    Delete diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 7d098e6..cd73e98 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -145,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler { func serveEditTracks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := components["edittracks"].Execute(w, release) + + type editTracksData struct { + Release *model.Release + Add func(a int, b int) int + } + + err := components["edittracks"].Execute(w, editTracksData{ + Release: release, + Add: func(a, b int) int { return a + b }, + }) if err != nil { fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/model/track.go b/model/track.go index d44c224..ca54ddd 100644 --- a/model/track.go +++ b/model/track.go @@ -12,6 +12,8 @@ type ( Description string `json:"description"` Lyrics string `json:"lyrics" db:"lyrics"` PreviewURL string `json:"previewURL" db:"preview_url"` + + Number int } ) From ad39e68cd651176211e98d3e7b9cf1e25ec925e1 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jan 2025 18:49:04 +0000 Subject: [PATCH 27/30] fix API endpoints which require account authorisation --- admin/http.go | 16 +++++------ api/api.go | 74 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/admin/http.go b/admin/http.go index 4d32aa9..b6a71ee 100644 --- a/admin/http.go +++ b/admin/http.go @@ -20,20 +20,20 @@ func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/login", loginHandler(app)) - mux.Handle("/logout", RequireAccount(app, logoutHandler(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("/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("/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))) + mux.Handle("/", requireAccount(app, AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists return enforceSession(app, mux) @@ -381,7 +381,7 @@ func logoutHandler(app *model.AppState) http.Handler { }) } -func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { +func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) if session.Account == nil { diff --git a/api/api.go b/api/api.go index 9489126..50b1c63 100644 --- a/api/api.go +++ b/api/api.go @@ -1,11 +1,13 @@ package api import ( + "context" + "errors" "fmt" "net/http" + "os" "strings" - "arimelody-web/admin" "arimelody-web/controller" "arimelody-web/model" ) @@ -36,10 +38,10 @@ func Handler(app *model.AppState) http.Handler { ServeArtist(app, artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - admin.RequireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) + requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) + requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -51,7 +53,7 @@ func Handler(app *model.AppState) http.Handler { ServeAllArtists(app).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r) + requireAccount(app, CreateArtist(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -78,10 +80,10 @@ func Handler(app *model.AppState) http.Handler { ServeRelease(app, release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - admin.RequireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) + requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) + requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -93,7 +95,7 @@ func Handler(app *model.AppState) http.Handler { ServeCatalog(app).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r) + requireAccount(app, CreateRelease(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -117,13 +119,13 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - admin.RequireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) + requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - admin.RequireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) + requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) + requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -132,10 +134,10 @@ func Handler(app *model.AppState) http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - admin.RequireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) + requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r) + requireAccount(app, CreateTrack(app)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -143,3 +145,51 @@ 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 +} From 1edc051ae21f8cc64d2d00796ad04dc8305d8dec Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 26 Jan 2025 00:48:19 +0000 Subject: [PATCH 28/30] fixed GetTOTP, started rough QR code implementation GetTOTP handles TOTP method retrieval for confirmation and deletion. QR code implementation looks like it's gonna suck, so might end up using a library for this later. --- admin/accounthttp.go | 11 +++-- admin/http.go | 16 +++++-- controller/qr.go | 107 +++++++++++++++++++++++++++++++++++++++++++ controller/totp.go | 9 ++-- main.go | 2 +- 5 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 controller/qr.go diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 9402410..aa1e042 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -304,6 +304,12 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } + fmt.Printf( + "TOTP:\n\tName: %s\n\tSecret: %s\n", + totp.Name, + totp.Secret, + ) + confirmCode := controller.GenerateTOTP(totp.Secret, 0) if code != confirmCode { confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) @@ -330,12 +336,11 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } - name := r.URL.Path - fmt.Printf("%s\n", name); - if len(name) == 0 { + 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) diff --git a/admin/http.go b/admin/http.go index b6a71ee..42b7e46 100644 --- a/admin/http.go +++ b/admin/http.go @@ -19,6 +19,17 @@ import ( 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([]byte("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))) @@ -243,11 +254,6 @@ func loginHandler(app *model.AppState) http.Handler { return } - // new accounts won't have TOTP methods at first. there should be a - // second phase of login that prompts the user for a TOTP *only* - // if that account has a TOTP method. - // TODO: login phases (username & password -> TOTP) - type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` diff --git a/controller/qr.go b/controller/qr.go new file mode 100644 index 0000000..c4c2520 --- /dev/null +++ b/controller/qr.go @@ -0,0 +1,107 @@ +package controller + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/png" +) + +const margin = 4 + +type QRCodeECCLevel int64 +const ( + LOW QRCodeECCLevel = iota + MEDIUM + QUARTILE + HIGH +) + +func GenerateQRCode(data []byte) (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/totp.go b/controller/totp.go index bc71747..dbbeec7 100644 --- a/controller/totp.go +++ b/controller/totp.go @@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string { 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)) + // 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() @@ -116,8 +116,9 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { err := db.Get( &totp, "SELECT * FROM totp " + - "WHERE account=$1", + "WHERE account=$1 AND name=$2", accountID, + name, ) if err != nil { if strings.Contains(err.Error(), "no rows") { diff --git a/main.go b/main.go index 251d9a9..ac1de59 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,6 @@ func main() { } username := os.Args[2] totpName := os.Args[3] - secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { @@ -102,6 +101,7 @@ func main() { os.Exit(1) } + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) totp := model.TOTP { AccountID: account.ID, Name: totpName, From 3450d879acd0662f8bfae1d010f6e02ee9441e06 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 26 Jan 2025 20:09:18 +0000 Subject: [PATCH 29/30] QR codes complete, account settings finished! + refactored templates a little; this might need more work! --- admin/accounthttp.go | 31 ++++--- admin/artisthttp.go | 2 +- admin/http.go | 12 +-- admin/releasehttp.go | 16 ++-- admin/templates.go | 150 ++++++++++++++++++---------------- admin/trackhttp.go | 2 +- admin/views/totp-confirm.html | 17 ++-- controller/qr.go | 15 +++- go.mod | 6 +- go.sum | 6 +- main.go | 2 +- templates/templates.go | 47 +++++------ view/music.go | 4 +- 13 files changed, 175 insertions(+), 135 deletions(-) diff --git a/admin/accounthttp.go b/admin/accounthttp.go index aa1e042..408b4c5 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -63,7 +63,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Message = sessionMessage session.Error = sessionError - err = pages["account"].Execute(w, accountResponse{ + err = accountTemplate.Execute(w, accountResponse{ Session: session, TOTPs: totps, }) @@ -199,7 +199,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err := pages["totp-setup"].Execute(w, totpSetupData{ Session: 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) @@ -216,6 +216,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { Session *model.Session TOTP *model.TOTP NameEscaped string + QRBase64Image string } err := r.ParseForm() @@ -242,7 +243,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { 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 := pages["totp-setup"].Execute(w, totpSetupData{ Session: 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) @@ -250,10 +251,24 @@ func totpSetupHandler(app *model.AppState) http.Handler { return } - err = pages["totp-confirm"].Execute(w, totpSetupData{ + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Printf("WARN: Failed to generate TOTP setup QR code: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + 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 + } + + err = totpConfirmTemplate.Execute(w, totpSetupData{ 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) @@ -304,18 +319,12 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "TOTP:\n\tName: %s\n\tSecret: %s\n", - totp.Name, - totp.Secret, - ) - confirmCode := controller.GenerateTOTP(totp.Secret, 0) if code != confirmCode { confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) if code != confirmCodeOffset { controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.") - err = pages["totp-confirm"].Execute(w, totpConfirmData{ + err = totpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: totp, }) diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 5979493..6dfbbfd 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -39,7 +39,7 @@ func serveArtist(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = pages["artist"].Execute(w, ArtistResponse{ + err = artistTemplate.Execute(w, ArtistResponse{ Session: session, Artist: artist, Credits: credits, diff --git a/admin/http.go b/admin/http.go index 42b7e46..6fd8f59 100644 --- a/admin/http.go +++ b/admin/http.go @@ -20,7 +20,7 @@ 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([]byte("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")) + 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) @@ -87,7 +87,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { Tracks []*model.Track } - err = pages["index"].Execute(w, IndexData{ + err = indexTemplate.Execute(w, IndexData{ Session: session, Releases: releases, Artists: artists, @@ -116,7 +116,7 @@ func registerAccountHandler(app *model.AppState) http.Handler { } render := func() { - err := pages["register"].Execute(w, registerData{ Session: session }) + 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) @@ -229,7 +229,7 @@ func loginHandler(app *model.AppState) http.Handler { } render := func() { - err := pages["login"].Execute(w, loginData{ Session: session }) + 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) @@ -307,7 +307,7 @@ func loginHandler(app *model.AppState) http.Handler { Username string Password string } - err = pages["login-totp"].Execute(w, loginTOTPData{ + err = loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session, Username: credentials.Username, Password: credentials.Password, @@ -379,7 +379,7 @@ func logoutHandler(app *model.AppState) http.Handler { Path: "/", }) - err = pages["logout"].Execute(w, nil) + 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) diff --git a/admin/releasehttp.go b/admin/releasehttp.go index cd73e98..be5052b 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -60,7 +60,7 @@ func serveRelease(app *model.AppState) http.Handler { Release *model.Release } - err = pages["release"].Execute(w, ReleaseResponse{ + err = releaseTemplate.Execute(w, ReleaseResponse{ Session: session, Release: release, }) @@ -74,7 +74,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 := components["editcredits"].Execute(w, release) + err := editCreditsTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addcredit"].Execute(w, response{ + err = addCreditTemplate.Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newcredit"].Execute(w, artist) + err = newCreditTemplate.Execute(w, artist) if err != nil { fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -134,7 +134,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 := components["editlinks"].Execute(w, release) + err := editLinksTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -151,7 +151,7 @@ func serveEditTracks(release *model.Release) http.Handler { Add func(a int, b int) int } - err := components["edittracks"].Execute(w, editTracksData{ + err := editTracksTemplate.Execute(w, editTracksData{ Release: release, Add: func(a, b int) int { return a + b }, }) @@ -177,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["addtrack"].Execute(w, response{ + err = addTrackTemplate.Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -204,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = components["newtrack"].Execute(w, track) + err = newTrackTemplate.Execute(w, track) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/templates.go b/admin/templates.go index d9a74ca..49c118b 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,80 +1,90 @@ package admin import ( - "html/template" - "path/filepath" + "html/template" + "path/filepath" ) -var pages = map[string]*template.Template{ - "index": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "index.html"), - )), +var indexTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "index.html"), +)) - "login": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "login.html"), - )), - "login-totp": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "login-totp.html"), - )), - "register": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "register.html"), - )), - "logout": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "logout.html"), - )), - "account": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-account.html"), - )), - "totp-setup": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "totp-setup.html"), - )), - "totp-confirm": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "totp-confirm.html"), - )), +var loginTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login.html"), +)) +var loginTOTPTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), +)) +var registerTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "register.html"), +)) +var logoutTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logout.html"), +)) +var accountTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), +)) +var totpSetupTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-setup.html"), +)) +var totpConfirmTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-confirm.html"), +)) - "release": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-release.html"), - )), - "artist": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "edit-artist.html"), - )), - "track": template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "edit-track.html"), - )), -} +var releaseTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-release.html"), +)) +var artistTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-artist.html"), +)) +var trackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "edit-track.html"), +)) -var components = map[string]*template.Template{ - "editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))), - "addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))), - "newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))), +var editCreditsTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "editcredits.html"), +)) +var addCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "addcredit.html"), +)) +var newCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "newcredit.html"), +)) - "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))), +var editLinksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "links", "editlinks.html"), +)) - "edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))), - "addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))), - "newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))), -} +var editTracksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "edittracks.html"), +)) +var addTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "addtrack.html"), +)) +var newTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "newtrack.html"), +)) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 9436671..a92f81a 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -39,7 +39,7 @@ func serveTrack(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = pages["track"].Execute(w, TrackResponse{ + err = trackTemplate.Execute(w, TrackResponse{ Session: session, Track: track, Releases: releases, diff --git a/admin/views/totp-confirm.html b/admin/views/totp-confirm.html index ac39e52..b0810e2 100644 --- a/admin/views/totp-confirm.html +++ b/admin/views/totp-confirm.html @@ -3,6 +3,9 @@