diff --git a/.gitignore b/.gitignore index d31169fa..2efecd91 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ bin/ # Other .DS_Store +*.env* +certs +rmx.config.json +rmx-dev.config.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d9f71486 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "bracketSameLine": true, + "arrowParens": "always", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": true +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..72e63361 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Start App Server (dev)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/server", + "cwd": "${workspaceFolder}", + "envFile": "${workspaceFolder}/.env.development" + }, + { + "name": "Start CLI Server (dev)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/cli", + "cwd": "${workspaceFolder}", + "args": ["start", "dev"] + }, + { + "name": "TUI Client", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/ui/terminal", + "cwd": "${workspaceFolder}", + "args": ["--server", "http://localhost:9003"], + "console": "integratedTerminal" + } + ] +} diff --git a/Makefile b/Makefile index 9526e992..d0a3a137 100644 --- a/Makefile +++ b/Makefile @@ -37,11 +37,11 @@ fmt: go fmt ./... ## build_server: Build server binary into bin/ directory -.PHONY: build_server -build_server: - $(GOFLAGS) $(GO_BUILD) -a -v -ldflags="-w -s" -o bin/server cmd/server/main.go - -## build_cli: Build cli binary into bin/ directory -.PHONY: build_cli -build_cli: - $(GOFLAGS) $(GO_BUILD) -a -v -ldflags="-w -s" -o bin/cli cmd/cli/main.go +.PHONY: build +build: + $(GOFLAGS) $(GO_BUILD) -a -v -ldflags="-w -s" -o bin/rmx-server cmd/*.go + +# --host should be from ENV +.PHONT: tls +tls: + go run /usr/local/go/src/crypto/tls/generate_cert.go --host=$(HOSTNAME) \ No newline at end of file diff --git a/README.md b/README.md index fe0e5cd4..7beee14f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# golang-template-repository + -# [Repo name] +# Rapidmidiex ## Description @@ -35,6 +35,10 @@ _Let people know what your project can do specifically. Provide context and add ## Installation + + + + _Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection._ ## Usage diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 63e4dc31..3bb8e487 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,8 +1,31 @@ package main -import "fmt" +import ( + "log" + "os" + "time" + + "github.com/rog-golang-buddies/rmx/internal/commands" + "github.com/urfave/cli/v2" +) func main() { - // Feel free to delete this file. - fmt.Println("Hello Gophers") + if err := initCLI().Run(os.Args); err != nil { + log.Fatalln(err) + } +} + +func initCLI() *cli.App { + c := &cli.App{ + EnableBashCompletion: true, + Name: "rmx", + Usage: "RapidMidiEx Server CLI", + Version: commands.Version, + Compiled: time.Now().UTC(), + Action: commands.GetVersion, + Flags: commands.Flags, + Commands: commands.Commands, + } + + return c } diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index 63e4dc31..00000000 --- a/cmd/main.go +++ /dev/null @@ -1,8 +0,0 @@ -package main - -import "fmt" - -func main() { - // Feel free to delete this file. - fmt.Println("Hello Gophers") -} diff --git a/cmd/server/main.go b/cmd/server/main.go index 63e4dc31..ff7e94cf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,8 +1,26 @@ package main -import "fmt" +import ( + "log" + "os" + + "github.com/rog-golang-buddies/rmx/config" + "github.com/rog-golang-buddies/rmx/internal/commands" +) func main() { - // Feel free to delete this file. - fmt.Println("Hello Gophers") + rmxEnv := os.Getenv("RMX_ENV") + isDev := false + if rmxEnv == "development" { + isDev = true + } + cfg, err := config.LoadConfigFromEnv(isDev) + if err != nil { + log.Fatalf("Could load config: %v", err) + } + + err = commands.StartServer(cfg) + if err != nil { + log.Fatalf("Could not start server: %v", err) + } } diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..5452e74f --- /dev/null +++ b/config/config.go @@ -0,0 +1,126 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "os" +) + +type Config struct { + ServerPort string `json:"serverPort"` + DBHost string `json:"dbHost"` + DBPort string `json:"dbPort"` + DBName string `json:"dbName"` + DBUser string `json:"dbUser"` + DBPassword string `json:"dbPassword"` + RedisHost string `json:"redisHost"` + RedisPort string `json:"redisPort"` + RedisPassword string `json:"redisPassword"` +} + +const ( + configFileName = "rmx.config.json" + devConfigFileName = "rmx-dev.config.json" +) + +// writes the values of the config to a file +// NOTE: this will overwrite the previous generated file +func (c *Config) WriteToFile(dev bool) error { + var fp string + if dev { + fp = devConfigFileName + } else { + fp = configFileName + } + + bs, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + f, err := os.OpenFile(fp, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0660) + if err != nil { + return err + } + + defer func() { + if err := f.Close(); err != nil { + log.Fatalln(err) + } + }() + + if _, err := f.Write(bs); err != nil { + return err + } + + return nil +} + +// checks for a config file and if one is available the value is returned +func ScanConfigFile(dev bool) (*Config, error) { + // check for a config file + var fp string + if dev { + fp = devConfigFileName + } else { + fp = configFileName + } + + if _, err := os.Stat(fp); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + c := &Config{} + bs, err := os.ReadFile(fp) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(bs, c); err != nil { + return nil, err + } + + return c, nil +} + +// LoadConfigFromEnv creates a Config from environment variables. +func LoadConfigFromEnv(dev bool) (*Config, error) { + serverPort := os.Getenv("PORT") + + pgURI := os.Getenv("POSTGRES_URI") + + pgParsed, err := url.Parse(pgURI) + if err != nil && pgURI != "" { + return nil, fmt.Errorf("invalid POSTGRES_URL env var: %q: %w", pgURI, err) + } + + pgUser := pgParsed.User.Username() + pgPassword, _ := pgParsed.User.Password() + + pgHost := pgParsed.Host + pgPort := pgParsed.Port() + pgName := pgParsed.Path + + redisHost := os.Getenv("REDIS_HOST") + redisPort := os.Getenv("REDIS_PORT") + redisPassword := os.Getenv("REDIS_PASSWORD") + + return &Config{ + ServerPort: serverPort, + DBHost: pgHost, + DBPort: pgPort, + DBName: pgName, + DBUser: pgUser, + DBPassword: pgPassword, + RedisHost: redisHost, + RedisPort: redisPort, + RedisPassword: redisPassword, + }, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 00000000..c502938f --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestConfig(t *testing.T) { + // Write config to file + i := &Config{ + ServerPort: "8000", + DBHost: "localhost", + DBPort: "3306", + DBName: "rmx", + DBUser: "rmx", + DBPassword: "password", + RedisHost: "localhost", + RedisPort: "6379", + RedisPassword: "password", + } + + if err := i.WriteToFile(false); err != nil { + t.Fatal(err) + } + + // Read config from file + o, err := ScanConfigFile(false) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(i, o) { + t.Fatalf("expected:\n%+v\ngot:\n%+v", i, o) + } +} diff --git a/go.mod b/go.mod index 99b78ec0..6d98d6ff 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,67 @@ -module github.com/rog-golang-buddies/rapidmidiex +module github.com/rog-golang-buddies/rmx -go 1.18 +go 1.19 + +require ( + github.com/charmbracelet/bubbles v0.14.0 + github.com/charmbracelet/bubbletea v0.22.1 + github.com/charmbracelet/lipgloss v0.6.0 + github.com/go-chi/chi v1.5.4 + github.com/go-chi/chi/v5 v5.0.7 + github.com/gobwas/ws v1.1.0 + github.com/gorilla/websocket v1.5.0 + github.com/hyphengolang/prelude v0.1.3 + github.com/manifoldco/promptui v0.9.0 + github.com/pkg/errors v0.9.1 + github.com/rs/cors v1.8.2 + github.com/urfave/cli/v2 v2.16.3 + golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 + golang.org/x/term v0.0.0-20220919170432-7a66f970e087 +) + +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/goccy/go-json v0.9.11 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect + github.com/jackc/puddle/v2 v2.0.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.1 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.4 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.0 // indirect + github.com/lithammer/shortuuid/v4 v4.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect +) + +require ( + github.com/BurntSushi/toml v1.1.0 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/go-redis/redis/v9 v9.0.0-beta.2 + github.com/jackc/pgx/v5 v5.0.3 + github.com/lestrrat-go/jwx/v2 v2.0.6 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/wagslane/go-password-validator v0.3.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..50a7c7c7 --- /dev/null +++ b/go.sum @@ -0,0 +1,169 @@ +github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og= +github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24= +github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= +github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-redis/redis/v9 v9.0.0-beta.2 h1:ZSr84TsnQyKMAg8gnV+oawuQezeJR11/09THcWCQzr4= +github.com/go-redis/redis/v9 v9.0.0-beta.2/go.mod h1:Bldcd/M/bm9HbnNPi/LUtYBSD8ttcZYBMupwMXhdU0o= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= +github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= +github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hyphengolang/prelude v0.1.3 h1:rNwjyywvCXd7/llM9R3lx7au9BYJvG0JzI2UO0j3yEY= +github.com/hyphengolang/prelude v0.1.3/go.mod h1:O1Wj9q3gP0zJwsrLQKvE1hyVz9fZIwIsh+d7P8wOgOc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM= +github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= +github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= +github.com/jackc/puddle/v2 v2.0.0/go.mod h1:itE7ZJY8xnoo0JqJEpSMprN0f+NQkMCuEV/N9j8h0oc= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80= +github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= +github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.0.6 h1:RlyYNLV892Ed7+FTfj1ROoF6x7WxL965PGTHso/60G0= +github.com/lestrrat-go/jwx/v2 v2.0.6/go.mod h1:aVrGuwEr3cp2Prw6TtQvr8sQxe+84gruID5C9TxT64Q= +github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c= +github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/gomega v1.20.0 h1:8W0cWlwFkflGPLltQvLRB7ZVD5HuP6ng320w2IS245Q= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U= +github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/urfave/cli/v2 v2.16.3 h1:gHoFIwpPjoyIMbJp/VFd+/vuD0dAgFK4B6DpEMFJfQk= +github.com/urfave/cli/v2 v2.16.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA= +golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 h1:ZrnxWX62AgTKOSagEqxvb3ffipvEDX2pl7E1TdqLqIc= +golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 00000000..d279ddda --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,55 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" +) + +var ( + ErrInvalidPort = errors.New("invalid port number") +) + +var Flags = []cli.Flag{ + altsrc.NewIntFlag(&cli.IntFlag{ + Name: "port", + Value: 0, + Usage: "Defines the port which server should listen on", + Required: false, + Aliases: []string{"p"}, + EnvVars: []string{"PORT"}, + }), + &cli.StringFlag{ + Name: "load", + Aliases: []string{"l"}, + }, +} + +var Commands = []*cli.Command{ + { + Name: "start", + Category: "run", + Aliases: []string{"s"}, + Description: "Starts the server in production mode.", + Action: run(false), // disable dev mode + Flags: Flags, + }, + { + Name: "dev", + Category: "run", + Aliases: []string{"d"}, + Description: "Starts the server in development mode", + Action: run(true), // enable dev mode + Flags: Flags, + }, +} + +// shouldn't be here +const Version = "v0.0.0-a.1" + +func GetVersion(cCtx *cli.Context) error { + _, err := fmt.Println("rmx version: " + Version) + return err +} diff --git a/internal/commands/run.go b/internal/commands/run.go new file mode 100644 index 00000000..ba83f3bd --- /dev/null +++ b/internal/commands/run.go @@ -0,0 +1,304 @@ +package commands + +import ( + "context" + "errors" + "log" + "net" + "net/http" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/manifoldco/promptui" + "github.com/rog-golang-buddies/rmx/config" + "github.com/rog-golang-buddies/rmx/service" + "github.com/rog-golang-buddies/rmx/store" + "github.com/rs/cors" + "github.com/urfave/cli/v2" + "golang.org/x/sync/errgroup" +) + +func run(dev bool) func(cCtx *cli.Context) error { + var f = func(cCtx *cli.Context) error { + templates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . | green }} ", + Invalid: "{{ . | red }} ", + Success: "{{ . | bold }} ", + } + + // Server Port + validateNumber := func(v string) error { + if _, err := strconv.ParseUint(v, 0, 0); err != nil { + return errors.New("invalid number") + } + + return nil + } + + validateString := func(v string) error { + if !(len(v) > 0) { + return errors.New("invalid string") + } + + return nil + } + + // check if a config file exists and use that + c, err := config.ScanConfigFile(dev) // set dev mode true/false + if err != nil { + return errors.New("failed to scan config file") + } + if c != nil { + configPrompt := promptui.Prompt{ + Label: "A config file was found. do you want to use it?", + IsConfirm: true, + Default: "y", + } + + validateConfirm := func(s string) error { + if len(s) == 1 && strings.Contains("YyNn", s) || + configPrompt.Default != "" && len(s) == 0 { + return nil + } + return errors.New(`invalid input (you can only use "y" or "n")`) + } + + configPrompt.Validate = validateConfirm + + result, err := configPrompt.Run() + if err != nil { + if strings.ToLower(result) != "n" { + return err + } + } + + if strings.ToLower(result) == "y" { + return serve(c) + } + } + + // Server Port + serverPortPrompt := promptui.Prompt{ + Label: "Server Port", + Validate: validateNumber, + Templates: templates, + } + + serverPort, err := serverPortPrompt.Run() + if err != nil { + return err + } + + // DB Host + dbHostPrompt := promptui.Prompt{ + Label: "MySQL Database host", + Validate: validateString, + Templates: templates, + } + + dbHost, err := dbHostPrompt.Run() + if err != nil { + return err + } + + // DB Port + dbPortPrompt := promptui.Prompt{ + Label: "MySQL Database port", + Validate: validateNumber, + Templates: templates, + } + + dbPort, err := dbPortPrompt.Run() + if err != nil { + return err + } + + // DB Name + dbNamePrompt := promptui.Prompt{ + Label: "MySQL Database name", + Validate: validateString, + Templates: templates, + } + + dbName, err := dbNamePrompt.Run() + if err != nil { + return err + } + + // DB User + dbUserPrompt := promptui.Prompt{ + Label: "MySQL Database user", + Validate: validateString, + Templates: templates, + } + + dbUser, err := dbUserPrompt.Run() + if err != nil { + return err + } + + // DB Password + dbPasswordPrompt := promptui.Prompt{ + Label: "MySQL Database password", + Validate: validateString, + Templates: templates, + Mask: '*', + } + + dbPassword, err := dbPasswordPrompt.Run() + if err != nil { + return err + } + + // Redis Host + redisHostPrompt := promptui.Prompt{ + Label: "Redis host", + Validate: validateString, + Templates: templates, + } + + redisHost, err := redisHostPrompt.Run() + if err != nil { + return err + } + + // Redis Port + redisPortPrompt := promptui.Prompt{ + Label: "Redis port", + Validate: validateNumber, + Templates: templates, + } + + redisPort, err := redisPortPrompt.Run() + if err != nil { + return err + } + + // Redis Password + redisPasswordPrompt := promptui.Prompt{ + Label: "Redis password", + Validate: validateString, + Templates: templates, + Mask: '*', + } + + redisPassword, err := redisPasswordPrompt.Run() + if err != nil { + return err + } + + c = &config.Config{ + ServerPort: serverPort, + DBHost: dbHost, + DBPort: dbPort, + DBName: dbName, + DBUser: dbUser, + DBPassword: dbPassword, + RedisHost: redisHost, + RedisPort: redisPort, + RedisPassword: redisPassword, + } + + // prompt to save the config to a file + configPrompt := promptui.Prompt{ + Label: "Do you want to write the config to a file? (NOTE: this will rewrite the config file)", + IsConfirm: true, + Default: "n", + } + + validateConfirm := func(s string) error { + if len(s) == 1 && strings.Contains("YyNn", s) || + configPrompt.Default != "" && len(s) == 0 { + return nil + } + return errors.New(`invalid input (you can only use "y" or "n")`) + } + + configPrompt.Validate = validateConfirm + + result, err := configPrompt.Run() + if err != nil { + if strings.ToLower(result) != "n" { + return err + } + } + + if strings.ToLower(result) == "y" { + if err := c.WriteToFile(dev); err != nil { + return err + } + } + + return serve(c) + } + + return f +} + +func serve(cfg *config.Config) error { + sCtx, cancel := signal.NotifyContext( + context.Background(), + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, + ) + defer cancel() + + // ? should this defined within the instantiation of a new service + c := cors.Options{ + AllowedOrigins: []string{"*"}, // ? band-aid, needs to change to a flag + AllowCredentials: true, + AllowedMethods: []string{http.MethodGet, http.MethodPost}, + AllowedHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposedHeaders: []string{"Location"}, + } + + // init application store + s, _ := store.New(sCtx, "") // needs fix + // setup a new handler + h := service.New(sCtx, s) + + srv := http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: cors.New(c).Handler(h), + // max time to read request from the client + ReadTimeout: 10 * time.Second, + // max time to write response to the client + WriteTimeout: 10 * time.Second, + // max time for connections using TCP Keep-Alive + IdleTimeout: 120 * time.Second, + BaseContext: func(_ net.Listener) context.Context { return sCtx }, + ErrorLog: log.Default(), + } + + // srv.TLSConfig. + + g, gCtx := errgroup.WithContext(sCtx) + + g.Go(func() error { + // Run the server + srv.ErrorLog.Printf("App server starting on %s", srv.Addr) + return srv.ListenAndServe() + }) + + g.Go(func() error { + <-gCtx.Done() + return srv.Shutdown(context.Background()) + }) + + // if err := g.Wait(); err != nil { + // log.Printf("exit reason: %s \n", err) + // } + + return g.Wait() +} + +// StartServer starts the RMX application. +func StartServer(cfg *config.Config) error { + return serve(cfg) +} diff --git a/internal/fp/fp.go b/internal/fp/fp.go new file mode 100644 index 00000000..2876925a --- /dev/null +++ b/internal/fp/fp.go @@ -0,0 +1,19 @@ +package fp + +import "errors" + +func FMap[T any, U any](vs []T, f func(T) U) (us []U) { + us = make([]U, len(vs)) + + for i, v := range vs { + us[i] = f(v) + } + + return +} + +var ErrTuple = errors.New(`"key/value" pair is missing "value"`) + +type Tuple [2]string + +func (t Tuple) HasValue() bool { return len(t) == 2 } diff --git a/internal/internal.go b/internal/internal.go new file mode 100644 index 00000000..fd31e53d --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,152 @@ +package internal + +import ( + "context" + "errors" + "strings" + + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + "github.com/hyphengolang/prelude/types/suid" +) + +var ( + ErrInvalidEmail = errors.New("invalid email") + ErrNotImplemented = errors.New("not implemented") + ErrInvalidType = errors.New("invalid type") + ErrAlreadyExists = errors.New("already exists") + ErrNotFound = errors.New("not found") + ErrContextValue = errors.New("failed to retrieve value from context") +) + +type ContextKey string + +const ( + EmailKey = ContextKey("account-email") + TokenKey = ContextKey("jwt-token-key") + RoomKey = ContextKey("conn-pool-key") + UpgradeKey = ContextKey("upgrade-http-key") +) + +type MsgTyp int + +const ( + Unknown = iota + + Create + Delete + + Join + Leave + Message + + NoteOn + NoteOff +) + +func (t *MsgTyp) String() string { + switch *t { + case Create: + return "CREATE" + case Delete: + return "DELETE" + case Join: + return "JOIN" + case Leave: + return "LEAVE" + case Message: + return "MESSAGE" + case NoteOn: + return "NOTE_ON" + case NoteOff: + return "NOTE_OFF" + default: + return "UNKNOWN" + } +} + +func (t *MsgTyp) UnmarshalJSON(b []byte) error { + switch s := string(b[1 : len(b)-1]); s { + case "CREATE": + *t = Create + case "DELETE": + *t = Delete + case "JOIN": + *t = Join + case "LEAVE": + *t = Leave + case "MESSAGE": + *t = Message + case "NOTE_ON": + *t = NoteOn + case "NOTE_OFF": + *t = NoteOff + default: + *t = Unknown + } + + return nil +} + +func (t *MsgTyp) MarshalJSON() ([]byte, error) { + var sb strings.Builder + sb.WriteRune('"') + sb.WriteString(t.String()) + sb.WriteRune('"') + return []byte(sb.String()), nil +} + +type TokenClient interface { + RTokenClient + WTokenClient +} + +type RTokenClient interface { + ValidateRefreshToken(ctx context.Context, token string) error + ValidateClientID(ctx context.Context, cid string) error +} + +type WTokenClient interface { + BlackListClientID(ctx context.Context, cid, email string) error + BlackListRefreshToken(ctx context.Context, token string) error +} + +type TokenReader interface { + ValidateRefreshToken(ctx context.Context, token string) error + ValidateClientID(ctx context.Context, cid string) error +} + +type TokenWriter interface { + BlackListClientID(ctx context.Context, cid, email string) error + BlackListRefreshToken(ctx context.Context, token string) error +} + +type RepoReader[Entry any] interface { + // Returns an array of users subject to any filter + // conditions that are required + SelectMany(ctx context.Context) ([]Entry, error) + // Returns a user form the database, the "key" + // can be either the "id", "email" or "username" + // as these are all given unique values + Select(ctx context.Context, key any) (*Entry, error) +} + +type RepoWriter[Entry any] interface { + // Insert a new item to the database + Insert(ctx context.Context, e *Entry) error + // Performs a "hard" delete from database + // Restricted to admin only + Delete(ctx context.Context, key any) error +} + +type RepoCloser interface { + Close() +} + +// Custom user type required +type User struct { + ID suid.UUID `json:"id"` + Username string `json:"username"` + Email email.Email `json:"email"` + Password password.PasswordHash `json:"-"` +} diff --git a/internal/internal_test.go b/internal/internal_test.go new file mode 100644 index 00000000..cde9ec37 --- /dev/null +++ b/internal/internal_test.go @@ -0,0 +1,39 @@ +package internal + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hyphengolang/prelude/testing/is" + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" +) + +func TestCustomTypes(t *testing.T) { + t.Parallel() + + is := is.New(t) + + t.Run(`using "encoding/json" package with password`, func(t *testing.T) { + payload := `"this_password_is_complex"` + + var p password.Password + err := json.NewDecoder(strings.NewReader(payload)).Decode(&p) + is.NoErr(err) // parse password + + h, err := p.Hash() + is.NoErr(err) // hash password + + err = h.Compare(payload[1 : len(payload)-1]) + is.NoErr(err) // valid password + }) + + t.Run(`using "encoding/json" package with email`, func(t *testing.T) { + payload := `"fizz@mail.com"` + + var e email.Email + err := json.NewDecoder(strings.NewReader(payload)).Decode(&e) + is.NoErr(err) // parse email + }) +} diff --git a/internal/ruid/ruid.go b/internal/ruid/ruid.go new file mode 100644 index 00000000..c0de4e1b --- /dev/null +++ b/internal/ruid/ruid.go @@ -0,0 +1,24 @@ +package ruid + +import ( + "crypto/rand" + "io" +) + +type RUID string + +var table = [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} + +func New(length int) (string, error) { + b := make([]byte, length) + n, err := io.ReadAtLeast(rand.Reader, b, length) + if n != length { + return "", err + } + for i := 0; i < len(b); i++ { + b[i] = table[int(b[i])%len(table)] + } + return string(b), nil +} + +func (r RUID) String() string { return string(r) } diff --git a/internal/websocket/broker.go b/internal/websocket/broker.go new file mode 100644 index 00000000..a35ad2d2 --- /dev/null +++ b/internal/websocket/broker.go @@ -0,0 +1,121 @@ +package websocket + +import ( + "context" + "errors" + "sync" + + "github.com/hyphengolang/prelude/types/suid" +) + +// Broker contains the list of the Subscribers +type Broker[SI, CI any] struct { + lock sync.RWMutex + // list of Subscribers + ss map[suid.UUID]*Subscriber[SI, CI] + + // Maximum Capacity Subscribers allowed + Capacity uint + Context context.Context +} + +func NewBroker[SI, CI any](cap uint, ctx context.Context) *Broker[SI, CI] { + return &Broker[SI, CI]{ + ss: make(map[suid.UUID]*Subscriber[SI, CI]), + Capacity: cap, + Context: ctx, + } +} + +// Adds a new Subscriber to the list +func (b *Broker[SI, CI]) Subscribe(s *Subscriber[SI, CI]) { + b.connect(s) + b.add(s) +} + +func (b *Broker[SI, CI]) Unsubscribe(s *Subscriber[SI, CI]) error { + if err := b.disconnect(s); err != nil { + return err + } + b.remove(s) + return nil +} + +func (b *Broker[SI, CI]) Connect(s *Subscriber[SI, CI]) { + b.connect(s) +} + +func (b *Broker[SI, CI]) Disconnect(s *Subscriber[SI, CI]) error { + return b.disconnect(s) +} + +func (b *Broker[SI, CI]) GetSubscriber(sid suid.UUID) (*Subscriber[SI, CI], error) { + b.lock.Lock() + defer b.lock.Unlock() + s, ok := b.ss[sid] + + if !ok { + return nil, errors.New("Subscriber not found") + } + + return s, nil +} + +func (b *Broker[SI, CI]) ListSubscribers() []*Subscriber[SI, CI] { + b.lock.RLock() + defer b.lock.RUnlock() + + subs := make([]*Subscriber[SI, CI], 0, len(b.ss)) + for _, sub := range b.ss { + subs = append(subs, sub) + } + + return subs +} + +func (b *Broker[SI, CI]) add(s *Subscriber[SI, CI]) { + b.lock.Lock() + defer b.lock.Unlock() + b.ss[s.sid] = s +} + +func (b *Broker[SI, CI]) remove(s *Subscriber[SI, CI]) { + b.lock.Lock() + defer b.lock.Unlock() + close(s.ic) + close(s.oc) + close(s.errc) + delete(b.ss, s.sid) +} + +func (b *Broker[SI, CI]) connect(s *Subscriber[SI, CI]) { + if !s.online { + s.online = true + } + + go func() { + for m := range s.ic { + // s.lock.RLock() + cs := s.cs + // s.lock.RUnlock() + + for _, c := range cs { + if err := c.write(m.marshall()); err != nil { + s.errc <- &wsErr[CI]{c, err} + return + } + } + } + }() +} + +func (b *Broker[SI, CI]) disconnect(s *Subscriber[SI, CI]) error { + s.online = false + for _, c := range s.cs { + if err := s.disconnect(c); err != nil { + return err + } + } + + return nil +} diff --git a/internal/websocket/conn.go b/internal/websocket/conn.go new file mode 100644 index 00000000..536d0e64 --- /dev/null +++ b/internal/websocket/conn.go @@ -0,0 +1,26 @@ +package websocket + +import ( + "io" + "sync" + + "github.com/gobwas/ws/wsutil" + "github.com/hyphengolang/prelude/types/suid" +) + +// A Web-Socket Connection +type Conn[CI any] struct { + sid suid.UUID + rwc io.ReadWriteCloser + lock sync.RWMutex + + Info *CI +} + +// Writes raw bytes to the Connection +func (c *Conn[CI]) write(b []byte) error { + c.lock.RLock() + defer c.lock.RUnlock() + + return wsutil.WriteServerBinary(c.rwc, b) +} diff --git a/internal/websocket/error.go b/internal/websocket/error.go new file mode 100644 index 00000000..99b6e7a6 --- /dev/null +++ b/internal/websocket/error.go @@ -0,0 +1,10 @@ +package websocket + +type wsErr[CI any] struct { + conn *Conn[CI] + msg error +} + +func (e *wsErr[CI]) Error() string { + return e.msg.Error() +} diff --git a/internal/websocket/message.go b/internal/websocket/message.go new file mode 100644 index 00000000..afaf6478 --- /dev/null +++ b/internal/websocket/message.go @@ -0,0 +1,46 @@ +package websocket + +import ( + "encoding/json" +) + +type WSMsgTyp int + +const ( + Text WSMsgTyp = iota + 1 + JSON + Leave +) + +// type for parsing bytes into messages +type message struct { + typ WSMsgTyp + data []byte +} + +// Parses the bytes into the message type +func (m *message) parse(b []byte) { + // the first byte represents the data type (Text, JSON, Leave) + m.typ = WSMsgTyp(b[0]) + // and others represent the data itself + m.data = b[1:] +} + +func (m *message) marshall() []byte { + return append([]byte{byte(m.typ)}, m.data...) +} + +// Converts the given bytes to string +func (m *message) readText() (string, error) { + return string(m.data), nil +} + +// Converts the given bytes to JSON +func (m *message) readJSON() (any, error) { + var v any + if err := json.Unmarshal(m.data, v); err != nil { + return nil, err + } + + return v, nil +} diff --git a/internal/websocket/subscriber.go b/internal/websocket/subscriber.go new file mode 100644 index 00000000..0863c4f9 --- /dev/null +++ b/internal/websocket/subscriber.go @@ -0,0 +1,204 @@ +package websocket + +import ( + "context" + "io" + "log" + "sync" + "time" + + "github.com/gobwas/ws/wsutil" + "github.com/hyphengolang/prelude/types/suid" +) + +// Subscriber contains the list of the connections +type Subscriber[SI, CI any] struct { + // unique id for the Subscriber + sid suid.UUID + lock sync.RWMutex + // list of Connections + cs map[suid.UUID]*Conn[CI] + // Subscriber status + online bool + // Input/Output channel for new messages + ic chan *message + oc chan *message + // error channel + errc chan *wsErr[CI] + // Maximum Capacity clients allowed + Capacity uint + // Maximum message size allowed from peer. + ReadBufferSize int64 + // Time allowed to read the next pong message from the peer. + ReadTimeout time.Duration + // Time allowed to write a message to the peer. + WriteTimeout time.Duration + // Info binds its value(like a Jam session) to the subscriber + Info *SI + Context context.Context +} + +func NewSubscriber[SI, CI any]( + ctx context.Context, + cap uint, + rs int64, + rt time.Duration, + wt time.Duration, + i *SI, +) *Subscriber[SI, CI] { + s := &Subscriber[SI, CI]{ + sid: suid.NewUUID(), + cs: make(map[suid.UUID]*Conn[CI]), + // I did make + ic: make(chan *message), + oc: make(chan *message), + errc: make(chan *wsErr[CI]), + Capacity: cap, + ReadBufferSize: rs, + ReadTimeout: rt, + WriteTimeout: wt, + Info: i, + Context: ctx, + } + + s.catch() + s.listen() + + return s +} + +func (s *Subscriber[SI, CI]) NewConn(rwc io.ReadWriteCloser, info *CI) *Conn[CI] { + return &Conn[CI]{ + sid: suid.NewUUID(), + rwc: rwc, + Info: info, + } +} + +func (s *Subscriber[SI, CI]) Subscribe(c *Conn[CI]) { + s.connect(c) + s.add(c) +} + +func (s *Subscriber[SI, CI]) Unsubscribe(c *Conn[CI]) error { + if err := s.disconnect(c); err != nil { + return err + } + s.remove(c) + return nil +} + +// func (s *Subscriber[SI, CI]) Connect(c *Conn[CI]) error { +// return s.connect(c) +// } + +// func (s *Subscriber[SI, CI]) Disconnect(c *Conn[CI]) error { +// return s.disconnect(c) +// } + +func (s *Subscriber[SI, CI]) ListConns() []*Conn[CI] { + s.lock.RLock() + defer s.lock.RUnlock() + + conns := make([]*Conn[CI], 0, len(s.cs)) + for _, sub := range s.cs { + conns = append(conns, sub) + } + + return conns +} + +func (s *Subscriber[SI, CI]) IsFull() bool { + if s.Capacity == 0 { + return false + } + + return len(s.cs) >= int(s.Capacity) +} + +func (s *Subscriber[SI, CI]) GetID() suid.UUID { + return s.sid +} + +// listen to the input channel and broadcast messages to clients. +func (s *Subscriber[SI, CI]) listen() { + go func() { + for p := range s.ic { + for _, c := range s.cs { + if err := c.write(p.marshall()); err != nil { + s.errc <- &wsErr[CI]{c, err} + return + } + } + } + }() +} + +func (s *Subscriber[SI, CI]) catch() { + go func() { + for e := range s.errc { + if err := s.disconnect(e.conn); err != nil { + log.Println(err) + } + } + }() +} + +func (s *Subscriber[SI, CI]) add(c *Conn[CI]) { + s.lock.Lock() + defer s.lock.Unlock() + + // add the connection to the list + s.cs[c.sid] = c +} + +func (s *Subscriber[SI, CI]) remove(c *Conn[CI]) { + s.lock.Lock() + defer s.lock.Unlock() + + // remove connection from the list + delete(s.cs, c.sid) +} + +// Connects the given Connection to the Subscriber and starts reading from it +func (s *Subscriber[SI, CI]) connect(c *Conn[CI]) { + c.lock.RLock() + defer c.lock.RUnlock() + + go func() { + defer func() { + if err := s.disconnect(c); err != nil { + s.errc <- &wsErr[CI]{c, err} + return + } + }() + + for { + // read binary from connection + b, err := wsutil.ReadClientBinary(c.rwc) + if err != nil { + s.errc <- &wsErr[CI]{c, err} + return + } + + var m message + m.parse(b) + + switch m.typ { + case Leave: + if err := s.disconnect(c); err != nil { + s.errc <- &wsErr[CI]{c, err} + return + } + default: + s.ic <- &m + } + } + }() +} + +// Closes the given Connection and removes it from the Connections list +func (s *Subscriber[SI, CI]) disconnect(c *Conn[CI]) error { + // close websocket connection + return c.rwc.Close() +} diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go new file mode 100644 index 00000000..1c792001 --- /dev/null +++ b/internal/websocket/websocket.go @@ -0,0 +1,11 @@ +package websocket + +type Reader interface { + ReadText() (string, error) + ReadJSON() (interface{}, error) +} + +type Writer interface { + WriteText(s string) + WriteJSON(i any) error +} diff --git a/internal/websocket/websocket_test.go b/internal/websocket/websocket_test.go new file mode 100644 index 00000000..f2d6bf3f --- /dev/null +++ b/internal/websocket/websocket_test.go @@ -0,0 +1,182 @@ +package websocket_test + +// ok +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" + "github.com/hyphengolang/prelude/testing/is" + "github.com/hyphengolang/prelude/types/suid" + "github.com/rog-golang-buddies/rmx/internal/websocket" +) + +// so I am defining a simple echo server here for testing +func testServerPartA() http.Handler { + ctx := context.Background() + + s := websocket.NewSubscriber[any, any]( + ctx, + 2, + 512, + 2*time.Second, + 2*time.Second, + nil, + ) + + mux := http.NewServeMux() + + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, _, _, err := ws.UpgradeHTTP(r, w) + if err != nil { + return + } + + wsc := s.NewConn(conn, nil) + s.Subscribe(wsc) + }) + + return mux +} + +func TestSubscriber(t *testing.T) { + // t.Skip() + + is := is.New(t) + ctx := context.Background() + + srv := httptest.NewServer(testServerPartA()) + + t.Cleanup(func() { + srv.Close() + }) + + // NOTE - can you try this test for me please? passed, really? + t.Run("create a new client and connect to echo server", func(t *testing.T) { + wsPath := stripPrefix(srv.URL + "/ws") // this correct right? yup + + cli1, _, _, err := ws.DefaultDialer.Dial(ctx, wsPath) + is.NoErr(err) // connect cli1 to server + defer cli1.Close() // ok + + cli2, _, _, err := ws.DefaultDialer.Dial(ctx, wsPath) + is.NoErr(err) // connect cli2 to server + defer cli2.Close() // ok + + _, _, _, err = ws.DefaultDialer.Dial(ctx, wsPath) + is.NoErr(err) // cannot connect to the server + + data := []byte("Hello World!") + typ := []byte{1} + m := append(typ, data...) + + err = wsutil.WriteClientBinary(cli1, m) + is.NoErr(err) // send message to server + + // now I want to read the message from the server + msg, err := wsutil.ReadServerBinary(cli2) + is.NoErr(err) // read message from server + is.Equal(m, msg) // check if message is correct + }) +} + +func testServerPartB() http.Handler { + ctx := context.Background() + + type Info struct { + Username string + } + + b := websocket.NewBroker[Info, any](3, ctx) + + mux := http.NewServeMux() + + mux.HandleFunc("/create", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + s := websocket.NewSubscriber[Info, any](ctx, 2, 512, 2*time.Second, 2*time.Second, &Info{ + Username: "John Doe", + }) + + w.Header().Set("Location", "/"+s.GetID().ShortUUID().String()) + }) + + // so I need to get the subscriber from parsing here right? yes + mux.HandleFunc("/ws/{suid}", func(w http.ResponseWriter, r *http.Request) { + sid, err := parseSUID(w, r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + // can you see me? + + s, err := b.GetSubscriber(sid) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + conn, _, _, err := ws.UpgradeHTTP(r, w) + if err != nil { + return + } + + wsc := s.NewConn(conn, nil) + s.Subscribe(wsc) + }) + + return mux +} + +func TestBroker(t *testing.T) { + is := is.New(t) + + srv := httptest.NewServer(testServerPartB()) + + t.Cleanup(func() { + srv.Close() + }) + + t.Run("create a new subscriber", func(t *testing.T) { + srv.Client().Post(srv.URL+"/create", "application/json", nil) + + is.NoErr(nil) + }) + + t.Run("connect to subscriber", func(t *testing.T) { + t.Skip() + + is.NoErr(nil) + }) + + t.Run("delete a subscriber", func(t *testing.T) { + t.Skip() + is.NoErr(nil) + }) +} + +var resource = func(s string) string { + return s[strings.LastIndex(s, "/")+1:] +} + +var stripPrefix = func(s string) string { + return "ws" + strings.TrimPrefix(s, "http") +} + +func parseSUID(w http.ResponseWriter, r *http.Request) (suid.UUID, error) { + return suid.ParseString(chi.URLParam(r, "uuid")) +} + +/* + + */ diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 00000000..56daddd7 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,267 @@ +package auth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "net/http" + "time" + + "github.com/go-redis/redis/v9" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/pkg/errors" +) + +type Client struct { + rtdb, cidb *redis.Client +} + +var ( + ErrNotImplemented = errors.New("not implemented") + ErrGenerateKey = errors.New("failed to generate new ecdsa key pair") + ErrSignTokens = errors.New("failed to generate signed tokens") + ErrRTValidate = errors.New("failed to validate refresh token") +) + +func NewRedis(addr, password string) *Client { + rtdb := redis.Options{Addr: addr, Password: password, DB: 0} + cidb := redis.Options{Addr: addr, Password: password, DB: 1} + + c := &Client{redis.NewClient(&rtdb), redis.NewClient(&cidb)} + return c +} + +const ( + defaultAddr = "localhost:6379" + defaultPassword = "" +) + +var DefaultClient = &Client{ + rtdb: redis.NewClient(&redis.Options{Addr: defaultAddr, Password: defaultPassword, DB: 0}), + cidb: redis.NewClient(&redis.Options{Addr: defaultAddr, Password: defaultPassword, DB: 1}), +} + +func (c *Client) ValidateRefreshToken(ctx context.Context, token string) error { + tc, err := ParseRefreshTokenClaims(token) + if err != nil { + return err + } + + cid := tc.Subject() + email, ok := tc.PrivateClaims()["email"].(string) + if !ok { + return ErrRTValidate + } + + if err := c.ValidateClientID(ctx, cid); err != nil { + return err + } + + if _, err := c.rtdb.Get(ctx, token).Result(); err != nil { + switch err { + case redis.Nil: + return nil + default: + return err + } + } + + err = c.BlackListClientID(ctx, cid, email) + if err != nil { + return err + } + + return ErrRTValidate +} + +func (c *Client) BlackListClientID(ctx context.Context, cid, email string) error { + _, err := c.cidb.Set(ctx, cid, email, RefreshTokenExpiry).Result() + if err != nil { + return err + } + return nil +} + +func (c *Client) BlackListRefreshToken(ctx context.Context, token string) error { + _, err := c.rtdb.Set(ctx, token, nil, RefreshTokenExpiry).Result() + return err +} + +func (c *Client) ValidateClientID(ctx context.Context, cid string) error { + // check if a key with client id exists + // if the key exists it means that the client id is revoked and token should be denied + // we don't need the email value here + _, err := c.cidb.Get(ctx, cid).Result() + if err != nil { + switch err { + case redis.Nil: + return nil + default: + return ErrRTValidate + } + } + + return ErrRTValidate +} + +func ES256() (public, private jwk.Key) { + raw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + private, err = jwk.FromRaw(raw) + if err != nil { + panic(err) + } + + public, err = private.PublicKey() + if err != nil { + panic(err) + } + + return +} + +func RS256() (public, private jwk.Key) { + raw, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + + if private, err = jwk.FromRaw(raw); err != nil { + panic(err) + } + + if public, err = private.PublicKey(); err != nil { + panic(err) + } + + return +} + +func Sign(key jwk.Key, o *TokenOption) ([]byte, error) { + var sep jwt.SignEncryptParseOption + switch key := key.(type) { + case jwk.RSAPrivateKey: + sep = jwt.WithKey(jwa.RS256, key) + case jwk.ECDSAPrivateKey: + sep = jwt.WithKey(jwa.ES256, key) + default: + return nil, errors.New(`unsupported encryption`) + } + + var iat time.Time + if o.IssuedAt.IsZero() { + iat = time.Now().UTC() + } else { + iat = o.IssuedAt + } + + tk, err := jwt.NewBuilder(). + Issuer(o.Issuer). + Audience(o.Audience). + Subject(o.Subject). + IssuedAt(iat). + Expiration(iat.Add(o.Expiration)). + Build() + + if err != nil { + return nil, ErrSignTokens + } + + for k, v := range o.Claims { + if err := tk.Set(k, v); err != nil { + return nil, err + } + } + + return jwt.Sign(tk, sep) +} + +func ParseCookie(r *http.Request, key jwk.Key, cookieName string) (jwt.Token, error) { + c, err := r.Cookie(cookieName) + if err != nil { + return nil, err + } + + var sep jwt.SignEncryptParseOption + switch key := key.(type) { + case jwk.RSAPublicKey: + sep = jwt.WithKey(jwa.RS256, key) + case jwk.ECDSAPublicKey: + sep = jwt.WithKey(jwa.ES256, key) + default: + return nil, errors.New(`unsupported encryption`) + } + + return jwt.Parse([]byte(c.Value), sep) +} + +/* +ParseRequest searches a http.Request object for a JWT token. + +Specifying WithHeaderKey() will tell it to search under a specific +header key. Specifying WithFormKey() will tell it to search under +a specific form field. + +By default, "Authorization" header will be searched. + +If WithHeaderKey() is used, you must explicitly re-enable searching for "Authorization" header. + + # searches for "Authorization" + jwt.ParseRequest(req) + + # searches for "x-my-token" ONLY. + jwt.ParseRequest(req, jwt.WithHeaderKey("x-my-token")) + + # searches for "Authorization" AND "x-my-token" + jwt.ParseRequest(req, jwt.WithHeaderKey("Authorization"), jwt.WithHeaderKey("x-my-token")) +*/ +func ParseRequest(r *http.Request, key jwk.Key) (jwt.Token, error) { + var sep jwt.SignEncryptParseOption + switch key := key.(type) { + case jwk.RSAPublicKey: + sep = jwt.WithKey(jwa.RS256, key) + case jwk.ECDSAPublicKey: + sep = jwt.WithKey(jwa.ES256, key) + default: + return nil, errors.New(`unsupported encryption`) + } + return jwt.ParseRequest(r, sep) +} + +func ParseRefreshTokenClaims(token string) (jwt.Token, error) { return jwt.Parse([]byte(token)) } + +func ParseRefreshTokenWithValidate(key *jwk.Key, token string) (jwt.Token, error) { + payload, err := jwt.Parse([]byte(token), + jwt.WithKey(jwa.ES256, key), + jwt.WithValidate(true)) + if err != nil { + return nil, err + } + + return payload, nil +} + +type TokenOption struct { + IssuedAt time.Time + Issuer string + Audience []string + Subject string + Expiration time.Duration + Claims map[string]any +} + +type authCtxKey string + +const ( + // RefreshTokenCookieName = "RMX_REFRESH_TOKEN" + RefreshTokenExpiry = time.Hour * 24 * 7 + AccessTokenExpiry = time.Minute * 5 + EmailKey = authCtxKey("rmx-email") +) diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 00000000..e1e2392c --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,88 @@ +package auth + +import ( + "net/http" + "testing" + "time" + + "github.com/hyphengolang/prelude/testing/is" + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + "github.com/hyphengolang/prelude/types/suid" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/rog-golang-buddies/rmx/internal" +) + +func TestToken(t *testing.T) { + t.Parallel() + is := is.New(t) + + t.Run(`generate a token and sign`, func(t *testing.T) { + _, private := ES256() + + u := internal.User{ + ID: suid.NewUUID(), + Username: "fizz_user", + Email: "fizz@mail.com", + Password: password.Password("492045rf-vf").MustHash(), + } + + o := TokenOption{ + Issuer: "github.com/rog-golang-buddies/rmx", + Subject: suid.NewUUID().String(), + Expiration: time.Hour * 10, + Claims: map[string]any{"email": u.Email}, + } + + _, err := Sign(private, &o) + is.NoErr(err) // sign id token + + o.Subject = u.ID.String() + o.Expiration = AccessTokenExpiry + + _, err = Sign(private, &o) + is.NoErr(err) // access token + + o.Expiration = RefreshTokenExpiry + _, err = Sign(private, &o) + is.NoErr(err) // refresh token + }) +} + +func TestMiddleware(t *testing.T) { + t.Parallel() + is := is.New(t) + + t.Run("jwk parse request", func(t *testing.T) { + public, private := ES256() + + e, cookieName := email.Email("foobar@gmail.com"), `__g` + + o := TokenOption{ + Issuer: "github.com/rog-golang-buddies/rmx", + Subject: suid.NewUUID().String(), + Expiration: time.Hour * 10, + Claims: map[string]any{"email": e.String()}, + } + + // rts + rts, err := Sign(private, &o) + is.NoErr(err) // signing refresh token + + c := &http.Cookie{ + Path: "/", + Name: cookieName, + Value: string(rts), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 24 * 7, + } + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(c) + + // + _, err = jwt.Parse([]byte(c.Value), jwt.WithKey(jwa.ES256, public), jwt.WithValidate(true)) + is.NoErr(err) // parsing jwk page not found + }) +} diff --git a/pkg/repotest/user.go b/pkg/repotest/user.go new file mode 100644 index 00000000..17c7b628 --- /dev/null +++ b/pkg/repotest/user.go @@ -0,0 +1,135 @@ +package repotest + +import ( + "context" + "log" + "sync" + "time" + + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + "github.com/hyphengolang/prelude/types/suid" + + "github.com/rog-golang-buddies/rmx/internal" + "github.com/rog-golang-buddies/rmx/store/user" +) + +type UserRepo interface { + user.Repo +} + +func (r *repo) Close() {} + +func (r *repo) Delete(ctx context.Context, key any) error { return nil } + +type repo struct { + mu sync.Mutex + miu map[suid.UUID]*user.User + mei map[string]*user.User + + log func(v ...any) + logf func(format string, v ...any) +} + +func NewUserRepo() UserRepo { + r := &repo{ + miu: make(map[suid.UUID]*user.User), + mei: make(map[string]*user.User), + log: log.Println, + logf: log.Printf, + } + + return r +} + +// Remove implements internal.UserRepo +func (r *repo) Remove(ctx context.Context, key any) error { + panic("unimplemented") +} + +func (r *repo) Insert(ctx context.Context, iu *internal.User) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, found := r.mei[iu.Email.String()]; found { + return internal.ErrAlreadyExists + } + + u := &user.User{ + ID: iu.ID, + Username: iu.Username, + Email: iu.Email, + Password: iu.Password, + CreatedAt: time.Now(), + } + r.mei[iu.Email.String()], r.miu[iu.ID] = u, u + + return nil +} + +func (r *repo) SelectMany(ctx context.Context) ([]internal.User, error) { + panic("not implemented") +} + +func (r *repo) Select(ctx context.Context, key any) (*internal.User, error) { + switch key := key.(type) { + case suid.UUID: + return r.selectUUID(key) + case email.Email: + return r.selectEmail(key) + case string: + return r.selectUsername(key) + default: + return nil, internal.ErrInvalidType + } +} + +func (r *repo) selectUUID(uid suid.UUID) (*internal.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if u, ok := r.miu[uid]; ok { + return &internal.User{ + ID: u.ID, + Username: u.Username, + Email: email.Email(u.Email), + Password: password.PasswordHash(u.Password), + }, nil + } + + return nil, internal.ErrNotFound +} + +func (r *repo) selectUsername(username string) (*internal.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, u := range r.mei { + if u.Username == username { + return &internal.User{ + ID: u.ID, + Username: u.Username, + Email: email.Email(u.Email), + Password: password.PasswordHash(u.Password), + }, nil + } + } + + return nil, internal.ErrNotFound +} + +func (r *repo) selectEmail(email email.Email) (*internal.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if u, ok := r.mei[email.String()]; ok { + return &internal.User{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Password: password.PasswordHash(u.Password), + }, nil + } + + return nil, internal.ErrNotFound +} diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 00000000..9ea399c7 --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + h "github.com/hyphengolang/prelude/http" +) + +type Service interface { + chi.Router + + Context() context.Context + + Log(...any) + Logf(string, ...any) + + Decode(http.ResponseWriter, *http.Request, any) error + Respond(http.ResponseWriter, *http.Request, any, int) + RespondText(w http.ResponseWriter, r *http.Request, status int) + Created(http.ResponseWriter, *http.Request, string) + SetCookie(http.ResponseWriter, *http.Cookie) +} + +type service struct { + ctx context.Context + chi.Router +} + +// Context implements Service +func (s *service) Context() context.Context { + if s.ctx == nil { + return context.Background() + } + return s.ctx +} + +// Created implements Service +func (*service) Created(w http.ResponseWriter, r *http.Request, id string) { h.Created(w, r, id) } + +// Decode implements Service +func (*service) Decode(w http.ResponseWriter, r *http.Request, v any) error { return h.Decode(w, r, v) } + +// Log implements Service +func (*service) Log(v ...any) { log.Println(v...) } + +// Logf implements Service +func (*service) Logf(format string, v ...any) { log.Printf(format, v...) } + +// Respond implements Service +func (*service) Respond(w http.ResponseWriter, r *http.Request, v any, status int) { + h.Respond(w, r, v, status) +} + +func (s *service) RespondText(w http.ResponseWriter, r *http.Request, status int) { + s.Respond(w, r, http.StatusText(status), status) +} + +// SetCookie implements Service +func (*service) SetCookie(w http.ResponseWriter, c *http.Cookie) { http.SetCookie(w, c) } + +func New(ctx context.Context, mux chi.Router) Service { + return &service{ctx, mux} +} diff --git a/service/auth/service.go b/service/auth/service.go new file mode 100644 index 00000000..ec782127 --- /dev/null +++ b/service/auth/service.go @@ -0,0 +1,341 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/lestrrat-go/jwx/v2/jwk" + + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + + // github.com/rog-golang-buddies/rmx/service/internal/auth/auth + "github.com/hyphengolang/prelude/types/suid" + "github.com/rog-golang-buddies/rmx/internal" + "github.com/rog-golang-buddies/rmx/store/user" + + "github.com/rog-golang-buddies/rmx/pkg/auth" + "github.com/rog-golang-buddies/rmx/pkg/service" +) + +var ( + ErrNoCookie = errors.New("user: cookie not found") + ErrSessionNotFound = errors.New("user: session not found") + ErrSessionExists = errors.New("user: session already exists") +) + +/* +Register a new user + + [?] POST /auth/sign-up + +Get current account identity + + [?] GET /account/me + +Delete devices linked to account + + [ ] DELETE /account/{uuid}/device + +this returns a list of current connections: + + [ ] GET /account/{uuid}/devices + +Create a cookie + + [?] POST /auth/sign-in + +Delete a cookie + + [?] DELETE /auth/sign-out + +Refresh token + + [?] GET /auth/refresh +*/ +type Service struct { + service.Service + + r user.Repo + tc internal.TokenClient +} + +func (s *Service) routes() { + public, private := auth.ES256() + + s.Route("/api/v1/auth", func(r chi.Router) { + r.Post("/sign-in", s.handleSignIn(private)) + r.Delete("/sign-out", s.handleSignOut()) + r.Post("/sign-up", s.handleSignUp()) + + r.Get("/refresh", s.handleRefresh(public, private)) + }) + + s.Route("/api/v1/account", func(r chi.Router) { + r.Get("/me", s.handleIdentity(public)) + }) +} + +// FIXME this endpoint is broken due to the redis client +// We need to try fix this ASAP +func (s *Service) handleRefresh(public, private jwk.Key) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // NOTE temp switch away from auth middleware + jtk, err := auth.ParseCookie(r, public, cookieName) + if err != nil { + s.Respond(w, r, err, http.StatusUnauthorized) + return + } + + claim, ok := jtk.PrivateClaims()["email"].(string) + if !ok { + s.RespondText(w, r, http.StatusInternalServerError) + return + } + + u, err := s.r.Select(r.Context(), email.Email(claim)) + if err != nil { + s.Respond(w, r, err, http.StatusForbidden) + return + } + + // FIXME commented out as not complete + // // already checked in auth but I am too tired + // // to come up with a cleaner solution + // k, _ := r.Cookie(cookieName) + + // err := s.tc.ValidateRefreshToken(r.Context(), k.Value) + // if err != nil { + // s.Respond(w, r, err, http.StatusInternalServerError) + // return + // } + + // // token validated, now it should be set inside blacklist + // // this prevents token reuse + // err = s.tc.BlackListRefreshToken(r.Context(), k.Value) + // if err != nil { + // s.Respond(w, r, err, http.StatusInternalServerError) + // } + + // cid := j.Subject() + // _, ats, rts, err := s.signedTokens(private, claim.String(), suid.SUID(cid)) + // if err != nil { + // s.Respond(w, r, err, http.StatusInternalServerError) + // return + // } + + u.ID, _ = suid.ParseString(jtk.Subject()) + + _, ats, rts, err := s.signedTokens(private, u) + if err != nil { + s.Respond(w, r, err, http.StatusInternalServerError) + return + } + + c := s.newCookie(w, r, string(rts), auth.RefreshTokenExpiry) + + tk := &Token{ + AccessToken: string(ats), + } + + s.SetCookie(w, c) + s.Respond(w, r, tk, http.StatusOK) + } +} + +func (s *Service) handleIdentity(public jwk.Key) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := s.authenticate(w, r, public) + if err != nil { + s.Respond(w, r, err, http.StatusUnauthorized) + return + } + + s.Respond(w, r, u, http.StatusOK) + } +} + +func (s *Service) handleSignIn(privateKey jwk.Key) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var dto User + if err := s.Decode(w, r, &dto); err != nil { + s.Respond(w, r, err, http.StatusBadRequest) + return + } + + u, err := s.r.Select(r.Context(), dto.Email) + if err != nil { + s.Respond(w, r, err, http.StatusNotFound) + return + } + + if err := u.Password.Compare(dto.Password.String()); err != nil { + s.Respond(w, r, err, http.StatusUnauthorized) + return + } + + // NOTE - need to replace u.UUID with a client based ID + // this will mean different cookies for multi-device usage + u.ID = suid.NewUUID() + + its, ats, rts, err := s.signedTokens(privateKey, u) + if err != nil { + s.Respond(w, r, err, http.StatusInternalServerError) + return + } + + c := s.newCookie(w, r, string(rts), auth.RefreshTokenExpiry) + + tk := &Token{ + IDToken: string(its), + AccessToken: string(ats), + } + + s.SetCookie(w, c) + s.Respond(w, r, tk, http.StatusOK) + } +} + +func (s *Service) handleSignOut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c := s.newCookie(w, r, "", -1) + + s.SetCookie(w, c) + s.Respond(w, r, http.StatusText(http.StatusOK), http.StatusOK) + } +} + +func (s *Service) handleSignUp() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var u internal.User + if err := s.newUser(w, r, &u); err != nil { + s.Respond(w, r, err, http.StatusBadRequest) + return + } + + if err := s.r.Insert(r.Context(), &u); err != nil { + s.Respond(w, r, err, http.StatusInternalServerError) + return + } + + suid := u.ID.ShortUUID().String() + s.Created(w, r, suid) + } +} + +func (s *Service) newUser(w http.ResponseWriter, r *http.Request, u *internal.User) (err error) { + var dto User + if err = s.Decode(w, r, &dto); err != nil { + return + } + + var h password.PasswordHash + h, err = dto.Password.Hash() + if err != nil { + return + } + + *u = internal.User{ + ID: suid.NewUUID(), + Username: dto.Username, + Email: dto.Email, + Password: h, + } + + return nil +} + +func (s *Service) parseUUID(w http.ResponseWriter, r *http.Request) (suid.UUID, error) { + return suid.ParseString(chi.URLParam(r, "uuid")) +} + +func (s *Service) newCookie(w http.ResponseWriter, r *http.Request, value string, maxAge time.Duration) *http.Cookie { + c := &http.Cookie{ + Path: "/", + Name: cookieName, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: int(maxAge), + Value: string(value), + } + return c +} + +func (s *Service) authenticate(w http.ResponseWriter, r *http.Request, public jwk.Key) (*internal.User, error) { + tk, err := auth.ParseRequest(r, public) + if err != nil { + return nil, err + } + + claim, ok := tk.PrivateClaims()["email"].(string) + if err := fmt.Errorf("email claim does not exist"); !ok { + return nil, err + } + + u, err := s.r.Select(r.Context(), email.MustParse(claim)) + if err != nil { + return nil, err + } + + return u, nil +} + +// TODO there is two cid's being used here, need clarification +func (s *Service) signedTokens(private jwk.Key, u *internal.User) (its, ats, rts []byte, err error) { + o := auth.TokenOption{ + Issuer: issuer, + Subject: u.ID.ShortUUID().String(), // new client ID for tracking user connections + // Audience: []string{}, + Claims: map[string]any{"email": u.Email}, + } + + // its + o.Expiration = idTokenExp + if its, err = auth.Sign(private, &o); err != nil { + return + } + + // ats + o.Expiration = accessTokenExp + if ats, err = auth.Sign(private, &o); err != nil { + return + } + + // rts + o.Expiration = refreshTokenExp + if rts, err = auth.Sign(private, &o); err != nil { + return + } + + return +} + +func NewService(ctx context.Context, m chi.Router, r user.Repo, tc internal.TokenClient) *Service { + s := &Service{service.New(ctx, m), r, tc} + s.routes() + return s +} + +type User struct { + Email email.Email `json:"email"` + Username string `json:"username"` + Password password.Password `json:"password"` +} + +type Token struct { + IDToken string `omitempty,json:"idToken"` + AccessToken string `omitempty,json:"accessToken"` +} + +const ( + issuer = "github.com/rog-golang-buddies/rmx" + cookieName = "RMX_REFRESH_TOKEN" + idTokenExp = time.Hour * 10 + refreshTokenExp = time.Hour * 24 * 7 + accessTokenExp = time.Minute * 5 +) diff --git a/service/auth/service_test.go b/service/auth/service_test.go new file mode 100644 index 00000000..a8b34c09 --- /dev/null +++ b/service/auth/service_test.go @@ -0,0 +1,106 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/hyphengolang/prelude/testing/is" + "github.com/rog-golang-buddies/rmx/pkg/repotest" + "github.com/rog-golang-buddies/rmx/store/auth" +) + +const applicationJson = "application/json" + +var s http.Handler + +func init() { + ctx, mux := context.Background(), chi.NewMux() + + s = NewService(ctx, mux, repotest.NewUserRepo(), auth.DefaultTokenClient) +} + +func TestService(t *testing.T) { + t.Parallel() + is := is.New(t) + + srv := httptest.NewServer(s) + t.Cleanup(func() { srv.Close() }) + + t.Run("register a new user", func(t *testing.T) { + payload := ` + { + "email":"fizz@gmail.com", + "username":"fizz_user", + "password":"fizz_$PW_10" + }` + + res, _ := srv.Client(). + Post(srv.URL+"/api/v1/auth/sign-up", applicationJson, strings.NewReader(payload)) + is.Equal(res.StatusCode, http.StatusCreated) + }) + + t.Run("sign-in, access auth endpoint then sign-out", func(t *testing.T) { + payload := ` + { + "email":"fizz@gmail.com", + "password":"fizz_$PW_10" + }` + + res, _ := srv.Client(). + Post(srv.URL+"/api/v1/auth/sign-in", applicationJson, strings.NewReader(payload)) + is.Equal(res.StatusCode, http.StatusOK) + + type body struct { + IDToken string `json:"idToken"` + AccessToken string `json:"accessToken"` + } + + var b body + err := json.NewDecoder(res.Body).Decode(&b) + res.Body.Close() + is.NoErr(err) // parsing json + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/account/me", nil) + req.Header.Set(`Authorization`, fmt.Sprintf(`Bearer %s`, b.AccessToken)) + res, _ = srv.Client().Do(req) + is.Equal(res.StatusCode, http.StatusOK) // authorized endpoint + + req, _ = http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/auth/sign-out", nil) + req.Header.Set(`Authorization`, fmt.Sprintf(`Bearer %s`, b.AccessToken)) + res, _ = srv.Client().Do(req) + is.Equal(res.StatusCode, http.StatusOK) // delete cookie + }) + + t.Run("refresh token", func(t *testing.T) { + payload := ` + { + "email":"fizz@gmail.com", + "password":"fizz_$PW_10" + }` + + res, _ := srv.Client(). + Post(srv.URL+"/api/v1/auth/sign-in", applicationJson, strings.NewReader(payload)) + is.Equal(res.StatusCode, http.StatusOK) // add refresh token + + // get the refresh token from the response's `Set-Cookie` header + c := &http.Cookie{} + for _, k := range res.Cookies() { + t.Log(k.Value) + if k.Name == cookieName { + c = k + } + } + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/auth/refresh", nil) + req.AddCookie(c) + + res, _ = srv.Client().Do(req) + is.Equal(res.StatusCode, http.StatusOK) // refresh token + }) +} diff --git a/service/jam/service.go b/service/jam/service.go new file mode 100644 index 00000000..0dcf024f --- /dev/null +++ b/service/jam/service.go @@ -0,0 +1,242 @@ +package jam + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rog-golang-buddies/rmx/pkg/service" + + "github.com/hyphengolang/prelude/http/websocket" + + "github.com/hyphengolang/prelude/types/suid" +) + +// Jam Service Endpoints +// +// Create a new jam session. +// +// POST /api/v1/jam +// +// List all jam sessions metadata. +// +// GET /api/v1/jam +// +// Get a jam sessions metadata. +// +// GET /api/v1/jam/{uuid} +// +// Connect to jam session. +// +// GET /ws/jam/{uuid} +type Service struct { + service.Service + + // c *ws.Client +} + +type muxEntry struct { + sid suid.UUID + pool *websocket.Pool +} + +func (e muxEntry) String() string { return e.sid.ShortUUID().String() } + +type mux struct { + mu sync.Mutex + mp map[suid.UUID]muxEntry +} + +func (mux *mux) Store(e muxEntry) { + mux.mu.Lock() + { + mux.mp[e.sid] = e + } + mux.mu.Unlock() +} + +func (mux *mux) Load(sid suid.UUID) (pool *websocket.Pool, err error) { + mux.mu.Lock() + e, ok := mux.mp[sid] + mux.mu.Unlock() + + if !ok { + return nil, errors.New("pool not found") + } + + return e.pool, nil +} + +func (s *Service) routes() { + // NOTE this map is temporary + // map[suid.SUID]*websocket.Pool + var mux = &mux{ + mp: make(map[suid.UUID]muxEntry), + } + + s.Route("/api/v1/jam", func(r chi.Router) { + // r.Get("/", s.handleListRooms()) + r.Post("/", s.handleCreateJamRoom(mux)) + // r.Get("/{uuid}", s.handleGetRoomData(mux)) + }) + + s.Route("/ws/jam", func(r chi.Router) { + r.Get("/{uuid}", s.handleP2PComms(mux)) + }) + +} + +func (s *Service) handleP2PComms(mux *mux) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // decode uuid from + sid, err := s.parseUUID(w, r) + if err != nil { + s.Respond(w, r, err, http.StatusBadRequest) + return + } + + pool, err := mux.Load(sid) + if err != nil { + s.Respond(w, r, err, http.StatusNotFound) + return + } + + if err := errors.New("pool has reached max capacity"); pool.IsFull() { + s.Respond(w, r, err, http.StatusServiceUnavailable) + return + } + + rwc, err := websocket.UpgradeHTTP(w, r) + if err != nil { + s.Respond(w, r, err, http.StatusUpgradeRequired) + return + } + + pool.ListenAndServe(rwc) + } +} + +func (s *Service) handleCreateJamRoom(mux *mux) http.HandlerFunc { + type payload struct { + Capacity uint `json:"capacity"` + } + + return func(w http.ResponseWriter, r *http.Request) { + var pl payload + if err := s.Decode(w, r, &pl); err != nil { + s.Respond(w, r, err, http.StatusBadRequest) + return + } + + pool := &websocket.Pool{ + Capacity: pl.Capacity, + ReadBufferSize: 512, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + e := muxEntry{suid.NewUUID(), pool} + + mux.Store(e) + s.Created(w, r, e.String()) + } +} + +// func (s *Service) handleGetRoomData(mux *mux) http.HandlerFunc { +// return func(w http.ResponseWriter, r *http.Request) { +// uid, err := s.parseUUID(w, r) +// if err != nil { +// s.Respond(w, r, err, http.StatusBadRequest) +// return +// } + +// // FIXME possible rename +// // method as `Get` is nondescriptive +// p, err := s.c.Get(uid) +// if err != nil { +// s.Respond(w, r, err, http.StatusNotFound) +// return +// } + +// v := &Session{ +// ID: p.ID.ShortUUID(), +// Users: fp.FMap(p.Keys(), func(uid suid.UUID) suid.SUID { return uid.ShortUUID() }), +// } + +// s.Respond(w, r, v, http.StatusOK) +// } +// } + +// func (s *Service) handleListRooms() http.HandlerFunc { +// type response struct { +// Sessions []Session `json:"sessions"` +// } + +// return func(w http.ResponseWriter, r *http.Request) { +// v := &response{ +// Sessions: fp.FMap(s.c.List(), func(p *ws.Pool) Session { +// return Session{ +// ID: p.ID.ShortUUID(), +// Users: fp.FMap( +// p.Keys(), +// func(uid suid.UUID) suid.SUID { return uid.ShortUUID() }, +// ), +// UserCount: p.Size(), +// } +// }), +// } + +// s.Respond(w, r, v, http.StatusOK) +// } +// } + +// func (s *Service) upgradeHTTP(w http.ResponseWriter, r *http.Request, pool *websocket.Pool) (conn websocket.Conn, close func(), err error) { +// if pool.IsCap() { +// return nil, nil, errors.New("error: pool has reached capacity") +// } + +// if conn, err = websocket.UpgradeHTTP(w, r); err != nil { +// return nil, nil, err +// } + +// pool.Append(conn) +// return conn, func() { pool.Remove(conn) }, nil +// } + +var ( + ErrNoCookie = errors.New("api: cookie not found") + ErrSessionNotFound = errors.New("api: session not found") + ErrSessionExists = errors.New("api: session already exists") +) + +func NewService(ctx context.Context, mux chi.Router) *Service { + s := &Service{service.New(ctx, mux)} + s.routes() + return s +} + +func (s *Service) parseUUID(w http.ResponseWriter, r *http.Request) (suid.UUID, error) { + return suid.ParseString(chi.URLParam(r, "uuid")) +} + +type Jam struct { + Name string `json:"name"` + BPM int `json:"bpm"` +} + +type Session struct { + ID suid.SUID `json:"id"` + Name string `json:"name,omitempty"` + Users []suid.SUID `json:"users,omitempty"` + /* Not really required */ + UserCount int `json:"userCount"` +} + +type User struct { + ID suid.SUID `json:"id"` + Name string `json:"name,omitempty"` + /* More fields can belong here */ +} diff --git a/service/jam/service_test.go b/service/jam/service_test.go new file mode 100644 index 00000000..881e178f --- /dev/null +++ b/service/jam/service_test.go @@ -0,0 +1,119 @@ +package jam + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/hyphengolang/prelude/testing/is" + // "github.com/rog-golang-buddies/rmx/internal/websocket" +) + +var resource = func(s string) string { + return s[strings.LastIndex(s, "/")+1:] +} + +var stripPrefix = func(s string) string { + return "ws" + strings.TrimPrefix(s, "http") +} + +func TestService(t *testing.T) { + is := is.New(t) + + ctx, mux := context.Background(), chi.NewMux() + + h := NewService(ctx, mux) + srv := httptest.NewServer(h) + + t.Cleanup(func() { srv.Close() }) + + var firstPool string + t.Run(`create a new Room`, func(t *testing.T) { + payload := ` + { + "capacity": 2 + }` + + res, _ := srv.Client().Post(srv.URL+"/api/v1/jam", "application/json", strings.NewReader(payload)) + is.Equal(res.StatusCode, http.StatusCreated) // created a new resource + + loc, err := res.Location() + is.NoErr(err) // retrieve location + + firstPool = resource(loc.Path) + }) + + t.Run(`connect to jam session`, func(t *testing.T) { + c1, _, err := websocket.DefaultDialer.Dial(stripPrefix(srv.URL+"/ws/jam/"+firstPool), nil) + is.NoErr(err) // found first jam Session + + t.Cleanup(func() { c1.Close() }) + + err = c1.WriteMessage(websocket.TextMessage, []byte("Hello, World!")) + is.NoErr(err) // write to pool + + _, data, err := c1.ReadMessage() + is.NoErr(err) // read from pool + + is.Equal(string(data), "Hello, World!") + }) +} + +// func TestWebsocket(t *testing.T) { +// // t.Parallel() +// is := is.New(t) + +// h := NewService(context.Background(), chi.NewMux()) +// srv := httptest.NewServer(h) + +// t.Cleanup(func() { srv.Close() }) + +// t.Run(`list all jam sessions`, func(t *testing.T) { +// r, _ := srv.Client().Get(srv.URL + "/api/v1/jam") +// is.Equal(r.StatusCode, http.StatusOK) // successfully created a new room +// }) + +// var firstPool string +// t.Run(`create a new room`, func(t *testing.T) { +// payload := ` +// { +// "capacity":2 +// }` +// res, err := srv.Client().Post(srv.URL+"/api/v1/jam", "application/json", strings.NewReader(payload)) +// is.NoErr(err) // create a new pool +// is.Equal(res.StatusCode, http.StatusCreated) // created a new resource + +// loc, err := res.Location() +// is.NoErr(err) // retrieve location + +// firstPool = resource(loc.Path) +// }) + +// t.Run(`connect users to room websocket`, func(t *testing.T) { +// c1, err := w2.Dial(context.Background(), stripPrefix(srv.URL+"/ws/jam/")+firstPool) +// is.NoErr(err) // connect client 1 +// c2, err := w2.Dial(context.Background(), stripPrefix(srv.URL+"/ws/jam/")+firstPool) +// is.NoErr(err) // connect client 2 + +// t.Cleanup(func() { +// c1.Close() +// c2.Close() +// }) + +// _, err = w2.Dial(context.Background(), stripPrefix(srv.URL+"/ws/jam/")+firstPool) +// is.True(err != nil) // cannot connect client 3 + +// err = c1.WriteString("Hello, World!") +// is.NoErr(err) // write string to pool + +// msg, err := c2.ReadString() +// is.NoErr(err) // read message sent by c1 + +// is.Equal(msg, "Hello, World!") + +// }) +// } diff --git a/service/jam/v2/service.go b/service/jam/v2/service.go new file mode 100644 index 00000000..82fcf779 --- /dev/null +++ b/service/jam/v2/service.go @@ -0,0 +1,226 @@ +package v2 + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gobwas/ws" + "github.com/hyphengolang/prelude/types/suid" + "github.com/rog-golang-buddies/rmx/internal/fp" + "github.com/rog-golang-buddies/rmx/internal/websocket" + "github.com/rog-golang-buddies/rmx/pkg/service" +) + +// Jam Service Endpoints +// +// Create a new jam session. +// +// POST /api/v1/jam +// +// List all jam sessions metadata. +// +// GET /api/v1/jam +// +// Get a jam sessions metadata. +// +// GET /api/v1/jam/{uuid} +// +// Connect to jam session. +// +// GET /ws/jam/{uuid} + +type Service struct { + service.Service +} + +func NewService(ctx context.Context, mux chi.Router) *Service { + s := &Service{service.New(ctx, mux)} + s.routes() + return s +} + +const ( + defaultTimeout = time.Second * 10 +) + +type User struct { + Username string `json:"username"` +} + +func (u *User) fillDefaults() { + if strings.TrimSpace(u.Username) == "" { + u.Username = "User-" + suid.NewSUID().String() + } +} + +type Jam struct { + // Public name of the Jam. + Name string `json:"name,omitempty"` + // Owning user of the Jam. (not implemented yet) + Owner *User `json:"owner,omitempty"` + // Max number of Jam participants. + Capacity uint `json:"capacity,omitempty"` + // Beats per minute. Used for setting the tempo of MIDI playback. + BPM uint `json:"bpm,omitempty"` +} + +func (j *Jam) fillDefaults() { + if strings.TrimSpace(j.Name) == "" { + j.Name = "Jam-" + suid.NewSUID().String() + } + if j.Capacity == 0 { + j.Capacity = 10 + } + if j.BPM == 0 { + j.BPM = 80 + } +} + +func (s *Service) handleCreateJamRoom(b *websocket.Broker[Jam, User]) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var j Jam + if err := s.Decode(w, r, &j); err != nil { + s.Respond(w, r, err, http.StatusBadRequest) + return + } + + // fill out empty fields with default value. + j.fillDefaults() + + // create a new Subscriber + sub := websocket.NewSubscriber[Jam, User]( + b.Context, + j.Capacity, + 512, + defaultTimeout, + defaultTimeout, + &j, + ) + + // connect the Subscriber + b.Subscribe(sub) + + s.Created(w, r, sub.GetID().ShortUUID().String()) + } +} + +func (s *Service) handleGetRoomData(b *websocket.Broker[Jam, User]) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // decode uuid from URL + sid, err := s.parseUUID(r) + if err != nil { + s.Respond(w, r, sid, http.StatusBadRequest) + return + } + + sub, err := b.GetSubscriber(sid) + if err != nil { + s.Respond(w, r, err, http.StatusNotFound) + return + } + + s.Respond(w, r, sub.Info, http.StatusOK) + } +} + +func (s *Service) handleGetRoomUsers(b *websocket.Broker[Jam, User]) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // decode uuid from URL + sid, err := s.parseUUID(r) + if err != nil { + s.Respond(w, r, sid, http.StatusBadRequest) + return + } + + sub, err := b.GetSubscriber(sid) + if err != nil { + s.Respond(w, r, err, http.StatusNotFound) + return + } + + conns := sub.ListConns() + + connsInfo := fp.FMap(conns, func(c *websocket.Conn[User]) User { + return *c.Info + }) + + s.Respond(w, r, connsInfo, http.StatusOK) + } +} + +func (s *Service) handleListRooms(b *websocket.Broker[Jam, User]) http.HandlerFunc { + type response struct { + ID suid.SUID `json:"id"` + Jam + } + + return func(w http.ResponseWriter, r *http.Request) { + subs := b.ListSubscribers() + subsInfo := fp.FMap(subs, func(s *websocket.Subscriber[Jam, User]) *response { + return &response{ + ID: s.GetID().ShortUUID(), + Jam: *s.Info, + } + }) + + s.Respond(w, r, subsInfo, http.StatusOK) + } +} + +func (s *Service) handleP2PComms(b *websocket.Broker[Jam, User]) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // decode uuid from URL + sid, err := s.parseUUID(r) + if err != nil { + s.Respond(w, r, sid, http.StatusBadRequest) + return + } + + sub, err := b.GetSubscriber(sid) + if err != nil { + s.Respond(w, r, err, http.StatusNotFound) + return + } + + if err := errors.New("subscriber has reached max capacity"); sub.IsFull() { + s.Respond(w, r, err, http.StatusServiceUnavailable) + return + } + + rwc, _, _, err := ws.UpgradeHTTP(r, w) + if err != nil { + s.Respond(w, r, err, http.StatusUpgradeRequired) + return + } + + var u User + u.fillDefaults() + + conn := sub.NewConn(rwc, &u) + sub.Subscribe(conn) + } +} + +func (s *Service) routes() { + broker := websocket.NewBroker[Jam, User](10, context.Background()) + + s.Route("/api/v1/jam", func(r chi.Router) { + r.Get("/", s.handleListRooms(broker)) + r.Get("/{uuid}", s.handleGetRoomData(broker)) + r.Get("/{uuid}/users", s.handleGetRoomUsers(broker)) + r.Post("/", s.handleCreateJamRoom(broker)) + }) + + s.Route("/ws/jam", func(r chi.Router) { + r.Get("/{uuid}", s.handleP2PComms(broker)) + }) + +} + +func (s *Service) parseUUID(r *http.Request) (suid.UUID, error) { + return suid.ParseString(chi.URLParam(r, "uuid")) +} diff --git a/service/jam/v2/service_test.go b/service/jam/v2/service_test.go new file mode 100644 index 00000000..6710a547 --- /dev/null +++ b/service/jam/v2/service_test.go @@ -0,0 +1,65 @@ +package v2 + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" + "github.com/hyphengolang/prelude/testing/is" +) + +var resource = func(s string) string { + return s[strings.LastIndex(s, "/")+1:] +} + +var stripPrefix = func(s string) string { + return "ws" + strings.TrimPrefix(s, "http") +} + +func TestService(t *testing.T) { + is := is.New(t) + ctx, mux := context.Background(), chi.NewMux() + h := NewService(ctx, mux) + srv := httptest.NewServer(h) + + var firstJam string + t.Run("Create a new Jam room", func(t *testing.T) { + payload := `{ + "name": "John Doe", + "capacity": 5, + "bpm": 100 + }` + + res, _ := srv.Client().Post(srv.URL+"/api/v1/jam", "application/json", strings.NewReader(payload)) + is.Equal(res.StatusCode, http.StatusCreated) // created a new resource + + loc, err := res.Location() + is.NoErr(err) // retrieve location + + firstJam = resource(loc.Path) + }) + + t.Run(`Connect to Jam room with id: `+firstJam, func(t *testing.T) { + c1, _, _, err := ws.DefaultDialer.Dial(ctx, stripPrefix(srv.URL+"/ws/jam/"+firstJam)) + is.NoErr(err) // found first jam Session + + t.Cleanup(func() { c1.Close() }) + + data := []byte("Hello World!") + typ := []byte{1} + m := append(typ, data...) + + err = wsutil.WriteClientBinary(c1, m) + is.NoErr(err) // write to pool + + res, err := wsutil.ReadServerBinary(c1) + is.NoErr(err) // read from pool + + is.Equal(res, m) + }) +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 00000000..501ff4a8 --- /dev/null +++ b/service/service.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "log" + "net/http" + + "github.com/go-chi/chi/middleware" + "github.com/go-chi/chi/v5" + "github.com/rog-golang-buddies/rmx/service/auth" + jam "github.com/rog-golang-buddies/rmx/service/jam/v2" + "github.com/rog-golang-buddies/rmx/store" +) + +func (s *Service) routes() { + s.m.Use(middleware.Logger) +} + +type Service struct { + m chi.Router + + log func(s ...any) + logf func(string, ...any) + fatal func(s ...any) + fatalf func(string, ...any) +} + +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.m.ServeHTTP(w, r) } + +func New(ctx context.Context, st *store.Store) http.Handler { + s := &Service{chi.NewMux(), log.Print, log.Printf, log.Fatal, log.Fatalf} + + s.routes() + + // TODO - use mux.Mount instead. But this works + auth.NewService(ctx, s.m, st.UserRepo(), st.TokenClient()) + jam.NewService(ctx, s.m) + + return s +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 00000000..c240450d --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,15 @@ +version: '2' +sql: + - schema: 'store/sql/schema/user_schema.sql' + queries: 'store/sql/schema/user_query.sql' + engine: 'mysql' + gen: + go: + package: 'user' + out: 'store/sql/user' + emit_db_tags: true + emit_prepared_queries: true + emit_empty_slices: true + emit_params_struct_pointers: true + emit_json_tags: true + json_tags_case_style: 'camel' diff --git a/store/auth/auth.go b/store/auth/auth.go new file mode 100644 index 00000000..cfa3fb0f --- /dev/null +++ b/store/auth/auth.go @@ -0,0 +1,145 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/go-redis/redis/v9" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +var DefaultTokenClient = &client{make(map[string]bool), make(map[string]bool)} + +func (c *client) ValidateRefreshToken(ctx context.Context, token string) error { + return ErrNotImplemented +} + +func (c *client) ValidateClientID(ctx context.Context, token string) error { + return ErrNotImplemented +} + +// BlackListClientID implements internal.TokenClient +func (c *client) BlackListClientID(ctx context.Context, cid string, email string) error { + panic("unimplemented") +} + +// BlackListRefreshToken implements internal.TokenClient +func (c *client) BlackListRefreshToken(ctx context.Context, token string) error { + panic("unimplemented") +} + +type client struct { + mrt, mci map[string]bool +} + +type Client struct { + rtdb, cidb *redis.Client +} + +// ValidateRefreshToken implements internal.TokenClient +func (*Client) ValidateRefreshToken(ctx context.Context, token string) error { + panic("unimplemented") +} + +// BlackListClientID implements internal.TokenClient +func (*Client) BlackListClientID(ctx context.Context, cid string, email string) error { + panic("unimplemented") +} + +// BlackListRefreshToken implements internal.TokenClient +func (*Client) BlackListRefreshToken(ctx context.Context, token string) error { + panic("unimplemented") +} + +var ( + ErrNotImplemented = errors.New("not implemented") + ErrGenerateKey = errors.New("failed to generate new ecdsa key pair") + ErrSignTokens = errors.New("failed to generate signed tokens") + ErrRTValidate = errors.New("failed to validate refresh token") +) + +func NewRedis(addr, password string) *Client { + rtdb := redis.Options{Addr: addr, Password: password, DB: 0} + cidb := redis.Options{Addr: addr, Password: password, DB: 1} + + c := &Client{redis.NewClient(&rtdb), redis.NewClient(&cidb)} + return c +} + +const ( + defaultAddr = "localhost:6379" + defaultPassword = "" +) + +// var DefaultClient = &Client{ +// rtdb: redis.NewClient(&redis.Options{Addr: defaultAddr, Password: defaultPassword, DB: 0}), +// cidb: redis.NewClient(&redis.Options{Addr: defaultAddr, Password: defaultPassword, DB: 1}), +// } + +func (c *Client) Validate(ctx context.Context, token string) error { + tc, err := ParseRefreshTokenClaims(token) + if err != nil { + return err + } + + cid := tc.Subject() + email, ok := tc.PrivateClaims()["email"].(string) + if !ok { + return ErrRTValidate + } + + if err := c.ValidateClientID(ctx, cid); err != nil { + return err + } + + if _, err := c.rtdb.Get(ctx, token).Result(); err != nil { + switch err { + case redis.Nil: + return nil + default: + return err + } + } + + err = c.RevokeClientID(ctx, cid, email) + if err != nil { + return err + } + + return ErrRTValidate +} + +func (c *Client) RevokeClientID(ctx context.Context, cid, email string) error { + _, err := c.cidb.Set(ctx, cid, email, RefreshTokenExpiry).Result() + return err +} + +func (c *Client) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := c.rtdb.Set(ctx, token, nil, RefreshTokenExpiry).Result() + return err +} + +func (c *Client) ValidateClientID(ctx context.Context, cid string) error { + // check if a key with client id exists + // if the key exists it means that the client id is revoked and token should be denied + // we don't need the email value here + _, err := c.cidb.Get(ctx, cid).Result() + if err != nil { + switch err { + case redis.Nil: + return nil + default: + return ErrRTValidate + } + } + + return ErrRTValidate +} + +func ParseRefreshTokenClaims(token string) (jwt.Token, error) { return jwt.Parse([]byte(token)) } + +const ( + RefreshTokenExpiry = time.Hour * 24 * 7 + AccessTokenExpiry = time.Minute * 5 +) diff --git a/store/store.go b/store/store.go new file mode 100644 index 00000000..aab8751a --- /dev/null +++ b/store/store.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rog-golang-buddies/rmx/internal" + "github.com/rog-golang-buddies/rmx/store/auth" + "github.com/rog-golang-buddies/rmx/store/user" +) + +type Store struct { + tc internal.TokenClient + ur user.Repo +} + +func (s *Store) UserRepo() user.Repo { + if s.ur == nil { + panic("user repo must not be nil") + } + return s.ur +} + +func (s *Store) TokenClient() internal.TokenClient { + if s.tc == nil { + panic("token client must not be nil") + } + return s.tc +} + +func New(ctx context.Context, connString string) (*Store, error) { + pool, err := pgxpool.New(ctx, connString) + if err != nil { + return nil, err + } + + s := &Store{ + ur: user.NewRepo(ctx, pool), + tc: auth.DefaultTokenClient, + } + + return s, nil +} diff --git a/store/user/mysql.sql b/store/user/mysql.sql new file mode 100644 index 00000000..16a73849 --- /dev/null +++ b/store/user/mysql.sql @@ -0,0 +1,53 @@ +CREATE TABLE users ( + id text NOT NULL PRIMARY KEY, + username text NOT NULL, + email text NOT NULL, + password text NOT NULL, + created_at timestamp NOT NULL DEFAULT NOW(), + updated_at timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + deleted_at timestamp NULL DEFAULT NULL, + UNIQUE (email) +); + +-- name: GetUserByID :one +SELECT + * +FROM + users +WHERE + id = ? +LIMIT 1; + +-- name: GetUserByEmail :one +SELECT + * +FROM + users +WHERE + email = ? +LIMIT 1; + +-- name: ListUsers :many +SELECT + * +FROM + users +ORDER BY + id; + +-- name: CreateUser :execresult +INSERT INTO users (username, email, PASSWORD, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?); + +-- name: UpdateUser :execresult +UPDATE + users +SET + username = ? +WHERE + id = ?; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = ?; + diff --git a/store/user/repo.go b/store/user/repo.go new file mode 100644 index 00000000..b69b59fd --- /dev/null +++ b/store/user/repo.go @@ -0,0 +1,138 @@ +package user + +import ( + "context" + "time" + + psql "github.com/hyphengolang/prelude/sql/postgres" + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + "github.com/hyphengolang/prelude/types/suid" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/rog-golang-buddies/rmx/internal" +) + +// Definition of our User in the DB layer +type User struct { + // Primary key. + ID suid.UUID + // Unique. Stored as text. + Username string + // Unique. Stored as case-sensitive text. + Email email.Email + // Required. Stored as case-sensitive text. + Password password.PasswordHash + // Required. Defaults to current time. + CreatedAt time.Time + // TODO nullable, currently inactive + // UpdatedAt *time.Time + // TODO nullable, currently inactive + // DeletedAt *time.Time +} + +type Repo interface { + Closer + Writer + Reader +} + +type ReadWriter interface { + Reader + Writer +} + +type Writer interface { + internal.RepoWriter[internal.User] +} + +type Reader interface { + internal.RepoReader[internal.User] +} + +type Closer interface { + internal.RepoCloser +} + +type repo struct { + ctx context.Context + c *pgxpool.Pool +} + +// This is not really required +func NewRepo(ctx context.Context, conn *pgxpool.Pool) Repo { + return &repo{ctx, conn} +} + +func (r *repo) Context() context.Context { + if r.ctx != nil { + return r.ctx + } + return context.Background() +} + +func (r *repo) Close() { r.c.Close() } + +func (r *repo) Insert(ctx context.Context, u *internal.User) error { + args := pgx.NamedArgs{ + "id": u.ID, + "email": u.Email, + "username": u.Username, + "password": u.Password, + } + + return psql.Exec(r.c, qryInsert, args) +} + +func (r *repo) SelectMany(ctx context.Context) ([]internal.User, error) { + return psql.Query(r.c, qrySelectMany, func(r pgx.Rows, u *internal.User) error { + return r.Scan(&u.ID, &u.Email, &u.Username, &u.Password) + }) +} + +func (r *repo) Select(ctx context.Context, key any) (*internal.User, error) { + var qry string + switch key.(type) { + case suid.UUID: + qry = qrySelectByID + case email.Email: + qry = qrySelectByEmail + case string: + qry = qrySelectByUsername + default: + return nil, internal.ErrInvalidType + } + var u internal.User + return &u, psql.QueryRow(r.c, qry, func(r pgx.Row) error { return r.Scan(&u.ID, &u.Username, &u.Email, &u.Password) }, key) +} + +func (r *repo) Delete(ctx context.Context, key any) error { + var qry string + switch key.(type) { + case suid.UUID: + qry = qryDeleteByID + case email.Email: + qry = qryDeleteByEmail + case string: + qry = qryDeleteByUsername + default: + return internal.ErrInvalidType + } + return psql.Exec(r.c, qry, key) +} + +const ( + qryInsert = `insert into "user" (id, email, username, password) values (@id, @email, @username, @password)` + + qrySelectMany = `select id, email, username, password from "user" order by id` + + qrySelectByID = `select id, email, username, password from "user" where id = $1` + qrySelectByEmail = `select id, email, username, password from "user" where email = $1` + qrySelectByUsername = `select id, email, username, password from "user" where username = $1` + + qryDeleteByID = `delete from "user" where id = $1` + qryDeleteByEmail = `delete from "user" where email = $1` + qryDeleteByUsername = `delete from "user" where username = $1` +) diff --git a/store/user/repo_test.go b/store/user/repo_test.go new file mode 100644 index 00000000..ba745ba9 --- /dev/null +++ b/store/user/repo_test.go @@ -0,0 +1,125 @@ +// TODO - use the standard sql package instead of pgx +package user + +import ( + "context" + "testing" + + psql "github.com/hyphengolang/prelude/sql/postgres" + "github.com/hyphengolang/prelude/testing/is" + "github.com/hyphengolang/prelude/types/email" + "github.com/hyphengolang/prelude/types/password" + "github.com/hyphengolang/prelude/types/suid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rog-golang-buddies/rmx/internal" +) + +/* +https://www.covermymeds.com/main/insights/articles/on-update-timestamps-mysql-vs-postgres/ +*/ +var db Repo + +const migration = ` +begin; + +create extension if not exists "uuid-ossp"; +create extension if not exists "citext"; + +create temp table if not exists "user" ( + id uuid primary key default uuid_generate_v4(), + username text unique not null check (username <> ''), + email citext unique not null check (email ~ '^[a-zA-Z0-9.!#$%&’*+/=?^_\x60{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$'), + password citext not null check (password <> ''), + created_at timestamp not null default now() +); + +commit; +` + +var pool *pgxpool.Pool +var err error + +func init() { + // create pool connection + pool, err = pgxpool.New(context.Background(), `postgres://postgres:postgrespw@localhost:49153/testing`) + if err != nil { + panic(err) + } + + // setup migration + if err := psql.Exec(pool, migration); err != nil { + panic(err) + } + + db = NewRepo(context.Background(), pool) +} + +func TestPSQL(t *testing.T) { + t.Parallel() + + is, ctx := is.New(t), context.Background() + + t.Cleanup(func() { pool.Close() }) + + t.Run(`select * from "user"`, func(t *testing.T) { + _, err := db.SelectMany(ctx) + is.NoErr(err) // error reading from database + }) + + t.Run(`insert two new users`, func(t *testing.T) { + + fizz := internal.User{ + ID: suid.NewUUID(), + Email: email.MustParse("fizz@mail.com"), + Username: "fizz", + Password: password.MustParse("fizz_pw_1").MustHash(), + } + + err := db.Insert(ctx, &fizz) + is.NoErr(err) // insert new user "fizz" + + buzz := internal.User{ + ID: suid.NewUUID(), + Email: email.MustParse("buzz@mail.com"), + Username: "buzz", + Password: password.MustParse("buzz_pw_1").MustHash(), + } + + err = db.Insert(ctx, &buzz) + is.NoErr(err) // insert new user "buzz" + + us, err := db.SelectMany(ctx) + is.NoErr(err) // select all users + is.Equal(len(us), 2) // should be a length of 2 + }) + + t.Run("reject user with duplicate email/username", func(t *testing.T) { + fizz := internal.User{ + ID: suid.NewUUID(), + Email: email.MustParse("fuzz@mail.com"), + Username: "fizz", + Password: password.MustParse("fuzz_pw_1").MustHash(), + } + + err := db.Insert(ctx, &fizz) + is.True(err != nil) // duplicate user with username "fizz" + }) + + t.Run("select a user from the database using email/username", func(t *testing.T) { + u, err := db.Select(ctx, "fizz") + is.NoErr(err) // select user where username = "fizz" + is.NoErr(u.Password.Compare("fizz_pw_1")) // valid login + + _, err = db.Select(ctx, email.MustParse("buzz@mail.com")) + is.NoErr(err) // select user where email = "buzz@mail.com" + }) + + t.Run("delete by username from database, return 1 user in database", func(t *testing.T) { + err := db.Delete(ctx, "fizz") + is.NoErr(err) // delete user where username == "fizz" + + us, err := db.SelectMany(ctx) + is.NoErr(err) // select all users + is.Equal(len(us), 1) // should be a length of 1 + }) +} diff --git a/ui/terminal/main.go b/ui/terminal/main.go new file mode 100644 index 00000000..63a84a92 --- /dev/null +++ b/ui/terminal/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/rog-golang-buddies/rmx/ui/terminal/tui" + +func main() { + tui.Run() +} diff --git a/ui/terminal/tui/jamui/jam.go b/ui/terminal/tui/jamui/jam.go new file mode 100644 index 00000000..72bff3b8 --- /dev/null +++ b/ui/terminal/tui/jamui/jam.go @@ -0,0 +1,134 @@ +package jamui + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" + "golang.org/x/term" +) + +const ( + // In real life situations we'd adjust the document to fit the width we've + // detected. In the case of this example we're hardcoding the width, and + // later using the detected width only to truncate in order to avoid jaggy + // wrapping. + width = 96 + + columnWidth = 30 +) + +// DocStyle styling for viewports +var ( + subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} + + docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) + + keyBorder = lipgloss.Border{ + Top: "─", + Bottom: "-", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "╰", + BottomRight: "╯", + } + + key = lipgloss.NewStyle(). + Align(lipgloss.Center). + Border(keyBorder, true). + BorderForeground(highlight). + Padding(0, 1) +) + +// Message Types +type Entered struct{} + +type pianoKey struct { + noteNumber int // MIDI note number ie: 72 + name string // Name of musical note, ie: "C5" + keyMap string // Mapped qwerty keyboard key. Ex: "q" +} + +type Model struct { + piano []pianoKey // Piano keys. {"q": pianoKey{72, "C5", "q", ...}} + activeKeys map[string]struct{} // Currently active piano keys + Socket *websocket.Conn // Websocket connection for current Jam Session + ID string // Jam Session ID +} + +func New() Model { + return Model{ + piano: []pianoKey{ + {72, "C5", "q"}, + {74, "D5", "w"}, + {76, "E5", "e"}, + {77, "F5", "r"}, + {79, "G5", "t"}, + {81, "A5", "y"}, + {83, "B5", "u"}, + {84, "C6", "i"}, + }, + + activeKeys: make(map[string]struct{}), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyMsg: + switch msg.String() { + // These keys should exit the program. + case "ctrl+c": + return m, tea.Quit + default: + fmt.Printf("Key press: %s\n", msg.String()) + } + + // Entered the Jam Session + case Entered: + fmt.Println(m) + } + + return m, nil +} + +func (m Model) View() string { + physicalWidth, _, _ := term.GetSize(int(os.Stdout.Fd())) + doc := strings.Builder{} + + if physicalWidth > 0 { + docStyle = docStyle.MaxWidth(physicalWidth) + } + + // Keyboard + keyboard := lipgloss.JoinHorizontal(lipgloss.Top, + key.Render("C5"+"\n\n"+"(q)"), + key.Render("D5"+"\n\n"+"(w)"), + key.Render("E5"+"\n\n"+"(e)"), + key.Render("F5"+"\n\n"+"(r)"), + key.Render("G5"+"\n\n"+"(t)"), + key.Render("A5"+"\n\n"+"(y)"), + key.Render("B5"+"\n\n"+"(u)"), + key.Render("C6"+"\n\n"+"(i)"), + ) + doc.WriteString(keyboard + "\n\n") + return docStyle.Render(doc.String()) +} + +// Commands +func Enter() tea.Msg { + return Entered{} +} diff --git a/ui/terminal/tui/lobbyui/help.go b/ui/terminal/tui/lobbyui/help.go new file mode 100644 index 00000000..d4b2564c --- /dev/null +++ b/ui/terminal/tui/lobbyui/help.go @@ -0,0 +1,102 @@ +package lobbyui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// keyMap defines a set of keybindings. To work for help it must satisfy +// key.Map. It could also very easily be a map[string]key.Binding. +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Refresh key.Binding + New key.Binding + Enter key.Binding + Help key.Binding + Quit key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.New, k.Enter, k.Help, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +// TODO: Figure out why FullHelp not rendering correctly +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, // first column + {k.Help, k.Quit}, // second column + } +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "move left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "move right"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh")), + New: key.NewBinding(key.WithKeys("n"), + key.WithHelp("n", "new jam")), + Enter: key.NewBinding(key.WithKeys("enter", "space"), + key.WithHelp("enter", "select")), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), +} + +type model struct { + keys keyMap + help help.Model + inputStyle lipgloss.Style +} + +func NewHelpModel() model { + return model{ + keys: keys, + help: help.New(), + inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7")), + } +} +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + } + } + return m, nil +} + +func (m model) View() string { + return m.help.View(m.keys) +} diff --git a/ui/terminal/tui/lobbyui/lobby.go b/ui/terminal/tui/lobbyui/lobby.go new file mode 100644 index 00000000..c1a09353 --- /dev/null +++ b/ui/terminal/tui/lobbyui/lobby.go @@ -0,0 +1,292 @@ +package lobbyui + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" + "golang.org/x/term" +) + +const ( + // In real life situations we'd adjust the document to fit the width we've + // detected. In the case of this example we're hardcoding the width, and + // later using the detected width only to truncate in order to avoid jaggy + // wrapping. + width = 96 + + columnWidth = 30 +) + +// Styles +var ( + baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + + // Status Bar. + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#343433", Dark: "#C1C6B2"}). + Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"}) + + statusStyle = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#FF5F87")). + Padding(0, 1). + MarginRight(1) + + statusText = lipgloss.NewStyle().Inherit(statusBarStyle) + + messageText = lipgloss.NewStyle().Align(lipgloss.Left) + + helpMenu = lipgloss.NewStyle().Align(lipgloss.Center).PaddingTop(2) + // Page + docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) +) + +// Message types +type errMsg struct{ err error } + +type Session struct { + Id string `json:"id"` // TODO: Need to fix the API to return "id" + Name string `json:"name"` + // UserCount int `json:"userCount"` +} + +type jamsResp struct { + Sessions []Session `json:"sessions"` +} + +type jamCreated struct { + ID string `json:"id"` + UserCount int `json:"userCount"` +} + +// For messages that contain errors it's often handy to also implement the +// error interface on the message. +func (e errMsg) Error() string { return e.err.Error() } + +// Commands +func FetchSessions(baseURL string) tea.Cmd { + return func() tea.Msg { + // Create an HTTP client and make a GET request. + c := &http.Client{Timeout: 10 * time.Second} + res, err := c.Get(baseURL + "/jam") + if err != nil { + // There was an error making our request. Wrap the error we received + // in a message and return it. + return errMsg{err} + } + // We received a response from the server. + // Return the HTTP status code + // as a message. + if res.StatusCode >= 400 { + return errMsg{fmt.Errorf("could not get sessions: %d", res.StatusCode)} + } + decoder := json.NewDecoder(res.Body) + var resp jamsResp + decoder.Decode(&resp) + return resp + } +} + +type Model struct { + wsURL string // Websocket endpoint + apiURL string // REST API base endpoint + sessions []Session + jamTable table.Model + help tea.Model + loading bool + err error +} + +func New(wsURL, apiURL string) tea.Model { + return Model{ + wsURL: wsURL, + apiURL: apiURL, + help: NewHelpModel(), + loading: true, + } +} + +// Init needed to satisfy Model interface. It doesn't seem to be called on sub-models. +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.jamTable.SetWidth(msg.Width - 10) + case errMsg: + // There was an error. Note it in the model. + m.err = msg + case jamsResp: + m.sessions = msg.Sessions + m.jamTable = makeJamsTable(m) + m.jamTable.Focus() + m.loading = false + case jamCreated: + jamID := msg.ID + // Auto join the newly created Jam + cmds = append(cmds, jamConnect(m.wsURL, jamID)) + case tea.KeyMsg: + switch msg.String() { + case tea.KeyEnter.String(): + jamID := m.jamTable.SelectedRow()[1] + + cmds = append(cmds, jamConnect(m.wsURL, jamID)) + case "n": + // Create new Jam Session + cmds = append(cmds, jamCreate(m.apiURL)) + } + } + newJamTable, jtCmd := m.jamTable.Update(msg) + m.jamTable = newJamTable + + newHelp, hCmd := m.help.Update(msg) + m.help = newHelp + + cmds = append(cmds, jtCmd, hCmd) + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + physicalWidth, _, _ := term.GetSize(int(os.Stdout.Fd())) + doc := strings.Builder{} + status := "" + + if m.loading { + status = "Fetching Jam Sessions..." + } + + if m.err != nil { + status = fmt.Sprintf("Error: %v!", m.err) + } + + // Jam Session Table + { + if len(m.sessions) > 0 { + jamTable := baseStyle.Width(width).Render(m.jamTable.View()) + doc.WriteString(jamTable) + } else if !m.loading { + doc.WriteString(messageText.Render("No Jams Yet. Create one?\n\n")) + } + } + // Status bar + { + w := lipgloss.Width + + statusKey := statusStyle.Render("STATUS") + statusVal := statusText.Copy(). + Width(width - w(statusKey)). + Render(status) + + bar := lipgloss.JoinHorizontal(lipgloss.Top, + statusKey, + statusVal, + ) + + doc.WriteString("\n" + statusBarStyle.Width(width).Render(bar)) + } + + // Help menu + { + + doc.WriteString("\n" + helpMenu.Render(m.help.View())) + } + + if physicalWidth > 0 { + docStyle = docStyle.MaxWidth(physicalWidth) + } + + // Okay, let's print it + return docStyle.Render(doc.String()) +} + +// https://github.com/rog-golang-buddies/rapidmidiex-research/issues/9#issuecomment-1204853876 +func makeJamsTable(m Model) table.Model { + columns := []table.Column{ + {Title: "Name", Width: 15}, + {Title: "ID", Width: 15}, + {Title: "Players", Width: 10}, + // {Title: "Latency", Width: 4}, + } + + rows := make([]table.Row, 0) + + for _, s := range m.sessions { + row := table.Row{"Name Here", s.Id, "0"} + rows = append(rows, row) + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(7), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + +type JamConnected struct { + WS *websocket.Conn + JamID string +} + +// Commands +func jamConnect(wsEndpoint, jamID string) tea.Cmd { + return func() tea.Msg { + url := wsEndpoint + "/jam/" + jamID + fmt.Println("ws url", url) + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return errMsg{fmt.Errorf("jamConnect: %v\n%v", url, err)} + } + // TODO: Actually connect to Jam Session over websocket + return JamConnected{ + WS: ws, + JamID: jamID, + } + } +} + +func jamCreate(baseURL string) tea.Cmd { + // For now, we're just creating the Jam Session without + // and options. + // Next step would be to show inputs for Jam details + // (name, bpm, etc) before creating the Jam. + return func() tea.Msg { + resp, err := http.Post(baseURL+"/jam", "application/json", strings.NewReader("{}")) + if err != nil { + return errMsg{err: fmt.Errorf("jamCreate: %v", err)} + } + var body jamCreated + decoder := json.NewDecoder(resp.Body) + decoder.Decode(&body) + + return body + } +} diff --git a/ui/terminal/tui/tui.go b/ui/terminal/tui/tui.go new file mode 100644 index 00000000..00d6b3d7 --- /dev/null +++ b/ui/terminal/tui/tui.go @@ -0,0 +1,135 @@ +package tui + +import ( + "fmt" + "net/url" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gorilla/websocket" + "github.com/hyphengolang/prelude/types/suid" + + "github.com/rog-golang-buddies/rmx/ui/terminal/tui/jamui" + "github.com/rog-golang-buddies/rmx/ui/terminal/tui/lobbyui" +) + +// ******** +// Code heavily based on "Project Journal" +// https://github.com/bashbunni/pjs +// https://www.youtube.com/watch?v=uJ2egAkSkjg&t=319s +// ******** + +type Session struct { + Id suid.UUID `json:"id"` + // UserCount int `json:"userCount"` +} + +type appView int + +const ( + jamView appView = iota + lobbyView +) + +type mainModel struct { + curView appView + lobby tea.Model + jam jamui.Model + RESTendpoint string + WSendpoint string + jamSocket *websocket.Conn // Websocket connection to a Jam Session +} + +func NewModel(serverHostURL string) (mainModel, error) { + wsHostURL, err := url.Parse(serverHostURL) + if err != nil { + return mainModel{}, err + } + wsHostURL.Scheme = "ws" + + return mainModel{ + curView: lobbyView, + lobby: lobbyui.New(wsHostURL.String()+"/ws", serverHostURL+"/api/v1"), + jam: jamui.New(), + RESTendpoint: serverHostURL + "/api/v1", + }, nil +} + +func (m mainModel) Init() tea.Cmd { + return lobbyui.FetchSessions(m.RESTendpoint) +} + +func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + // Handle incoming messages from I/O + switch msg := msg.(type) { + + case tea.KeyMsg: + // Ctrl+c exits. Even with short running programs it's good to have + // a quit key, just incase your logic is off. Users will be very + // annoyed if they can't exit. + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + case lobbyui.JamConnected: + m.curView = jamView + m.jam.Socket = msg.WS + m.jam.ID = msg.JamID + cmds = append(cmds, jamui.Enter) + } + + // Call sub-model Updates + switch m.curView { + case lobbyView: + newLobby, newCmd := m.lobby.Update(msg) + lobbyModel, ok := newLobby.(lobbyui.Model) + if !ok { + panic("could not perform assertion on lobbyui model") + } + m.lobby = lobbyModel + cmd = newCmd + case jamView: + newJam, newCmd := m.jam.Update(msg) + jamModel, ok := newJam.(jamui.Model) + if !ok { + panic("could not perform assertion on jamui model") + } + m.jam = jamModel + cmd = newCmd + } + // Run all commands from sub-model Updates + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + +} + +func (m mainModel) View() string { + switch m.curView { + case jamView: + return m.jam.View() + default: + return m.lobby.View() + } +} + +func Run() { + // TODO: Get from args, user input, or env + const serverHostURL = "http://localhost:9003" + m, err := NewModel(serverHostURL) + if err != nil { + bail(err) + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + if err := p.Start(); err != nil { + bail(err) + } +} + +func bail(err error) { + if err != nil { + fmt.Printf("Uh oh, there was an error: %v\n", err) + os.Exit(1) + } +}