Initial commit

This commit is contained in:
Micheal Smith 2025-07-31 10:27:22 -05:00
commit 2a8f56610e
16 changed files with 1047 additions and 0 deletions

221
auth/auth.go Normal file
View file

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

7
auth/go.mod Normal file
View file

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

4
auth/go.sum Normal file
View file

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

53
cli/categories.go Normal file
View file

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

104
cli/config.go Normal file
View file

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

36
cli/games.go Normal file
View file

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

23
cli/go.mod Normal file
View file

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

40
cli/go.sum Normal file
View file

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

43
cli/init.go Normal file
View file

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

32
cli/live_followed.go Normal file
View file

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

151
cli/main.go Normal file
View file

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

74
cli/search.go Normal file
View file

@ -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, &params)
// 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)
}
}

27
cli/users.go Normal file
View file

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

33
flake.nix Normal file
View file

@ -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
];
};
});
};
}

193
generate_cert.go Normal file
View file

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

6
go.work Normal file
View file

@ -0,0 +1,6 @@
go 1.23.3
use (
./auth
./cli
)