API login/register/delete-account, automatic db schema init

This commit is contained in:
ari melody 2024-09-23 00:57:23 +01:00
parent 1846203076
commit f7edece0af
Signed by: ari
GPG key ID: CF99829C92678188
11 changed files with 479 additions and 8 deletions

View file

@ -6,6 +6,8 @@
uploads/*
test/
tmp/
db/
docker-compose.yml
docker-compose-test.yml
Dockerfile
schema.sql

View file

@ -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
}

54
account/model/account.go Normal file
View file

@ -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
}

View file

@ -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
}()

175
api/account.go Normal file
View file

@ -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"))
})
}

View file

@ -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) {

View file

@ -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

View file

@ -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

2
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

169
main.go
View file

@ -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
}