From be5cd05d08e63ff9dac0d0f452b7687e179ecfdd Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 19:02:26 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 💫 - +