diff --git a/Dockerfile b/Dockerfile index bb5c65b..7deac7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN set -ex \ make \ nodejs-lts-hydrogen \ npm \ - && make frontend \ + && make frontend_prod \ && go install \ -trimpath \ -mod=readonly \ diff --git a/Makefile b/Makefile index a226904..67a09c0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ default: lint frontend_lint test lint: golangci-lint run -publish: frontend +publish: frontend_prod curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh bash golang.sh @@ -12,6 +12,9 @@ test: # --- Editor frontend +frontend_prod: export NODE_ENV=production +frontend_prod: frontend + frontend: node_modules node ci/build.mjs diff --git a/auth.go b/auth.go index 18bd614..4202286 100644 --- a/auth.go +++ b/auth.go @@ -99,7 +99,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Authorization as %q complete, you can now close this window.", botUser), http.StatusOK) - frontendReloadHooks.Ping() // Tell frontend to update its config + frontendNotifyHooks.Ping(frontendNotifyTypeReload) // Tell frontend to update its config } func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) { @@ -150,5 +150,5 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Scopes for %q updated, you can now close this window.", grantUser), http.StatusOK) - frontendReloadHooks.Ping() // Tell frontend to update its config + frontendNotifyHooks.Ping(frontendNotifyTypeReload) // Tell frontend to update its config } diff --git a/botEditor.go b/botEditor.go index 801c486..d33dce3 100644 --- a/botEditor.go +++ b/botEditor.go @@ -5,7 +5,6 @@ import ( "github.com/pkg/errors" - "github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/twitch-bot/v3/pkg/twitch" ) @@ -20,26 +19,3 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error _, user, err := tc.GetAuthorizedUser() return user, tc, errors.Wrap(err, "getting authorized user") } - -func botEditorAuthMiddleware(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, tc, err := getAuthorizationFromRequest(r) - if err != nil { - http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusForbidden) - return - } - - id, err := tc.GetIDForUsername(user) - if err != nil { - http.Error(w, errors.Wrap(err, "getting ID for authorized user").Error(), http.StatusForbidden) - return - } - - if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) { - http.Error(w, "user is not authorized", http.StatusForbidden) - return - } - - h.ServeHTTP(w, r) - }) -} diff --git a/config.go b/config.go index 02abd01..3ecb363 100644 --- a/config.go +++ b/config.go @@ -2,16 +2,21 @@ package main import ( _ "embed" + "encoding/base64" + "encoding/hex" "fmt" "io" "os" "path" + "strings" "time" "github.com/go-irc/irc" "github.com/gofrs/uuid/v3" "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" "github.com/Luzifer/go_helpers/v2/str" @@ -96,6 +101,9 @@ func loadConfig(filename string) error { tmpConfig.updateAutoMessagesFromConfig(config) tmpConfig.fixDurations() tmpConfig.fixMissingUUIDs() + if err = tmpConfig.fixTokenHashStorage(); err != nil { + return errors.Wrap(err, "applying token hash fixes") + } switch { case config != nil && config.RawLog == tmpConfig.RawLog: @@ -130,7 +138,7 @@ func loadConfig(filename string) error { }).Info("Config file (re)loaded") // Notify listener config has changed - frontendReloadHooks.Ping() + frontendNotifyHooks.Ping(frontendNotifyTypeReload) return nil } @@ -159,6 +167,9 @@ func patchConfig(filename, authorName, authorEmail, summary string, patcher func } cfgFile.fixMissingUUIDs() + if err = cfgFile.fixTokenHashStorage(); err != nil { + return errors.Wrap(err, "applying token hash fixes") + } err = patcher(cfgFile) switch { @@ -229,6 +240,41 @@ func writeDefaultConfigFile(filename string) error { return errors.Wrap(err, "writing default config") } +func (c configAuthToken) validate(token string) error { + switch { + case strings.HasPrefix(c.Hash, "$2a$"): + return errors.Wrap( + bcrypt.CompareHashAndPassword([]byte(c.Hash), []byte(token)), + "validating bcrypt", + ) + + case strings.HasPrefix(c.Hash, "$argon2id$"): + var ( + flds = strings.Split(c.Hash, "$") + t, m uint32 + p uint8 + ) + + if _, err := fmt.Sscanf(flds[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return errors.Wrap(err, "scanning argon2id hash params") + } + + salt, err := base64.RawStdEncoding.DecodeString(flds[4]) + if err != nil { + return errors.Wrap(err, "decoding salt") + } + + if flds[5] == base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token), salt, t, m, p, argonHashLen)) { + return nil + } + + return errors.New("hash does not match") + + default: + return errors.New("unknown hash format found") + } +} + func (c *configFile) CloseRawMessageWriter() error { if c == nil || c.rawLogWriter == nil { return nil @@ -299,6 +345,26 @@ func (c *configFile) fixMissingUUIDs() { } } +func (c *configFile) fixTokenHashStorage() (err error) { + for key := range c.AuthTokens { + auth := c.AuthTokens[key] + + if strings.HasPrefix(auth.Hash, "$") { + continue + } + + rawHash, err := hex.DecodeString(auth.Hash) + if err != nil { + return errors.Wrap(err, "reading hash") + } + + auth.Hash = string(rawHash) + c.AuthTokens[key] = auth + } + + return nil +} + func (c *configFile) runLoadChecks() (err error) { if len(c.Channels) == 0 { log.Warn("Loaded config with empty channel list") diff --git a/configEditor.go b/configEditor.go index 428f555..a90eccd 100644 --- a/configEditor.go +++ b/configEditor.go @@ -15,7 +15,10 @@ import ( "github.com/Luzifer/twitch-bot/v3/plugins" ) -const websocketPingInterval = 30 * time.Second +const ( + moduleConfigEditor = "config-editor" + websocketPingInterval = 30 * time.Second +) var ( availableActorDocs = []plugins.ActionDocumentation{} diff --git a/configEditor_automessage.go b/configEditor_automessage.go index 372f3e9..3a544d7 100644 --- a/configEditor_automessage.go +++ b/configEditor_automessage.go @@ -18,7 +18,7 @@ func registerEditorAutoMessageRoutes() { Description: "Returns the current set of configured auto-messages in JSON format", HandlerFunc: configEditorHandleAutoMessagesGet, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get current auto-messages", Path: "/auto-messages", RequiresEditorsAuth: true, @@ -28,7 +28,7 @@ func registerEditorAutoMessageRoutes() { Description: "Adds a new Auto-Message", HandlerFunc: configEditorHandleAutoMessageAdd, Method: http.MethodPost, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Add Auto-Message", Path: "/auto-messages", RequiresEditorsAuth: true, @@ -38,7 +38,7 @@ func registerEditorAutoMessageRoutes() { Description: "Deletes the given Auto-Message", HandlerFunc: configEditorHandleAutoMessageDelete, Method: http.MethodDelete, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Delete Auto-Message", Path: "/auto-messages/{uuid}", RequiresEditorsAuth: true, @@ -56,7 +56,7 @@ func registerEditorAutoMessageRoutes() { Description: "Updates the given Auto-Message", HandlerFunc: configEditorHandleAutoMessageUpdate, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Update Auto-Message", Path: "/auto-messages/{uuid}", RequiresEditorsAuth: true, diff --git a/configEditor_general.go b/configEditor_general.go index 73433d8..3a3d5d2 100644 --- a/configEditor_general.go +++ b/configEditor_general.go @@ -30,7 +30,7 @@ func registerEditorGeneralConfigRoutes() { Description: "Add new authorization token", HandlerFunc: configEditorHandleGeneralAddAuthToken, Method: http.MethodPost, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Add authorization token", Path: "/auth-tokens", RequiresEditorsAuth: true, @@ -40,7 +40,7 @@ func registerEditorGeneralConfigRoutes() { Description: "Delete authorization token", HandlerFunc: configEditorHandleGeneralDeleteAuthToken, Method: http.MethodDelete, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Delete authorization token", Path: "/auth-tokens/{handle}", RequiresEditorsAuth: true, @@ -58,7 +58,7 @@ func registerEditorGeneralConfigRoutes() { Description: "List authorization tokens", HandlerFunc: configEditorHandleGeneralListAuthTokens, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "List authorization tokens", Path: "/auth-tokens", RequiresEditorsAuth: true, @@ -68,7 +68,7 @@ func registerEditorGeneralConfigRoutes() { Description: "Returns the current general config", HandlerFunc: configEditorHandleGeneralGet, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get general config", Path: "/general", RequiresEditorsAuth: true, @@ -78,7 +78,7 @@ func registerEditorGeneralConfigRoutes() { Description: "Updates the general config", HandlerFunc: configEditorHandleGeneralUpdate, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Update general config", Path: "/general", RequiresEditorsAuth: true, @@ -88,7 +88,7 @@ func registerEditorGeneralConfigRoutes() { Description: "Get Bot-Auth URLs for updating bot token and channel scopes", HandlerFunc: configEditorHandleGeneralAuthURLs, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get Bot-Auth-URLs", Path: "/auth-urls", RequiresEditorsAuth: true, diff --git a/configEditor_global.go b/configEditor_global.go index d6819a9..81ded8b 100644 --- a/configEditor_global.go +++ b/configEditor_global.go @@ -11,7 +11,9 @@ import ( "github.com/Luzifer/twitch-bot/v3/plugins" ) -var frontendReloadHooks = newHooker() +const frontendNotifyTypeReload = "configReload" + +var frontendNotifyHooks = newHooker() //nolint:funlen // Just contains a collection of objects func registerEditorGlobalMethods() { @@ -20,7 +22,7 @@ func registerEditorGlobalMethods() { Description: "Returns the documentation for available actions", HandlerFunc: configEditorGlobalGetActions, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get available actions", Path: "/actions", ResponseType: plugins.HTTPRouteResponseTypeJSON, @@ -29,7 +31,7 @@ func registerEditorGlobalMethods() { Description: "Returns all available modules for auth", HandlerFunc: configEditorGlobalGetModules, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get available modules", Path: "/modules", ResponseType: plugins.HTTPRouteResponseTypeJSON, @@ -38,7 +40,7 @@ func registerEditorGlobalMethods() { Description: "Returns information about a Twitch user to properly display bot editors", HandlerFunc: configEditorGlobalGetUser, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get user information", Path: "/user", QueryParams: []plugins.HTTPRouteParamDocumentation{ @@ -56,7 +58,7 @@ func registerEditorGlobalMethods() { Description: "Subscribe for configuration changes", HandlerFunc: configEditorGlobalSubscribe, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Websocket: Subscribe config changes", Path: "/notify-config", ResponseType: plugins.HTTPRouteResponseTypeTextPlain, @@ -65,7 +67,7 @@ func registerEditorGlobalMethods() { Description: "Validate a cron expression and return the next executions", HandlerFunc: configEditorGlobalValidateCron, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Validate cron expression", Path: "/validate-cron", QueryParams: []plugins.HTTPRouteParamDocumentation{ @@ -88,7 +90,7 @@ func registerEditorGlobalMethods() { Description: "Validate a regular expression against the RE2 regex parser", HandlerFunc: configEditorGlobalValidateRegex, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Validate regular expression", Path: "/validate-regex", QueryParams: []plugins.HTTPRouteParamDocumentation{ @@ -105,7 +107,7 @@ func registerEditorGlobalMethods() { Description: "Validate a template expression against the built in template function library", HandlerFunc: configEditorGlobalValidateTemplate, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Validate template expression", Path: "/validate-template", QueryParams: []plugins.HTTPRouteParamDocumentation{ @@ -161,9 +163,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) { defer conn.Close() var ( - configReloadNotify = make(chan struct{}, 1) - pingTimer = time.NewTicker(websocketPingInterval) - unsubscribe = frontendReloadHooks.Register(func() { configReloadNotify <- struct{}{} }) + frontendNotify = make(chan string, 1) + pingTimer = time.NewTicker(websocketPingInterval) + unsubscribe = frontendNotifyHooks.Register(func(payload any) { frontendNotify <- payload.(string) }) ) defer unsubscribe() @@ -173,9 +175,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) { for { select { - case <-configReloadNotify: + case msgType := <-frontendNotify: if err := conn.WriteJSON(socketMsg{ - MsgType: "configReload", + MsgType: msgType, }); err != nil { log.WithError(err).Debug("Unable to send websocket notification") return diff --git a/configEditor_rules.go b/configEditor_rules.go index c6a8581..22352a9 100644 --- a/configEditor_rules.go +++ b/configEditor_rules.go @@ -18,7 +18,7 @@ func registerEditorRulesRoutes() { Description: "Returns the current set of configured rules in JSON format", HandlerFunc: configEditorRulesGet, Method: http.MethodGet, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Get current rules", Path: "/rules", RequiresEditorsAuth: true, @@ -28,7 +28,7 @@ func registerEditorRulesRoutes() { Description: "Adds a new Rule", HandlerFunc: configEditorRulesAdd, Method: http.MethodPost, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Add Rule", Path: "/rules", RequiresEditorsAuth: true, @@ -38,7 +38,7 @@ func registerEditorRulesRoutes() { Description: "Deletes the given Rule", HandlerFunc: configEditorRulesDelete, Method: http.MethodDelete, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Delete Rule", Path: "/rules/{uuid}", RequiresEditorsAuth: true, @@ -56,7 +56,7 @@ func registerEditorRulesRoutes() { Description: "Updates the given Rule", HandlerFunc: configEditorRulesUpdate, Method: http.MethodPut, - Module: "config-editor", + Module: moduleConfigEditor, Name: "Update Rule", Path: "/rules/{uuid}", RequiresEditorsAuth: true, diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..6426213 --- /dev/null +++ b/config_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAuthTokenValidate(t *testing.T) { + assert.NoError( + t, + configAuthToken{Hash: "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$vvmJ4o4I75omtCuCUcFupw"}. + validate("21a84dfe-155f-427d-add2-2e63dab7502f"), + "valid argon2id token", + ) + assert.Error( + t, + configAuthToken{Hash: "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$vvmJ4o4I75omtCuCUcFupw"}. + validate("a8188a1f-d7ff-4628-b0f3-0efa0ef364d8"), + "invalid argon2id token", + ) + + assert.NoError( + t, + configAuthToken{Hash: "$2a$10$xa3tzQheujq3nj/vJdzKIOzaPvirI6FFangBNDFXI8BME4rBMhNoG"}. + validate("21a84dfe-155f-427d-add2-2e63dab7502f"), + "valid bcrypt token", + ) + assert.Error( + t, + configAuthToken{Hash: "$2a$10$xa3tzQheujq3nj/vJdzKIOzaPvirI6FFangBNDFXI8BME4rBMhNoG"}. + validate("a8188a1f-d7ff-4628-b0f3-0efa0ef364d8"), + "invalid bcrypt token", + ) +} diff --git a/hooker.go b/hooker.go index 693534c..1d5a6b1 100644 --- a/hooker.go +++ b/hooker.go @@ -8,23 +8,23 @@ import ( type ( hooker struct { - hooks map[string]func() + hooks map[string]func(any) lock sync.RWMutex } ) -func newHooker() *hooker { return &hooker{hooks: map[string]func(){}} } +func newHooker() *hooker { return &hooker{hooks: map[string]func(any){}} } -func (h *hooker) Ping() { +func (h *hooker) Ping(payload any) { h.lock.RLock() defer h.lock.RUnlock() for _, hf := range h.hooks { - hf() + hf(payload) } } -func (h *hooker) Register(hook func()) func() { +func (h *hooker) Register(hook func(any)) func() { h.lock.Lock() defer h.lock.Unlock() diff --git a/internal/apimodules/raffle/api.go b/internal/apimodules/raffle/api.go new file mode 100644 index 0000000..bd8a110 --- /dev/null +++ b/internal/apimodules/raffle/api.go @@ -0,0 +1,300 @@ +package raffle + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gofrs/uuid" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +var apiRoutes = []plugins.HTTPRouteRegistrationArgs{ + { + Description: "Lists all raffles known to the bot", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, _ map[string]uint64) (any, error) { + ras, err := dbc.List() + return ras, errors.Wrap(err, "fetching raffles from database") + }, nil), + Method: http.MethodGet, + Module: actorName, + Name: "List Raffles", + Path: "/", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeJSON, + }, + + { + Description: "Creates a new raffle based on the data in the body", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, _ map[string]uint64) (any, error) { + var ra raffle + if err := json.NewDecoder(r.Body).Decode(&ra); err != nil { + return nil, errors.Wrap(err, "parsing raffle from body") + } + + return nil, errors.Wrap(dbc.Create(ra), "creating raffle") + }, nil), + Method: http.MethodPost, + Module: actorName, + Name: "Create Raffle", + Path: "/", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + }, + + { + Description: "Deletes raffle by given ID including all entries", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.Delete(ids["id"]), "fetching raffle from database") + }, []string{"id"}), + Method: http.MethodDelete, + Module: actorName, + Name: "Delete Raffle", + Path: "/{id}", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to fetch", + Name: "id", + }, + }, + }, + + { + Description: "Gets raffle by given ID including all entries", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + ra, err := dbc.Get(ids["id"]) + return ra, errors.Wrap(err, "fetching raffle from database") + }, []string{"id"}), + Method: http.MethodGet, + Module: actorName, + Name: "Get Raffle", + Path: "/{id}", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeJSON, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to fetch", + Name: "id", + }, + }, + }, + + { + Description: "Updates the given raffle (needs to include the whole object, not just changed fields)", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + var ra raffle + if err := json.NewDecoder(r.Body).Decode(&ra); err != nil { + return nil, errors.Wrap(err, "parsing raffle from body") + } + + if ra.ID != ids["id"] { + return nil, errors.New("raffle ID does not match") + } + + return nil, errors.Wrap(dbc.Update(ra), "updating raffle") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Update Raffle", + Path: "/{id}", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to update", + Name: "id", + }, + }, + }, + + { + Description: "Duplicates the raffle given by its ID", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.Clone(ids["id"]), "cloning raffle") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Clone Raffle", + Path: "/{id}/clone", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to clone", + Name: "id", + }, + }, + }, + + { + Description: "Closes the raffle given by its ID", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.Close(ids["id"]), "closing raffle") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Close Raffle", + Path: "/{id}/close", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to close", + Name: "id", + }, + }, + }, + + { + Description: "Picks a winner for the given raffle (this does NOT close the raffle, use only on closed raffle!)", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.PickWinner(ids["id"]), "picking winner") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Pick Raffle Winner", + Path: "/{id}/pick", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to pick a winner for", + Name: "id", + }, + }, + }, + + { + Description: "Re-opens a raffle for additional entries, only Status and CloseAt are modified", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + dur, err := strconv.ParseInt(r.URL.Query().Get("duration"), 10, 64) + if err != nil { + return nil, errors.Wrap(err, "parsing duration") + } + + return nil, errors.Wrap(dbc.Reopen(ids["id"], time.Duration(dur)*time.Second), "reopening raffle") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Reopen Raffle", + Path: "/{id}/reopen", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Number of seconds to leave the raffle open", + Name: "duration", + Required: true, + Type: "integer", + }, + }, + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to pick a winner for", + Name: "id", + }, + }, + }, + + { + Description: "Starts a raffle making it available for entries", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.Start(ids["id"]), "starting raffle") + }, []string{"id"}), + Method: http.MethodPut, + Module: actorName, + Name: "Start Raffle", + Path: "/{id}/start", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to start", + Name: "id", + }, + }, + }, + + { + Description: "Dismisses a previously picked winner and picks a new one", + HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) { + return nil, errors.Wrap(dbc.RedrawWinner(ids["id"], ids["winner"]), "re-picking winner") + }, []string{"id", "winner"}), + Method: http.MethodPut, + Module: actorName, + Name: "Re-Pick Raffle Winner", + Path: "/{id}/repick/{winner}", + RequiresWriteAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeNo200, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "ID of the raffle to re-pick the winner for", + Name: "id", + }, + { + Description: "ID of the winner to replace", + Name: "winner", + }, + }, + }, +} + +func handleWrap(f func(http.ResponseWriter, *http.Request, map[string]uint64) (any, error), parseIDs []string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + ids = map[string]uint64{} + reqUUID = uuid.Must(uuid.NewV4()).String() + logger = logrus.WithFields(logrus.Fields{ + "path": r.URL.Path, + "req": reqUUID, + }) + ) + + for _, k := range parseIDs { + id, err := strconv.ParseUint(mux.Vars(r)[k], 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("invalid ID field %q", k), http.StatusBadRequest) + return + } + + ids[k] = id + } + + resp, err := f(w, r, ids) + if err != nil { + logger.WithError(err).Error("handling request") + http.Error(w, "something went wrong", http.StatusInternalServerError) + return + } + + if resp == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "applicatioh/json") + if err = json.NewEncoder(w).Encode(resp); err != nil { + logger.WithError(err).Error("serializing response") + http.Error(w, "something went wrong", http.StatusInternalServerError) + return + } + } +} + +func registerAPI(args plugins.RegistrationArguments) (err error) { + for i, r := range apiRoutes { + if err = args.RegisterAPIRoute(r); err != nil { + return errors.Wrapf(err, "registering route %d", i) + } + } + + return nil +} diff --git a/internal/apimodules/raffle/database.go b/internal/apimodules/raffle/database.go new file mode 100644 index 0000000..b2fd49b --- /dev/null +++ b/internal/apimodules/raffle/database.go @@ -0,0 +1,637 @@ +package raffle + +import ( + "strings" + "sync" + "time" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + + "github.com/Luzifer/twitch-bot/v3/pkg/database" + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +const ( + frontendNotifyEventRaffleChange = "raffleChanged" + frontendNotifyEventRaffleEntryChange = "raffleEntryChanged" +) + +type ( + dbClient struct { + activeRaffles map[string]uint64 + speakUp map[string]*speakUpWait + db database.Connector + lock sync.RWMutex + } + + raffle struct { + ID uint64 `gorm:"primaryKey" json:"id"` + + Channel string `json:"channel"` + Keyword string `json:"keyword"` + Title string `json:"title"` + Status raffleStatus `gorm:"default:planned" json:"status"` + + AllowEveryone bool `json:"allowEveryone"` + AllowFollower bool `json:"allowFollower"` + AllowSubscriber bool `json:"allowSubscriber"` + AllowVIP bool `gorm:"column:allow_vip" json:"allowVIP"` + MinFollowAge time.Duration `json:"minFollowAge"` + + MultiFollower float64 `json:"multiFollower"` + MultiSubscriber float64 `json:"multiSubscriber"` + MultiVIP float64 `gorm:"column:multi_vip" json:"multiVIP"` + + AutoStartAt *time.Time `json:"autoStartAt"` + CloseAfter time.Duration `json:"closeAfter"` + CloseAt *time.Time `json:"closeAt"` + WaitForResponse time.Duration `json:"waitForResponse"` + + TextClose string `json:"textClose"` + TextClosePost bool `json:"textClosePost"` + TextEntry string `json:"textEntry"` + TextEntryPost bool `json:"textEntryPost"` + TextEntryFail string `json:"textEntryFail"` + TextEntryFailPost bool `json:"textEntryFailPost"` + TextReminder string `json:"textReminder"` + TextReminderInterval time.Duration `json:"textReminderInterval"` + TextReminderNextSend time.Time `json:"-"` + TextReminderPost bool `json:"textReminderPost"` + TextWin string `json:"textWin"` + TextWinPost bool `json:"textWinPost"` + + Entries []raffleEntry `gorm:"foreignKey:RaffleID" json:"entries,omitempty"` + } + + raffleEntry struct { + ID uint64 `gorm:"primaryKey" json:"id"` + RaffleID uint64 `gorm:"uniqueIndex:user_per_raffle" json:"-"` + + UserID string `gorm:"uniqueIndex:user_per_raffle" json:"userID"` + UserLogin string `json:"userLogin"` + UserDisplayName string `json:"userDisplayName"` + + EnteredAt time.Time `json:"enteredAt"` + EnteredAs string `json:"enteredAs"` + Multiplier float64 `json:"multiplier"` + + WasPicked bool `json:"wasPicked"` + WasRedrawn bool `json:"wasRedrawn"` + + DrawResponse string `json:"drawResponse,omitempty"` + SpeakUpUntil *time.Time `json:"speakUpUntil,omitempty"` + } + + raffleMessageEvent uint8 + raffleStatus string + + speakUpWait struct { + RaffleEntryID uint64 + Until time.Time + } +) + +const ( + raffleMessageEventEntryFailed raffleMessageEvent = iota + raffleMessageEventEntry + raffleMessageEventReminder + raffleMessageEventWin + raffleMessageEventClose +) + +const ( + raffleStatusPlanned raffleStatus = "planned" + raffleStatusActive raffleStatus = "active" + raffleStatusEnded raffleStatus = "ended" +) + +var errRaffleNotFound = errors.New("raffle not found") + +func newDBClient(db database.Connector) *dbClient { + return &dbClient{ + activeRaffles: make(map[string]uint64), + speakUp: make(map[string]*speakUpWait), + db: db, + } +} + +// AutoCloseExpired collects all active raffles which have overdue +// close_at dates and closes them +func (d *dbClient) AutoCloseExpired() (err error) { + var rr []raffle + + if err = d.db.DB(). + Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()). + Find(&rr). + Error; err != nil { + return errors.Wrap(err, "fetching raffles to close") + } + + for _, r := range rr { + if err = d.Close(r.ID); err != nil { + return errors.Wrapf(err, "closing raffle %d", r.ID) + } + } + + return nil +} + +// AutoSendReminders collects all active raffles which have enabled +// reminders which are overdue and posts the reminder for them +func (d *dbClient) AutoSendReminders() (err error) { + var rr []raffle + + if err = d.db.DB(). + Where("status = ? AND text_reminder_post = ? AND text_reminder_next_send < ?", raffleStatusActive, true, time.Now().UTC()). + Find(&rr). + Error; err != nil { + return errors.Wrap(err, "fetching raffles to send reminders") + } + + for _, r := range rr { + if err = r.SendEvent(raffleMessageEventReminder, nil); err != nil { + return errors.Wrapf(err, "sending reminder for raffle %d", r.ID) + } + } + + return nil +} + +// AutoStart collects planned and overdue raffles and starts them +func (d *dbClient) AutoStart() (err error) { + var rr []raffle + + if err = d.db.DB(). + Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()). + Find(&rr). + Error; err != nil { + return errors.Wrap(err, "fetching raffles to start") + } + + for _, r := range rr { + if err = d.Start(r.ID); err != nil { + return errors.Wrapf(err, "starting raffle %d", r.ID) + } + } + + return nil +} + +// Clone duplicates a raffle into a new draft resetting some +// parameters into their default state +func (d *dbClient) Clone(raffleID uint64) error { + raffle, err := d.Get(raffleID) + if err != nil { + return errors.Wrap(err, "getting raffle") + } + + raffle.CloseAt = nil + raffle.Entries = nil + raffle.ID = 0 + raffle.Status = raffleStatusPlanned + raffle.Title = strings.Join([]string{"Copy of", raffle.Title}, " ") + + if err = d.Create(raffle); err != nil { + return errors.Wrap(err, "creating copy") + } + + frontendNotify(frontendNotifyEventRaffleChange) + return nil +} + +// Close marks the raffle as closed and removes it from the active +// raffle cache +func (d *dbClient) Close(raffleID uint64) error { + r, err := d.Get(raffleID) + if err != nil { + return errors.Wrap(err, "getting raffle") + } + + if err = d.db.DB().Model(&raffle{}). + Where("id = ?", raffleID). + Update("status", raffleStatusEnded). + Error; err != nil { + return errors.Wrap(err, "setting status closed") + } + + d.lock.Lock() + defer d.lock.Unlock() + delete(d.activeRaffles, strings.Join([]string{r.Channel, r.Keyword}, "::")) + + frontendNotify(frontendNotifyEventRaffleChange) + + return errors.Wrap( + r.SendEvent(raffleMessageEventClose, nil), + "sending close-message", + ) +} + +// Create creates a new raffle. The record will be written to +// the database without modification and therefore need to be filled +// before calling this function +func (d *dbClient) Create(r raffle) error { + if err := d.db.DB().Create(&r).Error; err != nil { + return errors.Wrap(err, "creating database record") + } + + frontendNotify(frontendNotifyEventRaffleChange) + return nil +} + +// Delete removes all entries for the given raffle and afterwards +// deletes the raffle itself +func (d *dbClient) Delete(raffleID uint64) (err error) { + if err = d.db.DB(). + Where("raffle_id = ?", raffleID). + Delete(&raffleEntry{}). + Error; err != nil { + return errors.Wrap(err, "deleting raffle entries") + } + + if err = d.db.DB(). + Where("id = ?", raffleID). + Delete(&raffle{}).Error; err != nil { + return errors.Wrap(err, "creating database record") + } + + frontendNotify(frontendNotifyEventRaffleChange) + return nil +} + +// Enter creates a new raffle entry. The entry will be written to +// the database without modification and therefore need to be filled +// before calling this function +func (d *dbClient) Enter(re raffleEntry) error { + if err := d.db.DB().Create(&re).Error; err != nil { + return errors.Wrap(err, "creating database record") + } + + frontendNotify(frontendNotifyEventRaffleEntryChange) + return nil +} + +// Get retrieves a raffle from the database +func (d *dbClient) Get(raffleID uint64) (out raffle, err error) { + return out, errors.Wrap( + d.db.DB(). + Where("raffles.id = ?", raffleID). + Preload("Entries"). + First(&out). + Error, + "getting raffle from database", + ) +} + +// GetByChannelAndKeyword resolves an active raffle through channel +// and keyword given in the raffle and returns it through the Get +// function. If the combination is not known errRaffleNotFound is +// returned. +func (d *dbClient) GetByChannelAndKeyword(channel, keyword string) (raffle, error) { + d.lock.RLock() + id := d.activeRaffles[strings.Join([]string{channel, keyword}, "::")] + d.lock.RUnlock() + + if id == 0 { + return raffle{}, errRaffleNotFound + } + + return d.Get(id) +} + +// List returns a list of all known raffles +func (d *dbClient) List() (raffles []raffle, _ error) { + return raffles, errors.Wrap( + d.db.DB().Model(&raffle{}). + Order("id DESC"). + Find(&raffles). + Error, + "updating column", + ) +} + +// PatchNextReminderSend updates the time another reminder shall be +// sent for the given raffle ID. No other fields are modified +func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error { + return errors.Wrap( + d.db.DB().Model(&raffle{}). + Where("id = ?", raffleID). + Update("text_reminder_next_send", next). + Error, + "updating column", + ) +} + +// PickWinner fetches the given raffle and picks a random winner +// based on entries and their multiplier +func (d *dbClient) PickWinner(raffleID uint64) error { + r, err := d.Get(raffleID) + if err != nil { + return errors.Wrap(err, "getting raffle") + } + + winner, err := pickWinnerFromRaffle(r) + if err != nil { + return errors.Wrap(err, "picking winner") + } + + speakUpUntil := time.Now().UTC().Add(r.WaitForResponse) + if err = d.db.DB().Model(&raffleEntry{}). + Where("id = ?", winner.ID). + Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}). + Error; err != nil { + return errors.Wrap(err, "updating winner") + } + + d.lock.Lock() + d.speakUp[strings.Join([]string{r.Channel, winner.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: winner.ID, Until: speakUpUntil} + d.lock.Unlock() + + fields := plugins.FieldCollectionFromData(map[string]any{ + "user_id": winner.UserID, + "user": winner.UserLogin, + "winner": winner, + }) + + frontendNotify(frontendNotifyEventRaffleEntryChange) + + return errors.Wrap( + r.SendEvent(raffleMessageEventWin, fields), + "sending win-message", + ) +} + +// RedrawWinner marks the previous winner as redrawn (and therefore +// crossed out as winner in the interface) and picks a new one +func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error { + if err := d.db.DB().Model(&raffleEntry{}). + Where("id = ?", winnerID). + Update("was_redrawn", true). + Error; err != nil { + return errors.Wrap(err, "updating previous winner") + } + + return d.PickWinner(raffleID) +} + +// RefreshActiveRaffles loads all active raffles and populates the +// activeRaffles cache +func (d *dbClient) RefreshActiveRaffles() error { + d.lock.Lock() + defer d.lock.Unlock() + + var ( + actives []raffle + tmp = map[string]uint64{} + ) + + if err := d.db.DB(). + Where("status = ?", raffleStatusActive). + Find(&actives). + Error; err != nil { + return errors.Wrap(err, "fetching active raffles") + } + + for _, r := range actives { + tmp[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID + } + + d.activeRaffles = tmp + return nil +} + +// RefreshSpeakUp seeks all still active speak-up entries and +// populates the speak-up cache with them +func (d *dbClient) RefreshSpeakUp() error { + d.lock.Lock() + defer d.lock.Unlock() + + var ( + res []raffleEntry + tmp = map[string]*speakUpWait{} + ) + + if err := d.db.DB().Debug(). + Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()). + Find(&res). + Error; err != nil { + return errors.Wrap(err, "querying active entries") + } + + for _, e := range res { + var r raffle + if err := d.db.DB(). + Where("id = ?", e.RaffleID). + First(&r). + Error; err != nil { + return errors.Wrap(err, "fetching raffle for entry") + } + tmp[strings.Join([]string{r.Channel, e.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: e.ID, Until: *e.SpeakUpUntil} + } + + d.speakUp = tmp + return nil +} + +// RegisterSpeakUp sets the speak-up message if there was a +// speakUpWait for that user and channel +func (d *dbClient) RegisterSpeakUp(channel, user, message string) error { + d.lock.RLock() + w := d.speakUp[strings.Join([]string{channel, user}, ":")] + d.lock.RUnlock() + + if w == nil || w.Until.Before(time.Now()) { + // No speak-up-request for that user or expired + return nil + } + + if err := d.db.DB(). + Model(&raffleEntry{}). + Where("id = ?", w.RaffleEntryID). + Updates(map[string]any{ + "DrawResponse": message, + "SpeakUpUntil": nil, + }). + Error; err != nil { + return errors.Wrap(err, "registering speak-up") + } + + d.lock.Lock() + defer d.lock.Unlock() + delete(d.speakUp, strings.Join([]string{channel, user}, ":")) + + frontendNotify(frontendNotifyEventRaffleEntryChange) + return nil +} + +// Reopen updates the CloseAt attribute and status to active to +// prolong the raffle +func (d *dbClient) Reopen(raffleID uint64, duration time.Duration) error { + r, err := d.Get(raffleID) + if err != nil { + return errors.Wrap(err, "getting specified raffle") + } + + if err = d.db.DB(). + Model(&raffle{}). + Where("id = ?", raffleID). + Updates(map[string]any{ + "CloseAt": time.Now().UTC().Add(duration), + "status": raffleStatusActive, + }). + Error; err != nil { + return errors.Wrap(err, "updating raffle") + } + + // Store ID to active-raffle cache + d.lock.Lock() + defer d.lock.Unlock() + d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID + + frontendNotify(frontendNotifyEventRaffleChange) + return nil +} + +// Start fetches the given raffle, updates its CloseAt attribute +// in case it is not already set, sets the raffle to active, updates +// the raffle in the database and notes its channel/keyword combo +// into the activeRaffles cache for use with irc handling +func (d *dbClient) Start(raffleID uint64) error { + r, err := d.Get(raffleID) + if err != nil { + return errors.Wrap(err, "getting specified raffle") + } + + if r.CloseAt == nil { + end := time.Now().UTC().Add(r.CloseAfter) + r.CloseAt = &end + } + + r.Status = raffleStatusActive + if err = d.Update(r); err != nil { + return errors.Wrap(err, "updating raffle") + } + + // Store ID to active-raffle cache + d.lock.Lock() + defer d.lock.Unlock() + d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID + + frontendNotify(frontendNotifyEventRaffleChange) + + return errors.Wrap( + r.SendEvent(raffleMessageEventReminder, nil), + "sending first reminder", + ) +} + +// Update stores the given raffle to the database. The ID within the +// raffle object must be set in order to update it. The object must +// be completely filled. +func (d *dbClient) Update(r raffle) error { + old, err := d.Get(r.ID) + if err != nil { + return errors.Wrap(err, "getting previous version") + } + + // These information must not be changed after raffle has been started + if old.Status != raffleStatusPlanned { + r.Channel = old.Channel + r.Keyword = old.Keyword + r.Status = old.Status + + r.AllowEveryone = old.AllowEveryone + r.AllowFollower = old.AllowFollower + r.AllowSubscriber = old.AllowSubscriber + r.AllowVIP = old.AllowVIP + r.MinFollowAge = old.MinFollowAge + + r.MultiFollower = old.MultiFollower + r.MultiSubscriber = old.MultiSubscriber + r.MultiVIP = old.MultiVIP + + r.AutoStartAt = old.AutoStartAt + } + + // This info must be preserved + r.Entries = nil + r.TextReminderNextSend = old.TextReminderNextSend + + if err := d.db.DB(). + Model(&raffle{}). + Where("id = ?", r.ID). + Updates(&r). + Error; err != nil { + return errors.Wrap(err, "updating raffle") + } + + frontendNotify(frontendNotifyEventRaffleChange) + return nil +} + +// SendEvent processes the text template and sends the message if +// enabled given through the event +func (r raffle) SendEvent(evt raffleMessageEvent, fields *plugins.FieldCollection) (err error) { + if fields == nil { + fields = plugins.NewFieldCollection() + } + + fields.Set("raffle", r) // Make raffle available to templating + + var sendTextTpl string + + switch evt { + case raffleMessageEventClose: + if !r.TextClosePost { + return nil + } + sendTextTpl = r.TextClose + + case raffleMessageEventEntryFailed: + if !r.TextEntryFailPost { + return nil + } + sendTextTpl = r.TextEntryFail + + case raffleMessageEventEntry: + if !r.TextEntryPost { + return nil + } + sendTextTpl = r.TextEntry + + case raffleMessageEventReminder: + if !r.TextReminderPost { + return nil + } + sendTextTpl = r.TextReminder + if err = dbc.PatchNextReminderSend(r.ID, time.Now().UTC().Add(r.TextReminderInterval)); err != nil { + return errors.Wrap(err, "updating next reminder for raffle") + } + + case raffleMessageEventWin: + if !r.TextWinPost { + return nil + } + sendTextTpl = r.TextWin + + default: + // How? + return errors.New("unexpected event") + } + + msg, err := formatMessage(sendTextTpl, nil, nil, fields) + if err != nil { + return errors.Wrap(err, "formatting message to send") + } + + return errors.Wrap( + send(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + "#" + strings.TrimLeft(r.Channel, "#"), + msg, + }, + }), + "sending message", + ) +} diff --git a/internal/apimodules/raffle/irc.go b/internal/apimodules/raffle/irc.go new file mode 100644 index 0000000..53787e1 --- /dev/null +++ b/internal/apimodules/raffle/irc.go @@ -0,0 +1,144 @@ +package raffle + +import ( + "strings" + "time" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +func rawMessageHandler(m *irc.Message) error { + if m.Command != "PRIVMSG" { + // We only care for messages containing the raffle keyword + return nil + } + + channel := plugins.DeriveChannel(m, nil) + if channel == "" { + // The frick? Messages should have a channel but whatever + return nil + } + + user := plugins.DeriveUser(m, nil) + if user == "" { + // The frick? Messages should have a user but whatever + return nil + } + + go func() { + if err := dbc.RegisterSpeakUp(channel, user, m.Trailing()); err != nil { + logrus.WithFields(logrus.Fields{ + "channel": channel, + "user": user, + }).WithError(err).Error("registering speak-up") + } + }() + + return handleRaffleEntry(m, channel, user) +} + +//nolint:gocyclo // Dividing would need to carry over everyhing and make it more complex +func handleRaffleEntry(m *irc.Message, channel, user string) error { + flds := strings.Fields(m.Trailing()) + if len(flds) == 0 { + // A message also should have: a message! + return nil + } + + var ( + badges = twitch.ParseBadgeLevels(m) + doesFollow bool + keyword = flds[0] + ) + + r, err := dbc.GetByChannelAndKeyword(channel, keyword) + if err != nil { + if errors.Is(err, errRaffleNotFound) { + // We don't need to care, that was no raffle input + return nil + } + return errors.Wrap(err, "fetching raffle") + } + + raffleChan, err := tcGetter(r.Channel) + if err != nil { + return errors.Wrap(err, "getting twitch client for raffle") + } + + since, err := raffleChan.GetFollowDate(user, strings.TrimLeft(channel, "#")) + switch { + case err == nil: + doesFollow = since.Before(time.Now().Add(-r.MinFollowAge)) + + case errors.Is(err, twitch.ErrUserDoesNotFollow): + doesFollow = false + + default: + return errors.Wrap(err, "checking follow for user") + } + + re := raffleEntry{ + RaffleID: r.ID, + UserID: string(m.Tags["user-id"]), + UserLogin: user, + UserDisplayName: string(m.Tags["display-name"]), + EnteredAt: time.Now().UTC(), + } + + if re.UserDisplayName == "" { + re.UserDisplayName = re.UserLogin + } + + raffleEventFields := plugins.FieldCollectionFromData(map[string]any{ + "user_id": string(m.Tags["user-id"]), + "user": user, + }) + + switch { + case r.AllowVIP && badges.Has(twitch.BadgeVIP): + re.EnteredAs = twitch.BadgeVIP + re.Multiplier = r.MultiVIP + + case r.AllowSubscriber && badges.Has(twitch.BadgeSubscriber): + re.EnteredAs = twitch.BadgeSubscriber + re.Multiplier = r.MultiSubscriber + + case r.AllowFollower && doesFollow: + re.EnteredAs = "follower" + re.Multiplier = r.MultiFollower + + case r.AllowEveryone: + re.EnteredAs = "everyone" + re.Multiplier = 1 + + default: + // Well. No luck, no entry. + return errors.Wrap( + r.SendEvent(raffleMessageEventEntryFailed, raffleEventFields), + "sending entry-failed chat message", + ) + } + + // We have everything we need to create an entry + if err = dbc.Enter(re); err != nil { + logrus.WithFields(logrus.Fields{ + "raffle": r.ID, + "user_id": re.UserID, + "user": re.UserLogin, + }).WithError(err).Error("creating raffle entry") + return errors.Wrap( + r.SendEvent(raffleMessageEventEntryFailed, raffleEventFields), + "sending entry-failed chat message", + ) + } + + return errors.Wrap( + r.SendEvent(raffleMessageEventEntry, raffleEventFields), + "sending entry chat message", + ) +} diff --git a/internal/apimodules/raffle/pick.go b/internal/apimodules/raffle/pick.go new file mode 100644 index 0000000..7c360e7 --- /dev/null +++ b/internal/apimodules/raffle/pick.go @@ -0,0 +1,62 @@ +package raffle + +import ( + "crypto/rand" + "encoding/binary" + mathRand "math/rand" + + "github.com/pkg/errors" +) + +type ( + cryptRandSrc struct{} +) + +var errNoCandidatesLeft = errors.New("no candidates left") + +func pickWinnerFromRaffle(r raffle) (winner raffleEntry, err error) { + var maxScore float64 + for _, re := range r.Entries { + if re.WasPicked { + // We skip previously picked winners and pretend they + // don't exist + continue + } + + maxScore += re.Multiplier + } + + if maxScore == 0 { + return winner, errNoCandidatesLeft + } + + winnerPoint := mathRand.New(cryptRandSrc{}).Float64() * maxScore //#nosec:G404 - RNG is using a secure source + + for i := range r.Entries { + re := r.Entries[i] + + if re.WasPicked { + // We skip previously picked winners and pretend they + // don't exist + continue + } + + winnerPoint -= re.Multiplier + if winnerPoint < 0 { + winner = re + break + } + } + + return winner, nil +} + +func (cryptRandSrc) Int63() int64 { + var b [8]byte + rand.Read(b[:]) + // mask off sign bit to ensure positive number + return int64(binary.LittleEndian.Uint64(b[:]) & (1<<63 - 1)) +} + +// We're using a non-seedable source +func (cryptRandSrc) Seed(int64) {} diff --git a/internal/apimodules/raffle/pick_test.go b/internal/apimodules/raffle/pick_test.go new file mode 100644 index 0000000..cfaa118 --- /dev/null +++ b/internal/apimodules/raffle/pick_test.go @@ -0,0 +1,83 @@ +package raffle + +import ( + "math/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testGenerateRaffe() raffle { + r := raffle{ + MultiFollower: 1.1, + MultiSubscriber: 1.2, + MultiVIP: 1.5, + + Entries: make([]raffleEntry, 0, 837), + } + + // Now lets generate 132 non-followers taking part + for i := 0; i < 132; i++ { + r.Entries = append(r.Entries, raffleEntry{ID: uint64(i), Multiplier: 1}) + } + + // Now lets generate 500 followers taking part + for i := 0; i < 500; i++ { + r.Entries = append(r.Entries, raffleEntry{ID: 10000 + uint64(i), Multiplier: r.MultiFollower}) + } + + // Now lets generate 200 subscribers taking part + for i := 0; i < 200; i++ { + r.Entries = append(r.Entries, raffleEntry{ID: 20000 + uint64(i), Multiplier: r.MultiSubscriber}) + } + + // Now lets generate 5 VIPs taking part + for i := 0; i < 5; i++ { + r.Entries = append(r.Entries, raffleEntry{ID: 30000 + uint64(i), Multiplier: r.MultiVIP}) + } + + // They didn't join in order so lets shuffle them + rand.Shuffle(len(r.Entries), func(i, j int) { r.Entries[i], r.Entries[j] = r.Entries[j], r.Entries[i] }) + + return r +} + +func BenchmarkPickWinnerFromRaffle(b *testing.B) { + tData := testGenerateRaffe() + b.Run("pick", func(b *testing.B) { + for i := 0; i < b.N; i++ { + pickWinnerFromRaffle(tData) + } + }) +} + +func TestPickWinnerFromRaffle(t *testing.T) { + var ( + winners []uint64 + tData = testGenerateRaffe() + ) + + for i := 0; i < 5; i++ { + w, err := pickWinnerFromRaffle(tData) + require.NoError(t, err, "picking winner") + winners = append(winners, w.ID) + } + + t.Logf("winners: %v", winners) +} + +func TestPickWinnerFromRaffleSpecial(t *testing.T) { + r := raffle{} + _, err := pickWinnerFromRaffle(r) + assert.ErrorIs(t, errNoCandidatesLeft, err, "picking from 0 paricipants") + + r.Entries = append(r.Entries, raffleEntry{ID: 1, Multiplier: 1.0}) + winner, err := pickWinnerFromRaffle(r) + assert.NoError(t, err, "picking from set of 1") + assert.Equal(t, uint64(1), winner.ID, "expect the right winner") + + r.Entries[0].WasPicked = true + _, err = pickWinnerFromRaffle(r) + assert.ErrorIs(t, errNoCandidatesLeft, err, "picking from 1 paricipant, which already won") +} diff --git a/internal/apimodules/raffle/raffle.go b/internal/apimodules/raffle/raffle.go new file mode 100644 index 0000000..9903ab0 --- /dev/null +++ b/internal/apimodules/raffle/raffle.go @@ -0,0 +1,70 @@ +// Package raffle contains the backend and API implementation as well +// as the chat listeners for chat-raffles +package raffle + +import ( + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/pkg/database" + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +const actorName = "raffle" + +var ( + db database.Connector + dbc *dbClient + formatMessage plugins.MsgFormatter + frontendNotify func(string) + send plugins.SendMessageFunc + tcGetter func(string) (*twitch.Client, error) +) + +func Register(args plugins.RegistrationArguments) (err error) { + db = args.GetDatabaseConnector() + if err := db.DB().AutoMigrate(&raffle{}, &raffleEntry{}); err != nil { + return errors.Wrap(err, "applying schema migration") + } + + dbc = newDBClient(db) + if err = dbc.RefreshActiveRaffles(); err != nil { + return errors.Wrap(err, "refreshing active raffle cache") + } + if err = dbc.RefreshSpeakUp(); err != nil { + return errors.Wrap(err, "refreshing active speak-ups") + } + + formatMessage = args.FormatMessage + frontendNotify = args.FrontendNotify + send = args.SendMessage + tcGetter = args.GetTwitchClientForChannel + + if err = registerAPI(args); err != nil { + return errors.Wrap(err, "registering API") + } + + if _, err := args.RegisterCron("@every 10s", func() { + for name, fn := range map[string]func() error{ + "close": dbc.AutoCloseExpired, + "start": dbc.AutoStart, + "send_reminders": dbc.AutoSendReminders, + } { + if err := fn(); err != nil { + logrus.WithFields(logrus.Fields{ + "actor": actorName, + "cron": name, + }).WithError(err).Error("executing cron action") + } + } + }); err != nil { + return errors.Wrap(err, "registering cron") + } + + if err := args.RegisterRawMessageHandler(rawMessageHandler); err != nil { + return errors.Wrap(err, "registering raw message handler") + } + + return nil +} diff --git a/main.go b/main.go index 321e7d0..3458099 100644 --- a/main.go +++ b/main.go @@ -144,7 +144,7 @@ func main() { FallbackToken: cfg.TwitchToken, TokenUpdateHook: func() { // make frontend reload its state as of token change - frontendReloadHooks.Ping() + frontendNotifyHooks.Ping(frontendNotifyTypeReload) }, }); err != nil { if !errors.Is(err, access.ErrChannelNotAuthorized) { diff --git a/package-lock.json b/package-lock.json index dbe2930..7edbf72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,11 +4,10 @@ "requires": true, "packages": { "": { - "name": "twitch-bot", "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.3.0", - "@fortawesome/free-brands-svg-icons": "^6.3.0", - "@fortawesome/free-solid-svg-icons": "^6.3.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-brands-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^2.0.10", "axios": "^1.3.4", "bootstrap": "^4.6.2", @@ -896,45 +895,45 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", - "integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", + "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==", "hasInstallScript": true, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz", - "integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", + "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz", - "integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", + "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz", - "integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", + "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" }, "engines": { "node": ">=6" @@ -4940,32 +4939,32 @@ "dev": true }, "@fortawesome/fontawesome-common-types": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", - "integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", + "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==" }, "@fortawesome/fontawesome-svg-core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz", - "integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", + "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", "requires": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" } }, "@fortawesome/free-brands-svg-icons": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz", - "integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", + "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", "requires": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" } }, "@fortawesome/free-solid-svg-icons": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz", - "integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", + "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", "requires": { - "@fortawesome/fontawesome-common-types": "6.3.0" + "@fortawesome/fontawesome-common-types": "6.4.0" } }, "@fortawesome/vue-fontawesome": { diff --git a/package.json b/package.json index b855e41..d71d303 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "vue-template-compiler": "^2.7.14" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.3.0", - "@fortawesome/free-brands-svg-icons": "^6.3.0", - "@fortawesome/free-solid-svg-icons": "^6.3.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-brands-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^2.0.10", "axios": "^1.3.4", "bootstrap": "^4.6.2", diff --git a/pkg/database/connector.go b/pkg/database/connector.go index c95f1c1..ece29a3 100644 --- a/pkg/database/connector.go +++ b/pkg/database/connector.go @@ -62,7 +62,8 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) { } db, err := gorm.Open(innerDB, &gorm.Config{ - Logger: logger.New(newLogrusLogWriterWithLevel(logrus.TraceLevel, driverName), logger.Config{}), + DisableForeignKeyConstraintWhenMigrating: true, + Logger: logger.New(newLogrusLogWriterWithLevel(logrus.TraceLevel, driverName), logger.Config{}), }) if err != nil { return nil, errors.Wrap(err, "connecting database") diff --git a/plugins/interface.go b/plugins/interface.go index 2b78092..9c293f3 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -59,6 +59,8 @@ type ( CreateEvent EventHandlerFunc // FormatMessage is a method to convert templates into strings using internally known variables / configs FormatMessage MsgFormatter + // FrontendNotify is a way to send a notification to the frontend + FrontendNotify func(string) // GetDatabaseConnector returns an active database.Connector to access the backend storage database GetDatabaseConnector func() database.Connector // GetLogger returns a sirupsen log.Entry pre-configured with the module name diff --git a/plugins_core.go b/plugins_core.go index e4c1a93..bdb2416 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -38,6 +38,7 @@ import ( "github.com/Luzifer/twitch-bot/v3/internal/apimodules/customevent" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/msgformat" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/overlays" + "github.com/Luzifer/twitch-bot/v3/internal/apimodules/raffle" "github.com/Luzifer/twitch-bot/v3/internal/service/access" "github.com/Luzifer/twitch-bot/v3/internal/template/api" "github.com/Luzifer/twitch-bot/v3/internal/template/numeric" @@ -91,6 +92,7 @@ var ( customevent.Register, msgformat.Register, overlays.Register, + raffle.Register, } knownModules []string ) @@ -117,7 +119,7 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error { var hdl http.Handler = route.HandlerFunc switch { case route.RequiresEditorsAuth: - hdl = botEditorAuthMiddleware(hdl) + hdl = writeAuthMiddleware(hdl, moduleConfigEditor) case route.RequiresWriteAuth: hdl = writeAuthMiddleware(hdl, route.Module) } @@ -145,6 +147,7 @@ func getRegistrationArguments() plugins.RegistrationArguments { return nil }, FormatMessage: formatMessage, + FrontendNotify: func(mt string) { frontendNotifyHooks.Ping(mt) }, GetDatabaseConnector: func() database.Connector { return db }, GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) }, GetTwitchClient: func() *twitch.Client { return twitchClient }, diff --git a/src/app.vue b/src/app.vue index 63bc8fe..c859531 100644 --- a/src/app.vue +++ b/src/app.vue @@ -52,6 +52,16 @@ /> Rules + + + Raffle + @@ -295,8 +305,8 @@ export default { console.debug(`[notify] Socket message received type=${msg.msg_type}`) this.configNotifyBackoff = 100 // We've received a message, reset backoff - if (msg.msg_type === constants.NOTIFY_CONFIG_RELOAD) { - this.$bus.$emit(constants.NOTIFY_CONFIG_RELOAD) + if (msg.msg_type !== 'ping') { + this.$bus.$emit(msg.msg_type) } } this.configNotifySocket.onclose = evt => { diff --git a/src/automessages.vue b/src/automessages.vue index 55662ba..bebe96d 100644 --- a/src/automessages.vue +++ b/src/automessages.vue @@ -260,7 +260,6 @@ import * as constants from './const.js' import axios from 'axios' import TemplateEditor from './tplEditor.vue' -import Vue from 'vue' export default { components: { TemplateEditor }, @@ -375,7 +374,7 @@ export default { }, editAutoMessage(msg) { - Vue.set(this.models, 'autoMessage', { + this.$set(this.models, 'autoMessage', { ...msg, sendMode: msg.cron ? 'cron' : 'lines', }) @@ -395,7 +394,7 @@ export default { }, newAutoMessage() { - Vue.set(this.models, 'autoMessage', {}) + this.$set(this.models, 'autoMessage', {}) this.templateValid = {} this.showAutoMessageEditModal = true }, @@ -429,7 +428,7 @@ export default { }, updateTemplateValid(id, valid) { - Vue.set(this.templateValid, id, valid) + this.$set(this.templateValid, id, valid) }, }, diff --git a/src/generalConfig.vue b/src/generalConfig.vue index d71ac38..fe21e36 100644 --- a/src/generalConfig.vue +++ b/src/generalConfig.vue @@ -412,7 +412,6 @@ import * as constants from './const.js' import axios from 'axios' -import Vue from 'vue' export default { computed: { @@ -645,7 +644,7 @@ export default { this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true) return axios.get(`config-editor/user?user=${user}`, this.$root.axiosOptions) .then(resp => { - Vue.set(this.userProfiles, user, resp.data) + this.$set(this.userProfiles, user, resp.data) this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false) }) .catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err)) @@ -682,7 +681,7 @@ export default { }, newAPIToken() { - Vue.set(this.models, 'apiToken', { + this.$set(this.models, 'apiToken', { modules: [], name: '', }) diff --git a/src/main.js b/src/main.js index 07a0f28..257b357 100644 --- a/src/main.js +++ b/src/main.js @@ -19,16 +19,18 @@ Vue.use(VueRouter) import { library } from '@fortawesome/fontawesome-svg-core' import { fab } from '@fortawesome/free-brands-svg-icons' import { fas } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' library.add(fab, fas) Vue.component('FontAwesomeIcon', FontAwesomeIcon) +Vue.component('FontAwesomeLayers', FontAwesomeLayers) // App import App from './app.vue' import Router from './router.js' Vue.config.devtools = process.env.NODE_ENV === 'dev' +Vue.config.silent = process.env.NODE_ENV !== 'dev' Vue.prototype.$bus = new Vue() @@ -46,6 +48,14 @@ new Vue({ data: { authToken: null, + commonToastOpts: { + appendToast: true, + autoHideDelay: 3000, + bodyClass: 'd-none', + solid: true, + toaster: 'b-toaster-bottom-right', + }, + vars: {}, }, @@ -58,6 +68,34 @@ new Vue({ this.vars = resp.data }) }, + + toastError(message, options = {}) { + this.$bvToast.toast('...', { + ...this.commonToastOpts, + ...options, + noAutoHide: true, + title: message, + variant: 'danger', + }) + }, + + toastInfo(message, options = {}) { + this.$bvToast.toast('...', { + ...this.commonToastOpts, + ...options, + title: message, + variant: 'info', + }) + }, + + toastSuccess(message, options = {}) { + this.$bvToast.toast('...', { + ...this.commonToastOpts, + ...options, + title: message, + variant: 'success', + }) + }, }, mounted() { diff --git a/src/raffle.vue b/src/raffle.vue new file mode 100644 index 0000000..e61f903 --- /dev/null +++ b/src/raffle.vue @@ -0,0 +1,1146 @@ + + + diff --git a/src/router.js b/src/router.js index fa2bd79..60191fe 100644 --- a/src/router.js +++ b/src/router.js @@ -4,6 +4,7 @@ import VueRouter from 'vue-router' import Automessages from './automessages.vue' import GeneralConfig from './generalConfig.vue' +import Raffle from './raffle.vue' import Rules from './rules.vue' const routes = [ @@ -17,6 +18,11 @@ const routes = [ name: 'edit-automessages', path: '/automessages', }, + { + component: Raffle, + name: 'raffle', + path: '/raffle', + }, { component: Rules, name: 'edit-rules', diff --git a/src/rules.vue b/src/rules.vue index bc77898..c7a8216 100644 --- a/src/rules.vue +++ b/src/rules.vue @@ -677,7 +677,6 @@ import * as constants from './const.js' import axios from 'axios' import TemplateEditor from './tplEditor.vue' -import Vue from 'vue' export default { components: { TemplateEditor }, @@ -804,7 +803,7 @@ export default { addAction() { if (!this.models.rule.actions) { - Vue.set(this.models.rule, 'actions', []) + this.$set(this.models.rule, 'actions', []) } this.models.rule.actions.push({ attributes: {}, type: this.models.addAction }) @@ -846,7 +845,7 @@ export default { }, editRule(msg) { - Vue.set(this.models, 'rule', { + this.$set(this.models, 'rule', { ...msg, actions: msg.actions?.map(action => ({ ...action, attributes: action.attributes || {} })) || [], channel_cooldown: this.fixDurationRepresentationToString(msg.channel_cooldown), @@ -975,11 +974,11 @@ export default { tmp[idx] = tmp[idx + direction] tmp[idx + direction] = eltmp - Vue.set(this.models.rule, 'actions', tmp) + this.$set(this.models.rule, 'actions', tmp) }, newRule() { - Vue.set(this.models, 'rule', { match_message__validation: true }) + this.$set(this.models, 'rule', { match_message__validation: true }) this.templateValid = {} this.showRuleEditModal = true }, @@ -1100,7 +1099,7 @@ export default { }, updateTemplateValid(id, valid) { - Vue.set(this.templateValid, id, valid) + this.$set(this.templateValid, id, valid) }, validateActionArgument(idx, key) { @@ -1172,17 +1171,17 @@ export default { validateExceptionRegex() { return this.validateRegex(this.models.addException, false) - .then(res => Vue.set(this.models, 'addException__validation', res)) + .then(res => this.$set(this.models, 'addException__validation', res)) }, validateMatcherRegex() { if (this.models.rule.match_message === '') { - Vue.set(this.models.rule, 'match_message__validation', true) + this.$set(this.models.rule, 'match_message__validation', true) return } return this.validateRegex(this.models.rule.match_message, true) - .then(res => Vue.set(this.models.rule, 'match_message__validation', res)) + .then(res => this.$set(this.models.rule, 'match_message__validation', res)) }, validateRegex(regex, allowEmpty = true) { diff --git a/writeAuth.go b/writeAuth.go index 5780349..b1da649 100644 --- a/writeAuth.go +++ b/writeAuth.go @@ -1,26 +1,45 @@ package main import ( - "encoding/hex" + "crypto/rand" + "encoding/base64" + "fmt" "net/http" "github.com/gofrs/uuid/v3" "github.com/pkg/errors" - log "github.com/sirupsen/logrus" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/argon2" "github.com/Luzifer/go_helpers/v2/str" + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" +) + +const ( + // OWASP recommendations - 2023-07-07 + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s" + argonHashLen = 16 + argonMemory = 46 * 1024 + argonSaltLength = 8 + argonThreads = 1 + argonTime = 1 ) func fillAuthToken(token *configAuthToken) error { token.Token = uuid.Must(uuid.NewV4()).String() - hash, err := bcrypt.GenerateFromPassword([]byte(token.Token), bcrypt.DefaultCost) - if err != nil { - return errors.Wrap(err, "hashing token") + salt := make([]byte, argonSaltLength) + if _, err := rand.Read(salt); err != nil { + return errors.Wrap(err, "reading salt") } - token.Hash = hex.EncodeToString(hash) + token.Hash = fmt.Sprintf( + argonFmt, + argon2.Version, + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)), + ) return nil } @@ -33,24 +52,27 @@ func writeAuthMiddleware(h http.Handler, module string) http.Handler { return } - if err := validateAuthToken(token, module); err != nil { - http.Error(w, "auth not successful", http.StatusForbidden) + for _, fn := range []func() error{ + // First try to validate against internal token management + func() error { return validateAuthToken(token, module) }, + // If not successful validate against Twitch and check for bot-editors + func() error { return validateTwitchBotEditorAuthToken(token) }, + } { + if err := fn(); err != nil { + continue + } + + h.ServeHTTP(w, r) return } - h.ServeHTTP(w, r) + http.Error(w, "auth not successful", http.StatusForbidden) }) } func validateAuthToken(token string, modules ...string) error { for _, auth := range config.AuthTokens { - rawHash, err := hex.DecodeString(auth.Hash) - if err != nil { - log.WithError(err).Error("Invalid token hash found") - continue - } - - if bcrypt.CompareHashAndPassword(rawHash, []byte(token)) != nil { + if auth.validate(token) != nil { continue } @@ -65,3 +87,18 @@ func validateAuthToken(token string, modules ...string) error { return errors.New("no matching token") } + +func validateTwitchBotEditorAuthToken(token string) error { + tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") + + id, user, err := tc.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting authorized user") + } + + if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) { + return errors.New("user is not an bot-edtior") + } + + return nil +}