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 }