Compare commits

...

3 Commits

Author SHA1 Message Date
zomo
e01bd2eb9b added basic ws commands 2026-01-07 00:16:15 -06:00
zomo
a6d932a560 websocket base and command parser 2026-01-06 23:43:14 -06:00
zomo
81e8bca787 moved auth structs 2026-01-05 23:23:13 -06:00
9 changed files with 405 additions and 32 deletions

View File

@@ -5,5 +5,9 @@ CLIENT_SECRET= # Twitch Client Secret
REDIR_URI= # Twitch OAuth Redirect URI
# Required
WS_AUTHORIZATION= # the authorization code all websocket clients will need to connect
# Required
# TODO this is not the final version, this will not be a permanent config option
SQLITE_DB= # SQlite DB location
# Default: ./db.sqlite

View File

@@ -90,6 +90,14 @@ func (server *ApiServer) loadEndpoints() {
c.AbortWithStatus(http.StatusBadRequest)
}
})
server.engine.Any("/ws", func(c *gin.Context) {
err := server.ws.Handle(c.Writer, c.Request)
if err != nil {
log.Printf("Error handing websocket connection from %s: %v", c.RemoteIP(), err)
c.AbortWithStatus(http.StatusInternalServerError)
}
})
}
type ServerInfo struct {

View File

@@ -2,6 +2,7 @@ package api
import (
"github.com/gin-gonic/gin"
"zomo.dev/largehadroncollider/api/ws"
"zomo.dev/largehadroncollider/db"
"zomo.dev/largehadroncollider/ttv"
"zomo.dev/largehadroncollider/util"
@@ -9,8 +10,12 @@ import (
func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) {
engine := gin.Default()
wsServer, err := ws.InitWSServer(conf, dbConn, twitchConn)
if err != nil {
return nil, err
}
apiServer := &ApiServer{engine, conf, dbConn, twitchConn}
apiServer := &ApiServer{engine, wsServer, conf, dbConn, twitchConn}
apiServer.loadEndpoints()
@@ -19,6 +24,7 @@ func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchC
type ApiServer struct {
engine *gin.Engine
ws *ws.WSServer
conf *util.Config
db *db.DBConn
twitch *ttv.TwitchConn

234
api/ws/commands.go Normal file
View File

@@ -0,0 +1,234 @@
package ws
import (
"errors"
"io"
"strings"
)
type Command struct {
Command string
Args []string
}
type parser struct {
cmd *Command
r *strings.Reader
arg string
args []string
}
type parserQuote rune
const (
parserQuoteSingle parserQuote = '\''
parserQuoteDouble parserQuote = '"'
)
type parserBracket rune
const (
parserBracketCurly parserBracket = '{'
parserBracketSquare parserBracket = '['
parserBracketTriangle parserBracket = '<'
)
var parserClosingBracket = map[parserBracket]rune{
parserBracketCurly: '}',
parserBracketSquare: ']',
parserBracketTriangle: '>',
}
func parseCommand(msg string) (*Command, error) {
cmd := &Command{}
r := strings.NewReader(msg)
parser := &parser{cmd: cmd, r: r}
return cmd, parser.parse()
}
func (p *parser) parse() error {
err := p.stateBase()
if err != nil {
return err
}
p.args = append(p.args, p.arg)
filteredArgs := []string{}
for _, arg := range p.args {
if arg != "" {
filteredArgs = append(filteredArgs, arg)
}
}
if len(p.args) >= 1 {
p.cmd.Command = p.args[0]
}
if len(p.args) >= 2 {
p.cmd.Args = p.args[1:]
}
return nil
}
func (p *parser) consume() (rune, bool, error) {
b := make([]byte, 1)
_, err := p.r.Read(b)
if err == io.EOF {
return 0, true, nil
}
if err != nil {
return 0, false, err
}
return rune(b[0]), false, nil
}
func (p *parser) push(r rune) {
p.arg += string(r)
}
func (p *parser) flush() {
p.args = append(p.args, p.arg)
p.arg = ""
}
func (p *parser) stateBase() error {
for {
ch, eof, err := p.consume()
if err != nil {
return err
}
if eof {
return nil
}
switch ch {
case '\\':
err := p.stateEscape()
if err != nil {
return err
}
case rune(parserQuoteDouble): fallthrough
case rune(parserQuoteSingle):
err := p.stateQuote(parserQuote(ch))
if err != nil {
return err
}
// special case, if the arg starts with a { then parse it with rough object mode
case rune(parserBracketCurly): fallthrough
case rune(parserBracketSquare): fallthrough
case rune(parserBracketTriangle):
if p.arg != "" {
p.push(ch)
} else {
err := p.stateBracket(parserBracket(ch))
if err != nil {
return err
}
}
case '\n': fallthrough
case '\t': fallthrough
case ' ':
p.flush()
default:
p.push(ch)
}
}
}
func (p *parser) stateQuote(quote parserQuote) error {
for {
ch, eof, err := p.consume()
if err != nil {
return err
}
if eof {
return errors.New("missing closing quote")
}
switch ch {
case '\\':
err := p.stateEscape()
if err != nil {
return err
}
case rune(quote):
return nil
default:
p.push(ch)
}
}
}
func (p *parser) stateBracket(bracket parserBracket) error {
p.push(rune(bracket))
for {
ch, eof, err := p.consume()
if err != nil {
return err
}
if eof {
return errors.New("missing closing bracket")
}
switch ch {
case '\\':
err := p.stateEscape()
if err != nil {
return err
}
case rune(parserQuoteDouble): fallthrough
case rune(parserQuoteSingle):
p.push(ch)
err := p.stateQuote(parserQuote(ch))
if err != nil {
return err
}
p.push(ch)
case rune(parserBracketCurly): fallthrough
case rune(parserBracketSquare): fallthrough
case rune(parserBracketTriangle):
err := p.stateBracket(parserBracket(ch))
if err != nil {
return err
}
case parserClosingBracket[bracket]:
p.push(ch)
return nil
default:
p.push(ch)
}
}
}
func (p *parser) stateEscape() error {
ch, eof, err := p.consume()
if err != nil {
return err
}
if eof {
return errors.New("invalid escape sequence before end of file")
}
switch ch {
case 'n':
p.arg += "\n"
case 't':
p.arg += "\t"
default:
p.push(ch)
}
return nil
}

View File

@@ -1,17 +1,128 @@
package ws
import (
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"zomo.dev/largehadroncollider/db"
"zomo.dev/largehadroncollider/ttv"
"zomo.dev/largehadroncollider/util"
)
func InitWSServer(dbConn db.DBConn, twitchConn ttv.TwitchConn) (WSServer, error) {
return WSServer{}, nil
func InitWSServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*WSServer, error) {
return &WSServer{conf, dbConn, twitchConn}, nil
}
type WSServer struct {
conf *util.Config
db *db.DBConn
twitch *ttv.TwitchConn
}
func (wsServer *WSServer) Listen() {
// start web server itself
type WSConn struct {
conf *util.Config
c net.Conn
events []string
authrorized bool
}
func (wsServer *WSServer) Handle(w http.ResponseWriter, r *http.Request) error {
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
return err
}
wsconn := &WSConn{}
go wsconn.handleThread(wsServer.conf, conn)
return nil
}
func (conn *WSConn) handleThread(conf *util.Config, c net.Conn) {
conn.conf = conf
conn.c = c
defer c.Close()
for {
msg, _, err := wsutil.ReadClientData(conn.c)
if err != nil {
// TODO handle error better, print more conn info
log.Printf("Error reading data from ws connection %v", err)
continue
}
cmd, err := parseCommand(string(msg))
if err != nil {
log.Printf("Error parsing command data from ws connection %v", err)
continue
}
log.Printf("%+v", cmd)
err = conn.runCommand(cmd)
if err != nil {
_, err := fmt.Fprintf(conn.c, "error running command: %s: %v", cmd.Command, err)
if err != nil {
// TODO better print
log.Printf("ERROR: unable to send error to client: %v", err)
log.Printf("ERROR: client details: %+v", conn)
log.Printf("ERROR: command details: %+v", cmd)
return
}
}
}
}
func (conn *WSConn) runCommand(cmd *Command) error {
switch strings.ToLower(cmd.Command) {
case "ping":
return conn.runCommandPing(cmd.Args)
case "authorization":
return conn.runCommandAuthorization(cmd.Args)
case "events-add":
return conn.runCommandEventsAdd(cmd.Args)
default:
return fmt.Errorf("invalid command: %s", cmd.Command)
}
}
func (conn *WSConn) runCommandPing(_ []string) error {
_, err := fmt.Fprint(conn.c, "pong")
return err
}
func (conn *WSConn) runCommandAuthorization(args []string) error {
if conn.authrorized {
return errors.New("connection already authorized")
}
if len(args) < 1 {
return errors.New("no authorization key given")
}
key := args[0]
if key != conn.conf.WSAuthorization {
return errors.New("invalid authorization key")
}
conn.authrorized = true
return nil
}
func (conn *WSConn) runCommandEventsAdd(args []string) error {
if !conn.authrorized {
return errors.New("unauthorized")
}
// TODO subscribe
return nil
}

View File

@@ -1,12 +1,15 @@
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
fmt.Println("Now:", now)
fmt.Println("Truncated:", now.Truncate(5*time.Minute).Add(5*time.Minute).Sub(now))
// cmd, err := parseCommand("abc \\\"def ghi\"\" [abc][] {\"abc\":'def'}")
// if err != nil {
// fmt.Printf("ERROR: %v\n", err)
// return
// }
// fmt.Printf("cmd: %s\n", cmd.Command)
// for _, arg := range cmd.Args {
// fmt.Printf("arg: %s\n", arg)
// }
}

View File

@@ -32,6 +32,26 @@ type TwitchAuth struct {
Client *api.Client
}
type TwitchAuthRespOk struct {
Code string `json:"code"`
Scope string `json:"scope"`
State string `json:"state"`
}
type TwitchAuthRespErr struct {
Err string `json:"error"`
ErrDesc string `json:"error_description"`
State string `json:"state"`
}
type TwitchAuthTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
}
func (twitch *TwitchAuth) doAuth(formData url.Values) (TwitchAuthTokenResp, error) {
resp, err := http.PostForm(TWITCH_AUTH_URL, formData)
if err != nil {

View File

@@ -17,23 +17,3 @@ func InitTwitchConn(conf *util.Config, dbConn *db.DBConn) (*TwitchConn, error) {
type TwitchConn struct {
Auth *TwitchAuth
}
type TwitchAuthRespOk struct {
Code string `json:"code"`
Scope string `json:"scope"`
State string `json:"state"`
}
type TwitchAuthRespErr struct {
Err string `json:"error"`
ErrDesc string `json:"error_description"`
State string `json:"state"`
}
type TwitchAuthTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
}

View File

@@ -27,6 +27,7 @@ type Config struct {
ClientSecret string
RedirectURI string
SQliteDB string
WSAuthorization string
}
func (c *Config) def() {
@@ -51,6 +52,9 @@ func (c *Config) loadEnv() error {
if str, found := os.LookupEnv("SQLITE_DB"); found {
c.SQliteDB = strings.TrimSpace(str)
}
if str, found := os.LookupEnv("WS_AUTHORIZATION"); found {
c.WSAuthorization = strings.TrimSpace(str)
}
return nil
}
@@ -65,6 +69,9 @@ func (c *Config) verify() error {
if c.RedirectURI == "" {
return errors.New("unable to load a configured Redirect URI")
}
if c.WSAuthorization == "" {
return errors.New("unable to load a configured WS Authorization code")
}
return nil
}