Initial commit
This commit is contained in:
commit
2a8f56610e
16 changed files with 1047 additions and 0 deletions
221
auth/auth.go
Normal file
221
auth/auth.go
Normal 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
7
auth/go.mod
Normal 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
4
auth/go.sum
Normal 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
53
cli/categories.go
Normal 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
104
cli/config.go
Normal 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
36
cli/games.go
Normal 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
23
cli/go.mod
Normal 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
40
cli/go.sum
Normal 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
43
cli/init.go
Normal 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
32
cli/live_followed.go
Normal 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
151
cli/main.go
Normal 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
74
cli/search.go
Normal 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, ¶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)
|
||||
}
|
||||
}
|
||||
27
cli/users.go
Normal file
27
cli/users.go
Normal 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
33
flake.nix
Normal 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
193
generate_cert.go
Normal 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
6
go.work
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
go 1.23.3
|
||||
|
||||
use (
|
||||
./auth
|
||||
./cli
|
||||
)
|
||||
Loading…
Reference in a new issue