From 2a8f56610eef53fd0ef3cbd02d300315abf73643 Mon Sep 17 00:00:00 2001 From: Micheal Smith Date: Thu, 31 Jul 2025 10:27:22 -0500 Subject: [PATCH] Initial commit --- auth/auth.go | 221 +++++++++++++++++++++++++++++++++++++++++++ auth/go.mod | 7 ++ auth/go.sum | 4 + cli/categories.go | 53 +++++++++++ cli/config.go | 104 ++++++++++++++++++++ cli/games.go | 36 +++++++ cli/go.mod | 23 +++++ cli/go.sum | 40 ++++++++ cli/init.go | 43 +++++++++ cli/live_followed.go | 32 +++++++ cli/main.go | 151 +++++++++++++++++++++++++++++ cli/search.go | 74 +++++++++++++++ cli/users.go | 27 ++++++ flake.nix | 33 +++++++ generate_cert.go | 193 +++++++++++++++++++++++++++++++++++++ go.work | 6 ++ 16 files changed, 1047 insertions(+) create mode 100644 auth/auth.go create mode 100644 auth/go.mod create mode 100644 auth/go.sum create mode 100644 cli/categories.go create mode 100644 cli/config.go create mode 100644 cli/games.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/init.go create mode 100644 cli/live_followed.go create mode 100644 cli/main.go create mode 100644 cli/search.go create mode 100644 cli/users.go create mode 100644 flake.nix create mode 100644 generate_cert.go create mode 100644 go.work diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..afbe561 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,221 @@ +package auth + +import ( + "encoding/json" + + "github.com/nicklaw5/helix/v2" + + "context" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" +) + +// TODO; Handle scopes properly + +type Auth struct { + HaveAuth bool + client *helix.Client + context *context.Context +} + +var authTimeout time.Duration = time.Second * 60 + +func New(ctx *context.Context, c *helix.Client, s []string) (*Auth, error) { + var auth *Auth = &Auth{context: ctx, client: c} + + // See about getting a token + c.SetRedirectURI("http://localhost:3000") + + // See if there are any stored credentials. + creds, err := getUserToken() + if err != nil { + // Caller needs to get followup to get credentials via StartAuth() + return auth, nil + } + + // Validate token in case what we've got on file is still good. + validate := func() error { + valid, resp, err := c.ValidateToken(creds.AccessToken) + if valid { + return nil + } + + if err != nil { + log.Printf("Validation error: %v\n", err) + } + + _ = resp + return err + } + + // Attempt to refresh the token if possible. + refresh := func() (*helix.AccessCredentials, error) { + resp, err := c.RefreshUserAccessToken(creds.RefreshToken) + if err != nil { + log.Printf("Error refreshing token: %v\n", err) + return nil, err + } + + return &resp.Data, nil + } + + // FIXME: This should return an error. + if err := validate(); err != nil { + return auth, err + } + + refreshedToken, err := refresh() + if err != nil { + return auth, err + } + + // XXX: Maybe wrap this in a function. + c.SetUserAccessToken(refreshedToken.AccessToken) + c.SetRefreshToken(refreshedToken.RefreshToken) + storeUserToken(refreshedToken) + auth.HaveAuth = true + + return auth, nil +} + +// Returns path to a token file. Creates it if it does nt exist. +func tokenFilePath() (string, error) { + storePath, err := os.UserCacheDir() + if err != nil { + return "", err + } + + storePath = filepath.Join(storePath, "tremble") + err = os.Mkdir(storePath, 0755) + if err != nil && !os.IsExist(err) { + log.Printf("Error creating %s: %v\n", storePath, err) + return "", err + } + + return filepath.Join(storePath, "token.json"), nil +} + +// Only reporting here for now since it's probably not worth trying to recover +// from an error here. +func storeUserToken(token *helix.AccessCredentials) { + tf, err := tokenFilePath() + if err != nil { + return + } + + b, err := json.Marshal(token) + if err != nil { + log.Printf("Couldn't marshal %+v: %v\n", token, err) + return + } + + err = os.WriteFile(tf, b, 0644) + if err != nil { + log.Printf("Error writing token: %v\n", err) + return + } +} + +func getUserToken() (creds helix.AccessCredentials, err error) { + tf, err := tokenFilePath() + if err != nil { + return + } + + b, err := os.ReadFile(tf) + if err != nil { + + if err != os.ErrNotExist { + log.Printf("Couldn't read token file: %v\n", err) + } + return + } + + if err = json.Unmarshal(b, &creds); err != nil { + log.Printf("Error unmarshalling token: %v\n", err) + return + } + + return +} + +func (a *Auth) updateCode(code string) error { + resp, err := a.client.RequestUserAccessToken(code) + if err != nil { + log.Printf("error requesting user access token: %v\n", err) + return err + } + + log.Printf("%+v\n", resp.Data) + + a.client.SetUserAccessToken(resp.Data.AccessToken) + storeUserToken(&resp.Data) + return nil +} + +func (a *Auth) StartAuth(userId string, s []string) (chan int, error) { + // Need to get authorization + authUrl := a.client.GetAuthorizationURL(&helix.AuthorizationURLParams{ + ResponseType: "code", + Scopes: s, + State: userId, + ForceVerify: false, + }) + + // TODO: Check for MacOS here. + // Open browser and do the auth. Let the auth listener take care of the + // rest. + cmd := exec.Command("xdg-open", authUrl) + if err := cmd.Run(); err != nil { + return nil, err + } + + notify := make(chan int) + + a.waitForAuth(notify) + + return notify, nil +} + +func (a *Auth) waitForAuth(notify chan int) { + authCtx, cancel := context.WithTimeout(*a.context, authTimeout) + srv := &http.Server{Addr: ":3000"} + + var code string = "" + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + select { + case <-authCtx.Done(): + // Authenticated or timed out + return + default: + // Just continue + } + code = r.URL.Query().Get("code") + + if code != "" { + cancel() + + err := srv.Shutdown(authCtx) + if err != nil { + log.Fatalf("Error shutting down auth server: %v\n", err) + } + + a.updateCode(code) + notify <- 1 + } + }) + + go func() { + defer srv.Close() + + err := http.ListenAndServe(":3000", nil) + if err != nil { + log.Fatalf("Error running auth server: %v\n", err) + } + }() +} diff --git a/auth/go.mod b/auth/go.mod new file mode 100644 index 0000000..f13d89e --- /dev/null +++ b/auth/go.mod @@ -0,0 +1,7 @@ +module auth + +go 1.23.3 + +require github.com/nicklaw5/helix/v2 v2.31.0 + +require github.com/golang-jwt/jwt/v4 v4.5.1 // indirect diff --git a/auth/go.sum b/auth/go.sum new file mode 100644 index 0000000..0685d8c --- /dev/null +++ b/auth/go.sum @@ -0,0 +1,4 @@ +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/nicklaw5/helix/v2 v2.31.0 h1:/8E5H20D/f3PGmSWT5NWtjwt+M8/GeCjnK/AkoLIFQA= +github.com/nicklaw5/helix/v2 v2.31.0/go.mod h1:e1GsZq4NDk9sQlPJ0Nr3+14R9cizqg09VAk7/IonpOU= diff --git a/cli/categories.go b/cli/categories.go new file mode 100644 index 0000000..62fa57c --- /dev/null +++ b/cli/categories.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/nicklaw5/helix/v2" +) + +func categoryHelp() { + fmt.Print("\nTwitch category commands. Valid commands are:\n\n") + fmt.Println(" search : search twitch for categories matching search term.") +} + +func categories(client *helix.Client, args []string) { + if len(args) < 1 { + log.Error("Insufficient arguments.") + categoryHelp() + return + } + + switch args[0] { + case "search": + { + data, err := searchCategories(client, args[1]) + if err != nil { + log.Fatalf("Error searching categories: %v", err) + } + _ = data + } + default: + categoryHelp() + } +} + +// searchCategories prints a list of categories matching searchTerm. On success it also +// returns a list of categories. +func searchCategories(client *helix.Client, searchTerm string) ([]helix.Category, error) { + resp, err := client.SearchCategories(&helix.SearchCategoriesParams{ + Query: searchTerm, + }) + if err != nil { + log.Fatalf("Error while searching for category '%v'\n", searchTerm) + return nil, err + } + + // TODO: Pagination and such + for _, category := range resp.Data.Categories { + fmt.Printf("[%v] %v\n", category.ID, category.Name) + } + + return resp.Data.Categories, nil +} diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 0000000..ce41a12 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path" + "slices" + "strings" + + "github.com/charmbracelet/log" +) + +type Config struct { + UserName string + UserID string + ClientID string + ClientSecret string +} + +// Finds a valid configuration file if it exists. If nil is returned one +// should be created. +func find_config() (string, error) { + // Paths where a tremble.conf file might reside + // TODO: Check XDG_CONFIG_DIRS last in case XDG_CONFIG_HOME isn't set. + var paths []string = []string{ + ".", + "$XDG_CONFIG_HOME/tremble", + "$HOME/.tremble", + "$HOME/.config/tremble", + } + + if configDirs, isSet := os.LookupEnv("XDG_CONFIG_DIRS"); isSet { + paths = slices.Concat(paths, strings.Split(configDirs, ":")) + } + + for _, p := range paths { + dir := os.ExpandEnv(p) + pstr := path.Join(dir, "tremble.conf") + + _, err := os.Lstat(pstr) + if err != nil { + continue + } + return pstr, nil + } + + return "", errors.New("No config file found.") +} + +// Finds a configuration file and processes it if one exists. +func read_config() (*Config, error) { + pstr, err := find_config() + if err != nil { + log.Info(err) + return nil, err + } + + cdata, err := os.ReadFile(pstr) + if err != nil { + log.Info(err) + return nil, err + } + + // Parse the file and return. + var confOut Config + err = json.Unmarshal(cdata, &confOut) + if err != nil { + return nil, err + } + + return &confOut, nil +} + +// Writes configuration to the tremble config file. Currently this overwrites +// the entire thing if found, or creates a new one if not. +func write_config(config *Config) error { + var pstr string + + // XXX: Maybe check to make sure this isn't some substantial error. + // XXX: Some duplication here. + pstr, err := find_config() + if err != nil { + dir := os.ExpandEnv("$XDG_CONFIG_HOME/tremble") + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("Couldnt create directory '%v': %v\n", dir, err) + } + + pstr = path.Join(dir, "tremble.conf") + } + + // TODO: Make this fatal in the caller, or... not. Just not here. + cdata, err := json.Marshal(config) + if err != nil { + log.Fatalf("Couldn't write config %v: %v\n", config, err) + } + + err = os.WriteFile(pstr, cdata, 0755) + if err != nil { + log.Fatalf("Couldn't write config %v: %v\n", config, err) + } + + return nil +} diff --git a/cli/games.go b/cli/games.go new file mode 100644 index 0000000..3a64293 --- /dev/null +++ b/cli/games.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log" + + "github.com/nicklaw5/helix/v2" +) + +func searchGames(client *helix.Client, searchTerms []string) ([]helix.Game, error) { + resp, err := client.GetGames(&helix.GamesParams{ + Names: searchTerms, + }) + if err != nil { + return nil, err + } + + // TODO: Pagination and such + + return resp.Data.Games, nil +} + +func printGames(client *helix.Client, searchTerms []string) error { + games, err := searchGames(client, searchTerms) + if err != nil { + log.Fatalf("Error while seardching for games %v\n", searchTerms) + return err + } + + // TODO: Pagination and such ? + for _, game := range games { + fmt.Printf("[%v] %v\n", game.ID, game.Name) + } + + return nil +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..b0ba127 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,23 @@ +module cli + +go 1.23.3 + +require ( + github.com/charmbracelet/log v0.4.0 + github.com/nicklaw5/helix/v2 v2.31.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..3903d59 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,40 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +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/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nicklaw5/helix/v2 v2.31.0 h1:/8E5H20D/f3PGmSWT5NWtjwt+M8/GeCjnK/AkoLIFQA= +github.com/nicklaw5/helix/v2 v2.31.0/go.mod h1:e1GsZq4NDk9sQlPJ0Nr3+14R9cizqg09VAk7/IonpOU= +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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/cli/init.go b/cli/init.go new file mode 100644 index 0000000..ef1b770 --- /dev/null +++ b/cli/init.go @@ -0,0 +1,43 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "strings" +) + +// Setup authentication credentials for the user. +func setupAuth() (*Config, error) { + // Yikes here we go + input := bufio.NewReaderSize(os.Stdin, 256) + + // Reads user input. Fatal if there's an error. + readInput := func(line string) string { + fmt.Println(line) + in, err := input.ReadString('\n') + if err != nil { + log.Fatalf("Couldn't read user input; %v\n", err) + } + return strings.TrimSuffix(in, "\n") + } + + clientId := readInput("Please input client id.") + clientSecret := readInput("Please input client secret.") + userName := readInput("Input username you use to login to twitch.") + + // TODO: Probably do some input validation. Maybe. + if len(clientSecret) == 0 || len(clientId) == 0 || len(userName) == 0 { + panic("An input was empty.") + } + + // ID needs to be set by auth because twitch needs some auth to access it. + conf := &Config{ + ClientID: clientId, + ClientSecret: clientSecret, + UserName: userName, + } + + return conf, nil +} diff --git a/cli/live_followed.go b/cli/live_followed.go new file mode 100644 index 0000000..cfeb259 --- /dev/null +++ b/cli/live_followed.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "log" + + "github.com/nicklaw5/helix/v2" +) + +// getFollowedLive prints details about channels the user is following that +// are live. Currently auth is expected to be handled by the caller. The +// passed client should be ready to go. uid is just the user id string. +// XXX: Should maybe do at least scope handling here in the future. +func getFollowedLive(client *helix.Client, uid string) error { + resp, err := client.GetFollowedStream(&helix.FollowedStreamsParams{ + UserID: uid, + }) + + if err != nil { + log.Fatalf("Error getting followed streams: %v\n", err) + return err + } + + // TODO: Pagination and such + for _, stream := range resp.Data.Streams { + fmt.Printf("%v streaming %v for %v viewers since %v\n%v\n\n", + stream.UserName, stream.GameName, stream.ViewerCount, + stream.StartedAt, stream.Title) + } + + return nil +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..8cfe325 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,151 @@ +// Just a program to display information about twitch. +// Token can be found at https://dev.twitch.tv/console +// Also can be obtained at the console I beleive, but twitch-cli token also +// gives a valid token. + +package main + +import ( + "auth" + "context" + "fmt" + "os" + + "github.com/charmbracelet/log" + "github.com/nicklaw5/helix/v2" +) + +var authCtx, authCancel = context.WithCancel(context.Background()) +var helixOptions helix.Options +var defaultUserID string + +func init() { + config, err := read_config() + if err != nil { + fmt.Printf("Error with config: %v\n", err) + fmt.Println("Use the init subcommand to initialize a config file.") + } else { + helixOptions.ClientID = config.ClientID + helixOptions.ClientSecret = config.ClientSecret + defaultUserID = config.UserID + } + + maybeSet := func(out *string, env string) { + val, isSet := os.LookupEnv(env) + if isSet { + *out = val + } + } + + // Override any argument if a corresponding environment variable is set. + maybeSet(&helixOptions.ClientID, "TWITCH_CLIENT_ID") + // maybeSet(&helixOptions.AppAccessToken, "TWITCH_APP_TOKEN") + maybeSet(&helixOptions.ClientSecret, "TWITCH_CLIENT_SECRET") +} + +func printHelp() { + fmt.Println("Print help about commands here.") +} + +// Note: +// Currently everything is intended to just have all the scopes possible for +// us to need to be requested.. +func main() { + ctx := context.Background() + + runAuth := func() *helix.Client { + client, err := helix.NewClient(&helixOptions) + if err != nil { + panic(err) + } + + // Ask for all scopes up front. Not handling scope renegotiation at the + // moment. + a, err := auth.New(&ctx, client, []string{"user:read:follows"}) + if err != nil { + log.Info("Need to reauth") + } + + if !a.HaveAuth { + ch, err := a.StartAuth("", []string{"user:read:follows"}) + if err != nil { + panic(err) + } + + <-ch + } + + return client + } + + if len(os.Args) < 2 { + printHelp() + panic("Insufficient arguments.") + } + + switch os.Args[1] { + case "category": + { + client := runAuth() + categories(client, os.Args[2:]) + } + case "search": + { + client := runAuth() + search(client, os.Args[2:]) + } + case "live": + { + // TODO: FlagSet + client := runAuth() + // Always assume config has ID for now. + err := getFollowedLive(client, defaultUserID) + if err != nil { + panic("Couldn't get followed streams.") + } + } + case "games": + { + // TODO: FlagSet + // XXX: Handle a list of game search terms maybe. + client := runAuth() + if err := printGames(client, []string{os.Args[2]}); err != nil { + panic("Couldn't list games") + } + } + case "init": + { + newConfig, err := setupAuth() + if err != nil { + panic("Something went wrong with init.") + } + helixOptions.ClientID = newConfig.ClientID + helixOptions.ClientSecret = newConfig.ClientSecret + + client := runAuth() + users, err := getUsersByName(client, []string{newConfig.UserName}) + if err != nil { + log.Fatalf("Couldn't look up user '%v': %v\n", newConfig.UserName, err) + } + newConfig.UserID = users[0].ID + + // Hopefully the config is safe to write. + write_config(newConfig) + } + case "user": + { + // TODO: FlagSet + client := runAuth() + users, err := getUsersByName(client, []string{os.Args[2]}) + if err != nil { + panic("Something went wrong with user fetch.") + } + for _, user := range users { + fmt.Printf("%+v\n", user) + } + } + default: + printHelp() + } + +} diff --git a/cli/search.go b/cli/search.go new file mode 100644 index 0000000..31ebfd9 --- /dev/null +++ b/cli/search.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "fmt" + "strings" + + "github.com/charmbracelet/log" + "github.com/nicklaw5/helix/v2" +) + +// Comma separated strings +type CSStrings []string + +func searchHelp() { + fmt.Print("\nTwitch category commands. Valid commands are:\n\n") +} + +func search(client *helix.Client, args []string) { + var params = helix.StreamsParams{} + + fs := flag.NewFlagSet("search", flag.ContinueOnError) + + csvValue := func(in string) (s []string) { + s = strings.Split(in, ",") + for i := range s { + s[i] = strings.TrimSpace(s[i]) + } + + return + } + + langs := fs.String("lang", "en", "Comma separated list of languages (e.g., en,de,etc).") + games := fs.String("games", "", "List of games to search in. (IDs only for now)") + streamType := fs.String("type", "all", "Type of videos to return (e.g., all, live, or vodcast)") + + _ = client + + if err := fs.Parse(args); err != nil { + log.Fatalf("Couldn't parse arguments to search: %v", err) + } + + params.Language = csvValue(*langs) + params.GameIDs = csvValue(*games) + params.Type = *streamType + log.Infof("Params: %+v", params) + + searchWithParams(client, ¶ms) + + // if len(args) < 1 { + // log.Error("Insufficient arguments.") + // searchHelp() + // return + // } +} + +func searchWithParams(client *helix.Client, params *helix.StreamsParams) { + resp, err := client.GetStreams(params) + if err != nil { + log.Info("Search error: %v", err) + return + } + + // Print what we've got + for _, stream := range resp.Data.Streams { + fmt.Printf( + "%v is streaming %v for %v viewers\n", + stream.UserName, + stream.GameName, + stream.ViewerCount, + ) + fmt.Printf("%v\n\n", stream.Title) + } +} diff --git a/cli/users.go b/cli/users.go new file mode 100644 index 0000000..e4f5844 --- /dev/null +++ b/cli/users.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/nicklaw5/helix/v2" +) + +func getUsersByName(client *helix.Client, names []string) ([]helix.User, error) { + resp, err := client.GetUsers(&helix.UsersParams{ + Logins: names, + }) + if err != nil { + return nil, err + } + + return resp.Data.Users, nil +} + +func getUsersByID(client *helix.Client, IDs []string) ([]helix.User, error) { + resp, err := client.GetUsers(&helix.UsersParams{ + IDs: IDs, + }) + if err != nil { + return nil, err + } + + return resp.Data.Users, nil +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b65c9f2 --- /dev/null +++ b/flake.nix @@ -0,0 +1,33 @@ +{ + description = "A Nix-flake-based Go 1.22 development environment"; + + outputs = { self, nixpkgs }: + let + goVersion = 23; # Change this to update the whole stack + + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { + pkgs = import nixpkgs { + inherit system; + overlays = [ self.overlays.default ]; + }; + }); + in + { + overlays.default = final: prev: { + go = final."go_1_${toString goVersion}"; + }; + + devShells = forEachSupportedSystem ({ pkgs }: { + default = pkgs.mkShell { + packages = with pkgs; [ + # go (version is specified by overlay) + go + golines + gotools + gopls + ]; + }; + }); + }; +} diff --git a/generate_cert.go b/generate_cert.go new file mode 100644 index 0000000..b1239aa --- /dev/null +++ b/generate_cert.go @@ -0,0 +1,193 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build ignore + +// Generate a self-signed X.509 certificate for a TLS server. Outputs to +// 'cert.pem' and 'key.pem' and will overwrite existing files. + +package main + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "log" + "math/big" + "net" + "os" + "strings" + "time" +) + +var ( + host = flag.String( + "host", + "", + "Comma-separated hostnames and IPs to generate a certificate for", + ) + validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") + validFor = flag.Duration( + "duration", + 365*24*time.Hour, + "Duration that certificate is valid for", + ) + isCA = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority") + rsaBits = flag.Int( + "rsa-bits", + 2048, + "Size of RSA key to generate. Ignored if --ecdsa-curve is set", + ) + ecdsaCurve = flag.String( + "ecdsa-curve", + "", + "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521", + ) + ed25519Key = flag.Bool("ed25519", false, "Generate an Ed25519 key") +) + +func publicKey(priv any) any { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + case ed25519.PrivateKey: + return k.Public().(ed25519.PublicKey) + default: + return nil + } +} + +func main() { + flag.Parse() + + if len(*host) == 0 { + log.Fatalf("Missing required --host parameter") + } + + var priv any + var err error + switch *ecdsaCurve { + case "": + if *ed25519Key { + _, priv, err = ed25519.GenerateKey(rand.Reader) + } else { + priv, err = rsa.GenerateKey(rand.Reader, *rsaBits) + } + case "P224": + priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + case "P256": + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "P384": + priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "P521": + priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + log.Fatalf("Unrecognized elliptic curve: %q", *ecdsaCurve) + } + if err != nil { + log.Fatalf("Failed to generate private key: %v", err) + } + + // ECDSA, ED25519 and RSA subject keys should have the DigitalSignature + // KeyUsage bits set in the x509.Certificate template + keyUsage := x509.KeyUsageDigitalSignature + // Only RSA subject keys should have the KeyEncipherment KeyUsage bits set. In + // the context of TLS this KeyUsage is particular to RSA key exchange and + // authentication. + if _, isRSA := priv.(*rsa.PrivateKey); isRSA { + keyUsage |= x509.KeyUsageKeyEncipherment + } + + var notBefore time.Time + if len(*validFrom) == 0 { + notBefore = time.Now() + } else { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom) + if err != nil { + log.Fatalf("Failed to parse creation date: %v", err) + } + } + + notAfter := notBefore.Add(*validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Fatalf("Failed to generate serial number: %v", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.Split(*host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if *isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate( + rand.Reader, + &template, + &template, + publicKey(priv), + priv, + ) + if err != nil { + log.Fatalf("Failed to create certificate: %v", err) + } + + certOut, err := os.Create("cert.pem") + if err != nil { + log.Fatalf("Failed to open cert.pem for writing: %v", err) + } + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + log.Fatalf("Failed to write data to cert.pem: %v", err) + } + if err := certOut.Close(); err != nil { + log.Fatalf("Error closing cert.pem: %v", err) + } + log.Print("wrote cert.pem\n") + + keyOut, err := os.OpenFile("key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + log.Fatalf("Failed to open key.pem for writing: %v", err) + } + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + log.Fatalf("Unable to marshal private key: %v", err) + } + if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + log.Fatalf("Failed to write data to key.pem: %v", err) + } + if err := keyOut.Close(); err != nil { + log.Fatalf("Error closing key.pem: %v", err) + } + log.Print("wrote key.pem\n") +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..85bf6e3 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.23.3 + +use ( + ./auth + ./cli +)