This commit is contained in:
Knut Ahlers 2024-08-28 16:23:28 +02:00 committed by GitHub
commit 04561be392
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 3937 additions and 9709 deletions

View File

@ -8,7 +8,7 @@
const Module = require('module')
const hacks = [
'babel-eslint',
'@babel/eslint-parser',
'eslint-plugin-vue',
]
@ -34,7 +34,7 @@ module.exports = {
},
extends: [
'plugin:vue/recommended',
'plugin:vue/vue3-recommended',
'eslint:recommended', // https://eslint.org/docs/rules/
],
@ -44,13 +44,14 @@ module.exports = {
parserOptions: {
ecmaVersion: 2020,
parser: '@babel/eslint-parser',
parser: '@typescript-eslint/parser',
requireConfigFile: false,
},
plugins: [
// required to lint *.vue files
'vue',
'@typescript-eslint',
],
reportUnusedDisableDirectives: true,
@ -65,6 +66,7 @@ module.exports = {
'arrow-spacing': ['error', { after: true, before: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'camelcase': ['warn'],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
@ -84,7 +86,7 @@ module.exports = {
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['warn'],
'multiline-comment-style': ['off'],
'newline-per-chained-call': ['error'],
'no-alert': ['error'],
'no-console': ['off'],
@ -134,6 +136,7 @@ module.exports = {
'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'],
'vue/comment-directive': 'off',
'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'],

4
.gitignore vendored
View File

@ -4,7 +4,8 @@ config.yaml
docs/resources/_gen
editor/app.css
editor/app.js
editor/bundle.*
editor/*.ttf
editor/*.woff2
.env
hugo_*
.hugo_build.lock
@ -13,4 +14,5 @@ node_modules
storage.db
storage.db-journal
storage.json.gz
_template.vue
twitch-bot

View File

@ -1,19 +1,14 @@
package main
import (
"context"
"net/http"
"time"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/pkg/errors"
)
const internalTokenAuthCacheExpiry = 5 * time.Minute
func authBackendInternalToken(token string) (modules []string, expiresAt time.Time, err error) {
func authBackendInternalAppToken(token string) (modules []string, expiresAt time.Time, err error) {
for _, auth := range config.AuthTokens {
if auth.validate(token) != nil {
continue
@ -26,39 +21,12 @@ func authBackendInternalToken(token string) (modules []string, expiresAt time.Ti
return nil, time.Time{}, authcache.ErrUnauthorized
}
func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time, err error) {
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
var httpError twitch.HTTPError
id, user, err := tc.GetAuthorizedUser(context.Background())
switch {
case err == nil:
// We got a valid user, continue check below
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
// That user is none of our editors: Deny access
return nil, time.Time{}, authcache.ErrUnauthorized
}
_, _, expiresAt, err = tc.GetTokenInfo(context.Background())
if err != nil {
return nil, time.Time{}, errors.Wrap(err, "getting token expiry")
}
// Editors have full access: Return module "*"
return []string{"*"}, expiresAt, nil
case errors.As(err, &httpError):
// We either got "forbidden" or we got another error
if httpError.Code == http.StatusUnauthorized {
// That token wasn't valid or not a Twitch token: Unauthorized
return nil, time.Time{}, authcache.ErrUnauthorized
}
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
default:
// Something else went wrong
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
func authBackendInternalEditorToken(token string) ([]string, time.Time, error) {
_, _, expiresAt, modules, err := editorTokenService.ValidateLoginToken(token)
if err != nil {
// None of our tokens: Nay.
return nil, time.Time{}, authcache.ErrUnauthorized
}
return modules, expiresAt, nil
}

View File

@ -66,8 +66,11 @@ func writeAuthMiddleware(h http.Handler, module string) http.Handler {
case strings.EqualFold(tokenType, "token"):
// This is perfect: `Authorization: Token tokenhere`
case strings.EqualFold(tokenType, "bearer"):
// This is perfect: `Authorization: Bearer tokenhere`
default:
// That was unexpected: `Authorization: Bearer tokenhere` or similar
// That was unexpected
http.Error(w, "invalid token type", http.StatusForbidden)
return
}

View File

@ -1,21 +1,25 @@
package main
import (
"fmt"
"net/http"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"strings"
)
func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error) {
token := r.Header.Get("Authorization")
if token == "" {
return "", nil, errors.New("no authorization provided")
func getAuthorizationFromRequest(r *http.Request) (string, error) {
_, token, hadPrefix := strings.Cut(r.Header.Get("Authorization"), " ")
if !hadPrefix {
return "", fmt.Errorf("no authorization provided")
}
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
_, user, _, _, err := editorTokenService.ValidateLoginToken(token) //nolint:dogsled // Required at other places
if err != nil {
return "", fmt.Errorf("getting authorized user: %w", err)
}
_, user, err := tc.GetAuthorizedUser(r.Context())
return user, tc, errors.Wrap(err, "getting authorized user")
if user == "" {
user = "API-User"
}
return user, nil
}

View File

@ -1,21 +1,35 @@
import vuePlugin from 'esbuild-vue'
import esbuild from 'esbuild'
import { sassPlugin } from 'esbuild-sass-plugin'
import vuePlugin from 'esbuild-plugin-vue3'
esbuild.build({
const buildOpts = {
assetNames: '[name]-[hash]',
bundle: true,
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
},
entryPoints: ['src/main.js'],
loader: {},
entryPoints: ['src/main.ts'],
legalComments: 'none',
loader: {
'.md': 'text',
'.ttf': 'file',
'.woff2': 'file',
},
minify: true,
outfile: 'editor/app.js',
plugins: [vuePlugin()],
target: [
'chrome87',
'edge87',
'es2020',
'firefox84',
'safari14',
plugins: [
sassPlugin(),
vuePlugin(),
],
})
target: [
'chrome109',
'edge116',
'es2020',
'firefox115',
'safari15',
],
}
export { buildOpts }
esbuild.build(buildOpts)

View File

@ -78,7 +78,7 @@ func registerEditorAutoMessageRoutes() {
}
func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -104,7 +104,7 @@ func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) {
}
func configEditorHandleAutoMessageDelete(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -141,7 +141,7 @@ func configEditorHandleAutoMessagesGet(w http.ResponseWriter, _ *http.Request) {
}
func configEditorHandleAutoMessageUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return

View File

@ -103,7 +103,7 @@ func registerEditorGeneralConfigRoutes() {
}
func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -169,7 +169,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, _ *http.Request) {
}
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -232,7 +232,7 @@ func configEditorHandleGeneralListAuthTokens(w http.ResponseWriter, _ *http.Requ
}
func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return

View File

@ -4,15 +4,26 @@ import (
"encoding/json"
"net/http"
"regexp"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const frontendNotifyTypeReload = "configReload"
type (
configEditorLoginResponse struct {
ExpiresAt time.Time `json:"expiresAt"`
Token string `json:"token"`
User string `json:"user"`
}
)
var frontendNotifyHooks = newHooker()
//nolint:funlen // Just contains a collection of objects
@ -27,6 +38,15 @@ func registerEditorGlobalMethods() {
Path: "/actions",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Exchanges the Twitch token against an internal Bearer token",
HandlerFunc: configEditorGlobalLogin,
Method: http.MethodPost,
Module: moduleConfigEditor,
Name: "Authorize on Config-Editor",
Path: "/login",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Returns all available modules for auth",
HandlerFunc: configEditorGlobalGetModules,
@ -63,6 +83,16 @@ func registerEditorGlobalMethods() {
Path: "/notify-config",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
{
Description: "Takes the authorization token present in the request and returns a new one for the same user",
HandlerFunc: configEditorGlobalRefreshToken,
Method: http.MethodGet,
Module: moduleConfigEditor,
Name: "Refresh Auth-Token",
Path: "/refreshToken",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Validate a cron expression and return the next executions",
HandlerFunc: configEditorGlobalValidateCron,
@ -154,6 +184,70 @@ func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) {
}
}
func configEditorGlobalLogin(w http.ResponseWriter, r *http.Request) {
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, payload.Token, "")
id, user, err := tc.GetAuthorizedUser(r.Context())
if err != nil {
http.Error(w, "access denied", http.StatusUnauthorized)
return
}
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
// That user is none of our editors: Deny access
http.Error(w, "access denied", http.StatusForbidden)
return
}
// Bot-Editors do have unlimited access to all modules: Pass in module `*`
tok, expiresAt, err := editorTokenService.CreateUserToken(id, user, []string{"*"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(configEditorLoginResponse{
ExpiresAt: expiresAt,
Token: tok,
User: user,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func configEditorGlobalRefreshToken(w http.ResponseWriter, r *http.Request) {
tokenType, token, found := strings.Cut(r.Header.Get("Authorization"), " ")
if !found || !strings.EqualFold(tokenType, "bearer") {
http.Error(w, "invalid renew request", http.StatusBadRequest)
}
id, user, _, modules, err := editorTokenService.ValidateLoginToken(token)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
tok, expiresAt, err := editorTokenService.CreateUserToken(id, user, modules)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
if err := json.NewEncoder(w).Encode(configEditorLoginResponse{
ExpiresAt: expiresAt,
Token: tok,
User: user,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {

View File

@ -78,7 +78,7 @@ func registerEditorRulesRoutes() {
}
func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -117,7 +117,7 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
}
func configEditorRulesDelete(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
@ -154,7 +154,7 @@ func configEditorRulesGet(w http.ResponseWriter, _ *http.Request) {
}
func configEditorRulesUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
user, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return

View File

@ -1,151 +0,0 @@
/*
* Hack to automatically load globally installed eslint modules
* on Archlinux systems placed in /usr/lib/node_modules
*
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
*/
const Module = require('module')
const hacks = [
'babel-eslint',
'eslint-plugin-vue',
]
const ModuleFindPath = Module._findPath
Module._findPath = (request, paths, isMain) => {
const r = ModuleFindPath(request, paths, isMain)
if (!r && hacks.includes(request)) {
return require.resolve(`/usr/lib/node_modules/${request}`)
}
return r
}
/*
* ESLint configuration derived as differences from eslint:recommended
* with changes I found useful to ensure code quality and equal formatting
* https://eslint.org/docs/user-guide/configuring
*/
module.exports = {
env: {
browser: true,
node: true,
},
extends: [
'plugin:vue/recommended',
'eslint:recommended', // https://eslint.org/docs/rules/
],
globals: {
process: true,
},
parserOptions: {
ecmaVersion: 2020,
parser: 'babel-eslint',
},
plugins: [
// required to lint *.vue files
'vue',
],
reportUnusedDisableDirectives: true,
root: true,
rules: {
'array-bracket-newline': ['error', { multiline: true }],
'array-bracket-spacing': ['error'],
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': ['error', { after: true, before: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'camelcase': ['error'],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
'curly': ['error'],
'default-case-last': ['error'],
'default-param-last': ['error'],
'dot-location': ['error', 'property'],
'dot-notation': ['error'],
'eol-last': ['error', 'always'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'func-call-spacing': ['error', 'never'],
'function-paren-newline': ['error', 'multiline'],
'generator-star-spacing': ['off'], // allow async-await
'implicit-arrow-linebreak': ['error'],
'indent': ['error', 2],
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['warn'],
'newline-per-chained-call': ['error'],
'no-alert': ['error'],
'no-console': ['off'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
'no-duplicate-imports': ['error'],
'no-else-return': ['error'],
'no-empty-function': ['error'],
'no-extra-parens': ['error'],
'no-implicit-coercion': ['error'],
'no-lonely-if': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
'no-promise-executor-return': ['error'],
'no-return-assign': ['error'],
'no-script-url': ['error'],
'no-template-curly-in-string': ['error'],
'no-trailing-spaces': ['error'],
'no-unneeded-ternary': ['error'],
'no-unreachable-loop': ['error'],
'no-unsafe-optional-chaining': ['error'],
'no-useless-return': ['error'],
'no-var': ['error'],
'no-warning-comments': ['error'],
'no-whitespace-before-property': ['error'],
'object-curly-newline': ['error', { consistent: true }],
'object-curly-spacing': ['error', 'always'],
'object-shorthand': ['error'],
'padded-blocks': ['error', 'never'],
'prefer-arrow-callback': ['error'],
'prefer-const': ['error'],
'prefer-object-spread': ['error'],
'prefer-rest-params': ['error'],
'prefer-template': ['error'],
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
'require-atomic-updates': ['error'],
'require-await': ['error'],
'semi': ['error', 'never'],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', 'never'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': ['error'],
'space-unary-ops': ['error', { nonwords: false, words: true }],
'spaced-comment': ['warn', 'always'],
'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'],
'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'],
'vue/no-template-target-blank': ['error'],
'vue/no-unused-properties': ['error'],
'vue/no-unused-refs': ['error'],
'vue/no-useless-mustaches': ['error'],
'vue/order-in-components': ['off'], // Collides with sort-keys
'vue/require-name-property': ['error'],
'vue/v-for-delimiter-style': ['error'],
'vue/v-on-function-call': ['error'],
'wrap-iife': ['error'],
'yoda': ['error'],
},
}

View File

@ -1,18 +1,23 @@
<!doctype html>
<html lang="de">
<html lang="en" data-bs-theme="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Twitch-Bot: Config-Editor</title>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="editor/app.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="app" v-cloak></div>
<script src="editor/app.js"></script>
</body>
</html>
</html>

1
go.mod
View File

@ -15,6 +15,7 @@ require (
github.com/go-sql-driver/mysql v1.8.1
github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/itchyny/gojq v0.12.15

35
go.sum
View File

@ -4,8 +4,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc=
github.com/Luzifer/go-openssl/v4 v4.2.2/go.mod h1:+kAwI4NpyYXoWil85gKSCEJNoCQlMeFikEMn2f+5ffc=
github.com/Luzifer/go_helpers/v2 v2.24.0 h1:abACOhsn6a6c6X22jq42mZM1wuOM0Ihfa6yzssrjrOg=
github.com/Luzifer/go_helpers/v2 v2.24.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc=
github.com/Luzifer/go_helpers/v2 v2.25.0 h1:k1J4gd1+BfuokTDoWgcgib9P5mdadjzKEgbtKSVe46k=
github.com/Luzifer/go_helpers/v2 v2.25.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc=
github.com/Luzifer/korvike/functions v1.0.1 h1:9O9PQL7O8J3nBwR4XLyx4COC430QbnvueM+itA2HEto=
@ -34,8 +32,6 @@ github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4r
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -70,8 +66,6 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@ -83,6 +77,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY=
github.com/gofrs/uuid/v3 v3.1.2/go.mod h1:xPwMqoocQ1L5G6pXX5BcE7N5jlzn2o19oqAKxwZW/kI=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@ -101,13 +97,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I=
github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM=
github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
@ -233,8 +226,6 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -248,12 +239,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -274,8 +261,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -283,9 +268,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -293,8 +277,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -333,15 +315,12 @@ gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/cc/v4 v4.21.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag=
modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.2 h1:rg8qg9Rxq7AtL29N0Ar5LyNmH/fQGV0LhphfcTJ5zRQ=
modernc.org/ccgo/v4 v4.17.2/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg=
modernc.org/ccgo/v4 v4.17.3 h1:t2CQci84jnxKw3GGnHvjGKjiNZeZqyQx/023spkk4hU=
modernc.org/ccgo/v4 v4.17.3/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.50.3 h1:rxS4sOeGFzwiuDShZh0agxIRJnan/8vLsBomE50+OT4=
modernc.org/libc v1.50.3/go.mod h1:ZkNjeLQOsIbpUQhrp7H6dQVuxXPsCZKjTb0/nE/jQjU=
modernc.org/libc v1.50.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk=
modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
@ -352,8 +331,6 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8=
modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk=
modernc.org/sqlite v1.29.9 h1:9RhNMklxJs+1596GNuAX+O/6040bvOwacTxuFcRuQow=
modernc.org/sqlite v1.29.9/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=

View File

@ -93,6 +93,12 @@ backendLoop:
ce.ExpiresAt = time.Now().Add(negativeCacheTime)
}
if ce.ExpiresAt.IsZero() {
// Infinite valid token, we should periodically re-check and
// therefore cache for the negativeCacheTime
ce.ExpiresAt = time.Now().Add(negativeCacheTime)
}
s.lock.Lock()
s.cache[s.cacheKey(token)] = &ce
s.lock.Unlock()

View File

@ -0,0 +1,175 @@
// Package editortoken utilizes JWT to create / validate a token for
// the frontend
package editortoken
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"time"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/golang-jwt/jwt/v5"
)
const (
coreMetaSigningKey = "editortoken:signing-key"
tokenValidity = time.Hour
)
type (
claims struct {
Modules []string `json:"modules"`
TwitchUser *twitchUser `json:"twitchUser,omitempty"`
jwt.RegisteredClaims
}
twitchUser struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
// Service manages the permission database
Service struct{ db database.Connector }
)
// New creates a new Service on the given database
func New(db database.Connector) *Service {
return &Service{db}
}
// CreateUserToken packs user-id and user name into a JWT, signs it
// and returns the signed token
func (s Service) CreateUserToken(id, user string, modules []string) (token string, expiresAt time.Time, err error) {
cl := claims{
Modules: modules,
TwitchUser: &twitchUser{
ID: id,
Name: user,
},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "Twitch-Bot",
Subject: id,
Audience: []string{},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenValidity)),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
if token, err = s.createTokenFromClaims(cl); err != nil {
return "", time.Time{}, fmt.Errorf("creating token: %w", err)
}
return token, cl.ExpiresAt.Time, nil
}
// CreateGenericModuleToken creates a non-user-bound token with the
// given modules. Pass in 0 validity to create a non-expiring token.
func (s Service) CreateGenericModuleToken(modules []string, validity time.Duration) (token string, err error) {
cl := claims{
Modules: modules,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "Twitch-Bot",
Audience: []string{},
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
if validity > 0 {
cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(validity))
}
if token, err = s.createTokenFromClaims(cl); err != nil {
return "", fmt.Errorf("creating token: %w", err)
}
return token, nil
}
// ValidateLoginToken takes a token, validates it with the stored
// key and returns the twitch-id and the user-name from the token
func (s Service) ValidateLoginToken(token string) (id, user string, expiresAt time.Time, modules []string, err error) {
var cl claims
tok, err := jwt.ParseWithClaims(token, &cl, func(*jwt.Token) (any, error) {
priv, err := s.getSigningKey()
if err != nil {
return nil, fmt.Errorf("getting private key: %w", err)
}
return priv.Public(), nil
})
if err != nil {
// Something went wrong when parsing & validating
return "", "", expiresAt, nil, fmt.Errorf("validating token: %w", err)
}
if claims, ok := tok.Claims.(*claims); ok {
if claims.ExpiresAt != nil {
expiresAt = claims.ExpiresAt.Time
}
if claims.TwitchUser == nil {
return "", "", expiresAt, claims.Modules, nil
}
// We had no error and the claims are our claims
return claims.TwitchUser.ID, claims.TwitchUser.Name, expiresAt, claims.Modules, nil
}
// We had no error but were not able to convert the claims
return "", "", expiresAt, nil, fmt.Errorf("unknown claims type")
}
func (s Service) createTokenFromClaims(cl claims) (token string, err error) {
tok := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, cl)
priv, err := s.getSigningKey()
if err != nil {
return "", fmt.Errorf("getting signing key: %w", err)
}
if token, err = tok.SignedString(priv); err != nil {
return "", fmt.Errorf("signing token: %w", err)
}
return token, nil
}
func (s Service) getSigningKey() (priv ed25519.PrivateKey, err error) {
err = s.db.ReadEncryptedCoreMeta(coreMetaSigningKey, &priv)
switch {
case err == nil:
// We read the previously generated key
return priv, nil
case errors.Is(err, database.ErrCoreMetaNotFound):
// We don't have a key yet or the key was wiped for some reason,
// we generate a new one which automatically is stored for later
// retrieval.
if priv, err = s.generateSigningKey(); err != nil {
return nil, fmt.Errorf("creating signing key: %w", err)
}
return priv, nil
default:
// Something went wrong, bail.
return nil, fmt.Errorf("reading signing key: %w", err)
}
}
func (s Service) generateSigningKey() (ed25519.PrivateKey, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating key: %w", err)
}
if err = s.db.StoreEncryptedCoreMeta(coreMetaSigningKey, priv); err != nil {
return nil, fmt.Errorf("storing signing key: %w", err)
}
return priv, nil
}

View File

@ -0,0 +1,78 @@
package editortoken
import (
"crypto/ed25519"
"testing"
"time"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateToken(t *testing.T) {
dbc := database.GetTestDatabase(t)
s := New(dbc)
// Fresh database, no key stored, the key should be generated and
// stored
pk1, err := s.getSigningKey()
require.NoError(t, err)
assert.IsType(t, ed25519.PrivateKey{}, pk1)
// Now database should contain key
var dbpk ed25519.PrivateKey
err = dbc.ReadCoreMeta(coreMetaSigningKey, &dbpk)
require.Error(t, err, "Key must not be readable with plain func")
err = dbc.ReadEncryptedCoreMeta(coreMetaSigningKey, &dbpk)
require.NoError(t, err)
// When fetching the key again it should be the same as before
pk2, err := s.getSigningKey()
require.NoError(t, err)
assert.Equal(t, pk1, pk2)
assert.Equal(t, dbpk, pk2)
}
func TestTokenFlow(t *testing.T) {
dbc := database.GetTestDatabase(t)
s := New(dbc)
var (
id = "123456"
user = "example"
)
tok, expiresAt, err := s.CreateUserToken(id, user, []string{"*"})
require.NoError(t, err)
assert.True(t, expiresAt.After(time.Now().Add(tokenValidity-time.Minute)))
tid, tuser, texpiresAt, modules, err := s.ValidateLoginToken(tok)
require.NoError(t, err)
assert.Equal(t, id, tid)
assert.Equal(t, user, tuser)
assert.Equal(t, expiresAt, texpiresAt)
assert.Equal(t, []string{"*"}, modules)
// Generic without expiry
tok, err = s.CreateGenericModuleToken([]string{"test"}, 0)
require.NoError(t, err)
tid, tuser, texpiresAt, modules, err = s.ValidateLoginToken(tok)
require.NoError(t, err)
assert.Equal(t, "", tid)
assert.Equal(t, "", tuser)
assert.Equal(t, time.Time{}, texpiresAt)
assert.Equal(t, []string{"test"}, modules)
// Generic with expiry
tok, err = s.CreateGenericModuleToken([]string{"test"}, time.Minute)
require.NoError(t, err)
tid, tuser, texpiresAt, modules, err = s.ValidateLoginToken(tok)
require.NoError(t, err)
assert.Equal(t, "", tid)
assert.Equal(t, "", tuser)
assert.True(t, time.Now().Add(time.Minute+time.Second).After(texpiresAt))
assert.Equal(t, []string{"test"}, modules)
}

15
main.go
View File

@ -24,6 +24,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
"github.com/Luzifer/twitch-bot/v3/internal/service/editortoken"
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -68,10 +69,11 @@ var (
runID = uuid.Must(uuid.NewV4()).String()
db database.Connector
accessService *access.Service
authService *authcache.Service
timerService *timer.Service
db database.Connector
accessService *access.Service
authService *authcache.Service
editorTokenService *editortoken.Service
timerService *timer.Service
twitchClient *twitch.Client
@ -136,11 +138,12 @@ func main() {
}
authService = authcache.New(
authBackendInternalToken,
authBackendTwitchToken,
authBackendInternalAppToken,
authBackendInternalEditorToken,
)
cronService = cron.New(cron.WithSeconds())
editorTokenService = editortoken.New(db)
if timerService, err = timer.New(db, cronService); err != nil {
log.WithError(err).Fatal("applying timer migration")

6188
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,26 @@
{
"devDependencies": {
"@babel/eslint-parser": "^7.21.3",
"esbuild": "^0.17.13",
"esbuild-vue": "^1.2.2",
"eslint": "^8.36.0",
"eslint-plugin-vue": "^9.10.0",
"vue-template-compiler": "^2.7.14"
},
"dependencies": {
"@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",
"bootstrap-vue": "^2.23.1",
"bootswatch": "^4.6.2",
"codejar": "^3.7.0",
"@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap": "^5.3.3",
"codejar": "^4.2.0",
"marked": "^13.0.0",
"mitt": "^3.0.1",
"prismjs": "^1.29.0",
"vue": "^2.7.16",
"vue-router": "^3.6.5"
"vue": "^3.4.28",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.3"
},
"devDependencies": {
"@babel/eslint-parser": "^7.24.7",
"@types/bootstrap": "^5.2.10",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@vue/tsconfig": "^0.5.1",
"esbuild": "^0.21.5",
"esbuild-plugin-vue3": "^0.4.2",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.26.0",
"typescript": "^5.4.5"
}
}

View File

@ -1,356 +0,0 @@
<template>
<div>
<b-navbar
toggleable="lg"
type="dark"
variant="primary"
class="mb-3"
>
<b-navbar-brand :to="{ name: 'general-config' }">
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'robot']"
/>
Twitch-Bot
</b-navbar-brand>
<b-navbar-toggle target="nav-collapse" />
<b-collapse
id="nav-collapse"
is-nav
>
<b-navbar-nav v-if="isAuthenticated">
<b-nav-item
:to="{ name: 'general-config' }"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'cog']"
/>
General
</b-nav-item>
<b-nav-item
:to="{ name: 'edit-automessages' }"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'envelope-open-text']"
/>
Auto-Messages
</b-nav-item>
<b-nav-item
:to="{ name: 'edit-rules' }"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'inbox']"
/>
Rules
</b-nav-item>
<b-nav-item
:to="{ name: 'raffle' }"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'dice']"
/>
Raffle
</b-nav-item>
</b-navbar-nav>
<b-navbar-nav class="ml-auto">
<b-nav-text
v-if="loadingData"
>
<font-awesome-icon
fixed-width
class="text-warning"
:icon="['fas', 'spinner']"
pulse
/>
</b-nav-text>
<b-nav-text
class="ml-2"
>
<template
v-for="check in status.checks"
>
<font-awesome-icon
:id="`statusCheck${check.name}`"
:key="check.key"
fixed-width
:class="{ 'text-danger': !check.success, 'text-success': check.success }"
:icon="['fas', 'question-circle']"
/>
<b-tooltip
:key="check.key"
:target="`statusCheck${check.name}`"
triggers="hover"
>
{{ check.description }}
</b-tooltip>
</template>
</b-nav-text>
<b-nav-text class="ml-2">
<font-awesome-icon
v-if="configNotifySocketConnected"
id="socketConnectionStatus"
fixed-width
class="mr-1 text-success"
:icon="['fas', 'ethernet']"
/>
<font-awesome-icon
v-else
id="socketConnectionStatus"
fixed-width
class="mr-1 text-danger"
:icon="['fas', 'ethernet']"
/>
<b-tooltip
target="socketConnectionStatus"
triggers="hover"
>
<span v-if="configNotifySocketConnected">Connected to Bot</span>
<span v-else>Disconnected from Bot</span>
</b-tooltip>
</b-nav-text>
<b-nav-text class="ml-2">
<font-awesome-icon
id="botInfo"
fixed-width
class="mr-1"
:icon="['fas', 'info-circle']"
/>
<b-tooltip
target="botInfo"
triggers="hover"
>
Version: <code>{{ $root.vars.Version }}</code>
</b-tooltip>
</b-nav-text>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<b-container>
<!-- Error display -->
<b-row
v-if="error"
class="sticky-row"
>
<b-col>
<b-alert
dismissible
show
variant="danger"
@dismissed="error = null"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'exclamation-circle']"
/>
{{ error }}
</b-alert>
</b-col>
</b-row>
<!-- Working display -->
<b-row
v-if="changePending"
class="sticky-row"
>
<b-col>
<b-alert
show
variant="info"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'spinner']"
pulse
/>
Your change was submitted and is pending, please wait for config to be updated!
</b-alert>
</b-col>
</b-row>
<!-- Logged-out state -->
<b-row
v-if="!isAuthenticated"
>
<b-col
class="text-center"
>
<b-button
:disabled="!$root.vars.TwitchClientID"
:href="authURL"
variant="twitch"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fab', 'twitch']"
/>
Login with Twitch
</b-button>
</b-col>
</b-row>
<!-- Logged-in state -->
<router-view v-else />
</b-container>
</div>
</template>
<script>
import * as constants from './const.js'
import axios from 'axios'
export default {
computed: {
authURL() {
const scopes = []
const params = new URLSearchParams()
params.set('client_id', this.$root.vars.TwitchClientID)
params.set('redirect_uri', window.location.href.split('#')[0].split('?')[0])
params.set('response_type', 'token')
params.set('scope', scopes.join(' '))
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`
},
},
created() {
this.$bus.$on(constants.NOTIFY_CHANGE_PENDING, p => {
this.changePending = Boolean(p)
})
this.$bus.$on(constants.NOTIFY_ERROR, err => {
this.error = err
})
this.$bus.$on(constants.NOTIFY_FETCH_ERROR, err => {
this.handleFetchError(err)
})
this.$bus.$on(constants.NOTIFY_LOADING_DATA, l => {
this.loadingData = Boolean(l)
})
},
data() {
return {
changePending: false,
configNotifyBackoff: 100,
configNotifySocket: null,
configNotifySocketConnected: false,
error: null,
loadingData: false,
status: {},
}
},
methods: {
fetchStatus() {
return axios.get('status/status.json?fail-status=200')
.then(resp => {
this.status = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
handleFetchError(err) {
switch (err.response.status) {
case 403:
this.$root.authToken = null
this.error = 'This user is not authorized for the config editor'
break
case 502:
this.error = 'Looks like the bot is currently not reachable. Please check it is running and refresh the interface.'
break
default:
this.error = `Something went wrong: ${err.response.data} (${err.response.status})`
}
},
openConfigNotifySocket() {
if (this.configNotifySocket) {
this.configNotifySocket.close()
this.configNotifySocket = null
}
const updateBackoffAndReconnect = () => {
this.configNotifyBackoff = Math.min(this.configNotifyBackoff * 1.5, 10000)
window.setTimeout(() => this.openConfigNotifySocket(), this.configNotifyBackoff)
}
this.configNotifySocket = new WebSocket(`${window.location.href.split('#')[0].replace(/^http/, 'ws')}config-editor/notify-config`)
this.configNotifySocket.onopen = () => {
console.debug('[notify] Socket connected')
this.configNotifySocketConnected = true
}
this.configNotifySocket.onmessage = evt => {
const msg = JSON.parse(evt.data)
console.debug(`[notify] Socket message received type=${msg.msg_type}`)
this.configNotifyBackoff = 100 // We've received a message, reset backoff
if (msg.msg_type !== 'ping') {
this.$bus.$emit(msg.msg_type)
}
}
this.configNotifySocket.onclose = evt => {
console.debug(`[notify] Socket was closed wasClean=${evt.wasClean}`)
this.configNotifySocketConnected = false
updateBackoffAndReconnect()
}
},
},
mounted() {
if (this.isAuthenticated) {
this.openConfigNotifySocket()
}
window.setInterval(() => this.fetchStatus(), 10000)
this.fetchStatus()
},
name: 'TwitchBotEditorApp',
props: {
isAuthenticated: {
required: true,
type: Boolean,
},
},
watch: {
isAuthenticated(to) {
if (to && !this.configNotifySocketConnected) {
this.openConfigNotifySocket()
}
},
},
}
</script>
<style>
.btn-twitch {
background-color: #6441a5;
}
.sticky-row {
position: sticky;
top: 0;
}
</style>

View File

@ -1,445 +0,0 @@
<template>
<div>
<b-row>
<b-col>
<b-table
key="autoMessagesTable"
:busy="!autoMessages"
:fields="autoMessageFields"
hover
:items="autoMessages"
striped
>
<template #cell(actions)="data">
<b-button-group size="sm">
<b-button @click="editAutoMessage(data.item)">
<font-awesome-icon
fixed-width
:icon="['fas', 'pen']"
/>
</b-button>
<b-button
variant="danger"
@click="deleteAutoMessage(data.item.uuid)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-button-group>
</template>
<template #cell(channel)="data">
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'hashtag']"
/>
{{ data.value }}
</template>
<template #cell(cron)="data">
<code>{{ data.value }}</code>
</template>
<template #cell(message)="data">
{{ data.value }}<br>
<b-badge
v-if="data.item.disable"
class="mt-1 mr-1"
variant="danger"
>
Disabled
</b-badge>
<b-badge
v-if="data.item.disable_on_template"
class="mt-1 mr-1"
>
Disable on Template
</b-badge>
<b-badge
v-if="data.item.only_on_live"
class="mt-1 mr-1"
>
Only during Stream
</b-badge>
</template>
<template #head(actions)="">
<b-button-group size="sm">
<b-button
variant="success"
@click="newAutoMessage"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'plus']"
/>
</b-button>
</b-button-group>
</template>
</b-table>
</b-col>
</b-row>
<!-- Auto-Message Editor -->
<b-modal
v-if="showAutoMessageEditModal"
hide-header-close
:ok-disabled="!validateAutoMessage"
ok-title="Save"
size="lg"
:visible="showAutoMessageEditModal"
title="Edit Auto-Message"
@hidden="showAutoMessageEditModal=false"
@ok="saveAutoMessage"
>
<b-row>
<b-col cols="8">
<b-form-group
label="Channel"
label-for="formAutoMessageChannel"
>
<b-input-group
prepend="#"
>
<b-form-input
id="formAutoMessageChannel"
v-model="models.autoMessage.channel"
:state="validateAutoMessageChannel"
type="text"
required
/>
</b-input-group>
</b-form-group>
<hr>
<b-form-group
label="Message"
label-for="formAutoMessageMessage"
>
<template-editor
id="formAutoMessageMessage"
v-model="models.autoMessage.message"
:state="models.autoMessage.message"
@valid-template="valid => updateTemplateValid('autoMessage.message', valid)"
/>
<div slot="description">
<font-awesome-icon
fixed-width
class="mr-1 text-success"
:icon="['fas', 'code']"
title="Supports Templating"
/>
Ensure the template result has a length of less than {{ validateAutoMessageMessageLength }} characters (Twitch message size limit)
</div>
</b-form-group>
<b-form-group>
<b-form-checkbox
v-model="models.autoMessage.use_action"
switch
>
Send message as action (<code>/me</code>)
</b-form-checkbox>
</b-form-group>
<hr>
<b-form-group
label="Sending Mode"
label-for="formAutoMessageSendMode"
>
<b-form-select
id="formAutoMessageSendMode"
v-model="models.autoMessage.sendMode"
:options="autoMessageSendModes"
/>
</b-form-group>
<b-form-group
v-if="models.autoMessage.sendMode === 'cron'"
label="Send at"
label-for="formAutoMessageCron"
>
<b-form-input
id="formAutoMessageCron"
v-model="models.autoMessage.cron"
:state="validateAutoMessageCron"
type="text"
/>
<div slot="description">
<code>@every [time]</code> or Cron syntax
</div>
</b-form-group>
<b-form-group
v-if="models.autoMessage.sendMode === 'lines'"
label="Send every"
label-for="formAutoMessageNLines"
>
<b-input-group
append="Lines"
>
<b-form-input
id="formAutoMessageNLines"
v-model="models.autoMessage.message_interval"
type="number"
/>
</b-input-group>
</b-form-group>
<hr>
<b-form-group>
<b-form-checkbox
v-model="models.autoMessage.only_on_live"
switch
>
Send only when channel is live
</b-form-checkbox>
</b-form-group>
<b-form-group>
<b-form-checkbox
v-model="models.autoMessage.disable"
switch
>
Disable Auto-Message entirely
</b-form-checkbox>
</b-form-group>
<b-form-group
label="Disable on Template"
label-for="formAutoMessageDisableOnTemplate"
>
<div slot="description">
<font-awesome-icon
fixed-width
class="mr-1 text-success"
:icon="['fas', 'code']"
title="Supports Templating"
/>
Template expression resulting in <code>true</code> to disable the rule or <code>false</code> to enable it
</div>
<template-editor
id="formAutoMessageDisableOnTemplate"
v-model="models.autoMessage.disable_on_template"
@valid-template="valid => updateTemplateValid('autoMessage.disable_on_template', valid)"
/>
</b-form-group>
</b-col>
<b-col cols="4">
<h6>Getting Help</h6>
<p>
For information about available template functions and variables to use in the <strong>Message</strong> see the <a
href="https://github.com/Luzifer/twitch-bot/wiki#templating"
rel="noopener noreferrer"
target="_blank"
>Templating</a> section of the Wiki.
</p>
<p>
For information about the <strong>Cron</strong> syntax have a look at the <a
href="https://cron.help/"
rel="noopener noreferrer"
target="_blank"
>cron.help</a> site. Aditionally you can use <code>@every [time]</code> syntax. The <code>[time]</code> part is in format <code>1h30m20s</code>. You can leave out every segment but need to specify the unit of every segment. So for example <code>@every 1h</code> or <code>@every 10m</code> would be a valid specification.
</p>
</b-col>
</b-row>
</b-modal>
</div>
</template>
<script>
import * as constants from './const.js'
import axios from 'axios'
import TemplateEditor from './tplEditor.vue'
export default {
components: { TemplateEditor },
computed: {
validateAutoMessage() {
if (!this.models.autoMessage.sendMode) {
return false
}
if (this.models.autoMessage.sendMode === 'cron' && !this.validateAutoMessageCron) {
return false
}
if (this.models.autoMessage.sendMode === 'lines' && (!this.models.autoMessage.message_interval || Number(this.models.autoMessage.message_interval) <= 0)) {
return false
}
if (!this.validateAutoMessageChannel) {
return false
}
if (Object.entries(this.templateValid).filter(e => !e[1]).length > 0) {
return false
}
return true
},
validateAutoMessageChannel() {
return Boolean(this.models.autoMessage.channel?.match(/^[a-zA-Z0-9_]{4,25}$/))
},
validateAutoMessageCron() {
if (this.models.autoMessage.sendMode !== 'cron' && !this.models.autoMessage.cron) {
return true
}
return Boolean(this.models.autoMessage.cron?.match(constants.CRON_VALIDATION))
},
validateAutoMessageMessageLength() {
return this.models.autoMessage.use_action ? 496 : 500
},
},
data() {
return {
autoMessageFields: [
{
class: 'col-1 text-nowrap',
key: 'channel',
sortable: true,
thClass: 'align-middle',
},
{
class: 'col-9',
key: 'message',
sortable: true,
thClass: 'align-middle',
},
{
class: 'col-1 text-nowrap',
key: 'cron',
thClass: 'align-middle',
},
{
class: 'col-1 text-right',
key: 'actions',
label: '',
thClass: 'align-middle',
},
],
autoMessageSendModes: [
{ text: 'Cron', value: 'cron' },
{ text: 'Number of lines', value: 'lines' },
],
autoMessages: [],
models: {
autoMessage: {},
},
showAutoMessageEditModal: false,
templateValid: {},
}
},
methods: {
deleteAutoMessage(uuid) {
this.$bvModal.msgBoxConfirm('Do you really want to delete this message?', {
buttonSize: 'sm',
cancelTitle: 'NO',
centered: true,
okTitle: 'YES',
okVariant: 'danger',
size: 'sm',
title: 'Please Confirm',
})
.then(val => {
if (!val) {
return
}
return axios.delete(`config-editor/auto-messages/${uuid}`, this.$root.axiosOptions)
.then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
})
},
editAutoMessage(msg) {
this.$set(this.models, 'autoMessage', {
...msg,
sendMode: msg.cron ? 'cron' : 'lines',
})
this.templateValid = {}
this.showAutoMessageEditModal = true
},
fetchAutoMessages() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auto-messages', this.$root.axiosOptions)
.then(resp => {
this.autoMessages = resp.data
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
newAutoMessage() {
this.$set(this.models, 'autoMessage', {})
this.templateValid = {}
this.showAutoMessageEditModal = true
},
saveAutoMessage(evt) {
if (!this.validateAutoMessage) {
evt.preventDefault()
return
}
const obj = { ...this.models.autoMessage }
if (this.models.autoMessage.sendMode === 'cron') {
delete obj.message_interval
} else if (this.models.autoMessage.sendMode === 'lines') {
delete obj.cron
obj.message_interval = Number(obj.message_interval) // Enforce this is a number, not a string
}
let promise = null
if (obj.uuid) {
promise = axios.put(`config-editor/auto-messages/${obj.uuid}`, obj, this.$root.axiosOptions)
} else {
promise = axios.post(`config-editor/auto-messages`, obj, this.$root.axiosOptions)
}
promise.then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
updateTemplateValid(id, valid) {
this.$set(this.templateValid, id, valid)
},
},
mounted() {
this.$bus.$on(constants.NOTIFY_CONFIG_RELOAD, () => {
this.fetchAutoMessages()
})
this.fetchAutoMessages()
},
name: 'TwitchBotEditorAppAutomessages',
}
</script>

125
src/components/_headNav.vue Normal file
View File

@ -0,0 +1,125 @@
<template>
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<span class="navbar-brand">
<i class="fas fa-robot fa-fw me-1 text-info" />
Twitch-Bot
</span>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="navbarSupportedContent"
class="collapse navbar-collapse"
>
<ul class="navbar-nav me-auto mb-2 mb-lg-0" />
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li
v-if="!socketConnected"
class="nav-item d-flex align-content-center"
>
<span class="navbar-text me-2">
<i class="fas fa-cloud fa-fw text-warning" />
</span>
</li>
<li
v-if="isLoggedIn"
class="nav-item dropdown"
>
<a
ref="userMenuToggle"
class="nav-link d-flex align-items-center"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<img
class="rounded-circle nav-profile-image"
:src="profileImage"
>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a
class="dropdown-item"
href="#"
@click.prevent="logout"
>{{ $t('nav.signOut') }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
import { Dropdown } from 'bootstrap'
export default defineComponent({
computed: {
profileImage(): string {
return this.$root?.userInfo?.profile_image_url || ''
},
},
data() {
return {
socketConnected: true, // Directly after load it should always be connected
}
},
methods: {
logout() {
this.bus.emit('logout')
},
},
mounted() {
this.bus.on(BusEventTypes.NotifySocketConnected, () => {
this.socketConnected = true
})
this.bus.on(BusEventTypes.NotifySocketDisconnected, () => {
this.socketConnected = false
})
if (this.isLoggedIn) {
new Dropdown(this.$refs.userMenuToggle as Element)
}
},
name: 'TwitchBotEditorHeadNav',
props: {
isLoggedIn: {
required: true,
type: Boolean,
},
},
})
</script>
<style scoped>
.nav-profile-image {
max-width: 24px;
}
.navbar {
z-index: 1000;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<div class="nav flex-grow-1">
<template
v-for="section in navigation"
:key="section.header"
>
<div class="navHeading">
{{ section.header }}
</div>
<RouterLink
v-for="link in section.links"
:key="link.target"
:to="{name: link.target}"
class="nav-link"
>
<i :class="`${link.icon} fa-fw me-1`" />
{{ link.name }}
</RouterLink>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { RouterLink } from 'vue-router'
export default defineComponent({
components: { RouterLink },
data() {
return {
navigation: [
{
header: this.$t('menu.headers.core'),
links: [
{ icon: 'fas fa-chart-area', name: this.$t('menu.dashboard'), target: 'dashboard' },
{ icon: 'fas fa-robot', name: this.$t('menu.botAuth'), target: 'botAuth' },
{ icon: 'fas fa-tv', name: this.$t('menu.channels'), target: 'channels' },
{ icon: 'fas fa-user-gear', name: this.$t('menu.editors'), target: 'editors' },
{ icon: 'fas fa-id-card-clip', name: this.$t('menu.tokens'), target: 'tokens' },
],
},
{
header: this.$t('menu.headers.chatInteraction'),
links: [
{ icon: 'fas fa-envelope-open-text', name: this.$t('menu.autoMessages'), target: 'autoMessagesList' },
{ icon: 'fas fa-inbox', name: this.$t('menu.rules'), target: 'rulesList' },
],
},
{
header: this.$t('menu.headers.modules'),
links: [{ icon: 'fas fa-dice', name: this.$t('menu.raffles'), target: 'rafflesList' }],
},
],
}
},
name: 'TwitchBotEditorSideNav',
})
</script>
<style scoped>
.nav {
flex-direction: column;
flex-wrap: nowrap;
overflow-y: auto;
}
.nav>.nav-link {
align-items: center;
color: inherit;
display: flex;
padding-bottom: 0.75rem;
padding-left: 1.5rem;
padding-top: 0.75rem;
position: relative;
}
.nav>.nav-link.disabled {
color: var(--bs-nav-link-disabled-color);
}
.navHeading {
color: color-mix(in srgb, var(--bs-body-color) 50%, transparent);
font-size: 0.75rem;
font-weight: bold;
padding: 1.75rem 1rem 0.75rem;
text-transform: uppercase;
}
</style>

95
src/components/_toast.vue Normal file
View File

@ -0,0 +1,95 @@
<template>
<div
ref="toast"
:class="classForToast(toast)"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="d-flex">
<div class="toast-body">
{{ toast.text }}
</div>
<button
type="button"
:class="classForCloseButton(toast)"
data-bs-dismiss="toast"
aria-label="Close"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { Toast } from 'bootstrap'
export type ToastContent = {
id: string
autoHide?: boolean
color?: string
delay?: number
text: string
}
export default defineComponent({
data() {
return {
hdl: null as Toast | null,
}
},
emits: ['hidden'],
methods: {
classForCloseButton(toast: ToastContent): string {
const classes = [
'btn-close',
'me-2',
'm-auto',
]
if (toast.color) {
classes.push('btn-close-white')
}
return classes.join(' ')
},
classForToast(toast: ToastContent): string {
const classes = [
'toast',
'align-items-center',
]
if (toast.color) {
classes.push('border-0', `text-bg-${toast.color}`)
}
return classes.join(' ')
},
},
mounted() {
const t: Element = this.$refs.toast as Element
t.addEventListener('hidden.bs.toast', () => this.$emit('hidden'))
this.hdl = new Toast(t, {
autohide: this.toast.autoHide !== false,
delay: this.toast.delay || 5000,
})
this.hdl.show()
},
name: 'TwitchBotEditorToast',
props: {
toast: {
required: true,
type: Object as PropType<ToastContent>,
},
},
})
</script>

View File

@ -0,0 +1,41 @@
<template>
<div class="toast-container bottom-0 end-0 p-3">
<toast
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@hidden="removeToast(toast.id)"
/>
</div>
</template>
<script lang="ts">
import Toast, { type ToastContent } from './_toast.vue'
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
export default defineComponent({
components: { Toast },
data() {
return {
toasts: [] as ToastContent[],
}
},
methods: {
removeToast(id: string) {
this.toasts = this.toasts.filter((t: ToastContent) => t.id !== id)
},
},
mounted() {
this.bus.on(BusEventTypes.Toast, (toast: unknown) => this.toasts.push({
...toast as ToastContent,
id: (toast as ToastContent).id || crypto.randomUUID(),
}))
},
name: 'TwitchBotEditorToaster',
})
</script>

50
src/components/app.vue Normal file
View File

@ -0,0 +1,50 @@
<template>
<div class="h-100 user-select-none">
<head-nav :is-logged-in="true" />
<div class="layout">
<div class="layoutNav bg-body-tertiary d-flex">
<SideNav />
</div>
<div class="layoutContent">
<router-view />
<toaster />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import HeadNav from './_headNav.vue'
import SideNav from './_sideNav.vue'
import Toaster from './_toaster.vue'
</script>
<style scoped>
.layout {
display: flex;
}
.layoutContent {
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 100vh;
min-width: 1;
padding-left: 225px;
padding-top: 56px;
position: relative;
}
.layoutNav {
height: 100vh;
left: 0;
padding-top: 56px;
position: fixed;
right: 0;
top: 0;
width: 225px;
z-index: 900;
}
</style>

140
src/components/botauth.vue Normal file
View File

@ -0,0 +1,140 @@
<template>
<div class="container my-3">
<div class="row justify-content-center">
<div class="col col-9">
<div class="card">
<div class="card-header">
{{ $t('botauth.heading') }}
</div>
<div class="card-body">
<p>{{ $t('botauth.description') }}</p>
<ol>
<li
v-for="msg in $tm('botauth.directives')"
:key="msg"
>
{{ msg }}
</li>
</ol>
<div class="input-group">
<input
type="text"
class="form-control"
:value="authURLs?.update_bot_token || ''"
:disabled="!authURLs?.update_bot_token"
readonly
>
<button
ref="copyBtn"
class="btn btn-primary"
:disabled="!authURLs?.update_bot_token"
@click="copyAuthURL"
>
<i class="fas fa-clipboard fa-fw" />
</button>
</div>
</div>
</div>
</div>
<div class="col col-3">
<div
v-if="botProfile.profile_image_url"
class="card"
>
<div class="card-body text-center">
<p>
<img
:src="botProfile.profile_image_url"
class="img rounded-circle w-50"
>
</p>
<p class="mb-0">
<code>{{ botProfile.display_name }}</code>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
authURLs: {} as any,
botProfile: {} as any,
generalConfig: {} as any,
}
},
methods: {
/**
* Copies auth-url for the bot into clipboard and gives user feedback
* by colorizing copy-button for a short moment
*/
copyAuthURL(): void {
navigator.clipboard.writeText(this.authURLs.update_bot_token)
.then(() => {
const btn = this.$refs.copyBtn as Element
btn.classList.replace('btn-primary', 'btn-success')
window.setTimeout(() => btn.classList.replace('btn-success', 'btn-primary'), 2500)
})
},
/**
* Fetches auth-URLs from the backend
*/
fetchAuthURLs(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/auth-urls')
.then((data: any) => {
this.authURLs = data
})
},
/**
* Fetches the bot profile (including display-name and profile
* image) and stores it locally
*
* @param user Login-name of the user to fetch the profile for
*/
fetchBotProfile(user: string): Promise<void> | undefined {
return this.$root?.fetchJSON(`config-editor/user?user=${user}`)
.then((data: any) => {
this.botProfile = data
})
},
/**
* Fetches the general config object from the backend including the
* authorized bot-name
*/
fetchGeneralConfig(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general')
.then((data: any) => {
this.generalConfig = data
})
.then(() => this.fetchBotProfile(this.generalConfig.bot_name))
},
},
mounted() {
// Reload config after it changed
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig())
// Socket-reconnect could mean we need new auth-urls as the state
// may have changed due to bot-restart
this.bus.on(BusEventTypes.NotifySocketConnected, () => this.fetchAuthURLs())
// Do initial fetches
this.fetchAuthURLs()
this.fetchGeneralConfig()
},
name: 'TwitchBotEditorBotAuth',
})
</script>

View File

@ -0,0 +1,220 @@
<template>
<div class="container my-3">
<div class="row justify-content-center mb-3">
<div class="col-6">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-hashtag fa-fw me-1" />
</span>
<input
v-model="inputAddChannel.text"
type="text"
:class="inputAddChannelClasses"
@keypress.enter="addChannel"
>
<button
class="btn btn-success"
:disabled="!inputAddChannel.valid"
@click="addChannel"
>
<i class="fas fa-plus fa-fw me-1" />
{{ $t('channel.btnAdd') }}
</button>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col">
<table class="table">
<thead>
<tr>
<th>{{ $t("channel.table.colChannel") }}</th>
<th>{{ $t("channel.table.colPermissions") }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="channel in channels"
:key="channel.name"
>
<td class="align-content-center">
<i class="fas fa-hashtag fa-fw me-1" />
{{ channel.name }}
</td>
<td class="align-content-center">
<i
v-if="channel.numScopesGranted === 0"
class="fas fa-triangle-exclamation fa-fw me-1 text-danger"
:title="$t('channel.table.titleNoPermissions')"
/>
<i
v-else-if="channel.numScopesGranted < numExtendedScopes"
class="fas fa-triangle-exclamation fa-fw me-1 text-warning"
:title="$t('channel.table.titlePartialPermissions')"
/>
<i
v-else
class="fas fa-circle-check fa-fw me-1 text-success"
:title="$t('channel.table.titleAllPermissions')"
/>
{{ $t('channel.table.textPermissions', {
avail: numExtendedScopes,
granted: channel.numScopesGranted
}) }}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<RouterLink
:to="{ name:'channelPermissions', params: { channel: channel.name } }"
class="btn btn-secondary"
>
<i class="fas fa-pencil-alt fa-fw" />
</RouterLink>
<button
class="btn btn-danger"
@click="removeChannel(channel.name)"
>
<i class="fas fa-minus fa-fw" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
import { successToast } from '../helpers/toasts'
export default defineComponent({
computed: {
channels(): Array<any> {
return this.generalConfig.channels?.map((name: string) => ({
name,
numScopesGranted: (this.generalConfig.channel_scopes[name] || [])
.filter((scope: string) => Object.keys(this.authURLs.available_extended_scopes).includes(scope))
.length,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name))
},
inputAddChannelClasses(): string {
const classes = ['form-control']
if (this.inputAddChannel.valid) {
classes.push('is-valid')
} else if (this.inputAddChannel.text) {
classes.push('is-invalid')
}
return classes.join(' ')
},
numExtendedScopes(): number {
return Object.keys(this.authURLs.available_extended_scopes || {}).length
},
},
data() {
return {
authURLs: {} as any,
generalConfig: {} as any,
inputAddChannel: {
text: '',
valid: false,
},
}
},
methods: {
/**
* Adds the channel entered into the input field to the list
*/
addChannel(): Promise<void> | undefined {
if (!this.inputAddChannel.valid) {
return
}
const channel = this.inputAddChannel.text
return this.updateGeneralConfig({
...this.generalConfig,
channels: [
...this.generalConfig.channels.filter((chan: string) => chan !== channel),
channel,
],
})
?.then(() => {
this.inputAddChannel.text = ''
this.bus.emit(BusEventTypes.Toast, successToast(this.$t('channel.toastChannelAdded')))
})
},
/**
* Fetches auth-URLs from the backend
*/
fetchAuthURLs(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/auth-urls')
.then((data: any) => {
this.authURLs = data
})
},
/**
* Fetches the general config object from the backend
*/
fetchGeneralConfig(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general')
.then((data: any) => {
this.generalConfig = data
})
},
/**
* Tells backend to remove a channel
*/
removeChannel(channel: string): Promise<void> | undefined {
return this.updateGeneralConfig({
...this.generalConfig,
channels: this.generalConfig.channels.filter((chan: string) => chan !== channel),
})
?.then(() => this.bus.emit(BusEventTypes.Toast, successToast(this.$t('channel.toastChannelRemoved'))))
},
/**
* Writes general config back to backend
*
* @param config Configuration object to write (MUST contain all config)
*/
updateGeneralConfig(config: any): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general', {
body: JSON.stringify(config),
method: 'PUT',
})
},
},
mounted() {
// Reload config after it changed
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig())
// Do initial fetches
this.fetchAuthURLs()
this.fetchGeneralConfig()
},
name: 'TwitchBotEditorChannelOverview',
watch: {
'inputAddChannel.text'(to) {
this.inputAddChannel.valid = to.match(/^[a-zA-Z0-9_]{4,25}$/)
},
},
})
</script>

View File

@ -0,0 +1,201 @@
<template>
<div class="container my-3">
<div class="row justify-content-center mb-3">
<div class="col-8">
<p v-html="$t('channel.permissionStart', { channel })" />
<div
v-for="perm in permissions"
:key="perm.scope"
class="form-check form-switch"
>
<input
:id="`switch${perm.scope}`"
v-model="granted[perm.scope]"
class="form-check-input"
type="checkbox"
role="switch"
>
<label
class="form-check-label"
:for="`switch${perm.scope}`"
>{{ perm.description }}</label>
</div>
<div class="form-check form-switch mt-2">
<input
id="switch_all"
v-model="allPermissions"
class="form-check-input"
type="checkbox"
role="switch"
>
<label
class="form-check-label"
for="switch_all"
>{{ $t('channel.permissionsAll') }}</label>
</div>
<div class="input-group mt-4">
<input
type="text"
class="form-control"
:value="permissionsURL || ''"
:disabled="!permissionsURL"
readonly
>
<button
ref="copyBtn"
class="btn btn-primary"
:disabled="!authURLs?.update_bot_token"
@click="copyAuthURL"
>
<i class="fas fa-clipboard fa-fw" />
</button>
</div>
</div>
<div class="col-4">
<div class="card">
<div class="card-header">
<i class="fas fa-circle-info fa-fw me-1" />
{{ $t('channel.permissionInfoHeader') }}
</div>
<div class="card-body">
<p v-html="$t('channel.permissionIntro')" />
<ul>
<li
v-for="(bpt, idx) in $tm('channel.permissionIntroBullets')"
:key="`idx${idx}`"
>
{{ bpt }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
export default defineComponent({
computed: {
allPermissions: {
get(): boolean {
return this.extendedScopeNames
.filter((scope: string) => !this.granted[scope])
.length === 0
},
set(all: boolean): void {
this.granted = Object.fromEntries(this.extendedScopeNames
.map((scope: string) => [scope, all]))
},
},
extendedScopeNames(): Array<string> {
return Object.entries(this.authURLs.available_extended_scopes || {})
.map(e => e[0])
},
permissions(): Array<any> {
return Object.entries(this.authURLs.available_extended_scopes || {}).map(e => ({
description: e[1],
scope: e[0],
}))
},
permissionsURL(): string {
if (!this.authURLs.update_channel_scopes) {
return ''
}
const scopes = Object.entries(this.granted).filter(e => e[1])
.map(e => e[0])
const u = new URL(this.authURLs.update_channel_scopes)
u.searchParams.set('scope', scopes.join(' '))
return u.toString()
},
},
data() {
return {
authURLs: {} as any,
generalConfig: {} as any,
granted: {} as any,
}
},
methods: {
/**
* Copies auth-url for the bot into clipboard and gives user feedback
* by colorizing copy-button for a short moment
*/
copyAuthURL(): void {
navigator.clipboard.writeText(this.permissionsURL)
.then(() => {
const btn = this.$refs.copyBtn as Element
btn.classList.replace('btn-primary', 'btn-success')
window.setTimeout(() => btn.classList.replace('btn-success', 'btn-primary'), 2500)
})
},
/**
* Fetches auth-URLs from the backend
*/
fetchAuthURLs(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/auth-urls')
.then((data: any) => {
this.authURLs = data
})
},
/**
* Fetches the general config object from the backend
*/
fetchGeneralConfig(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general')
.then((data: any) => {
this.generalConfig = data
})
},
/**
* Loads the granted scopes into the object for easier display
* of the permission switches
*/
loadScopes(): void {
this.granted = Object.fromEntries((this.generalConfig.channel_scopes[this.channel] || []).map((scope: string) => [scope, true]))
},
},
mounted() {
// Reload config after it changed
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig()?.then(() => this.loadScopes()))
// Socket-reconnect could mean we need new auth-urls as the state
// may have changed due to bot-restart
this.bus.on(BusEventTypes.NotifySocketConnected, () => this.fetchAuthURLs())
// Do initial fetches
this.fetchAuthURLs()
this.fetchGeneralConfig()?.then(() => this.loadScopes())
},
name: 'TwitchBotEditorChannelPermissions',
props: {
channel: {
required: true,
type: String,
},
},
watch: {
channel() {
this.loadScopes()
},
},
})
</script>

View File

@ -0,0 +1,56 @@
<template>
<div class="container my-3">
<div class="row justify-content-center">
<!-- Here Number Scheduled Events Panel -->
<div
v-for="component in statusComponents"
:key="component"
class="col col-2"
>
<component
:is="component"
/>
</div>
</div>
<div class="row mt-3">
<div class="col">
<DashboardEventlog />
</div>
<div class="col">
<DashboardChangelog />
</div>
</div>
</div>
</template>
<script lang="ts">
import DashboardActiveRaffles from './dashboard/activeRaffles.vue'
import DashboardBotScopes from './dashboard/scopes.vue'
import DashboardChangelog from './dashboard/changelog.vue'
import DashboardEventlog from './dashboard/eventlog.vue'
import DashboardHealthCheck from './dashboard/healthcheck.vue'
import { defineComponent } from 'vue'
export default defineComponent({
components: {
DashboardActiveRaffles,
DashboardBotScopes,
DashboardChangelog,
DashboardEventlog,
DashboardHealthCheck,
},
data() {
return {
statusComponents: [
'DashboardHealthCheck',
'DashboardBotScopes',
'DashboardActiveRaffles',
],
}
},
name: 'TwitchBotEditorDashboard',
})
</script>

View File

@ -0,0 +1,107 @@
<template>
<div
:class="cardClass"
@click="navigate"
>
<div class="card-body">
<div class="fs-6 text-center">
{{ header }}
</div>
<template v-if="loading">
<div class="fs-1 text-center">
<i class="fa-solid fa-circle-notch fa-spin" />
</div>
</template>
<template v-else>
<div :class="valueClass">
{{ value }}
</div>
</template>
<div
v-if="caption"
class="text-muted text-center"
>
<small>{{ caption }}</small>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { type RouteLocationRaw } from 'vue-router'
export default defineComponent({
computed: {
cardClass(): string {
const classList = ['card']
if (this.clickRoute) {
classList.push('pointer-click')
}
return classList.join(' ')
},
valueClass(): string {
const classList = ['fs-1 text-center']
if (this.valueExtraClass) {
classList.push(this.valueExtraClass)
}
return classList.join(' ')
},
},
methods: {
navigate(): void {
if (!this.clickRoute) {
return
}
this.$router.push(this.clickRoute)
},
},
name: 'DashboardStatusPanel',
props: {
caption: {
default: null,
type: String,
},
clickRoute: {
default: null,
type: {} as PropType<RouteLocationRaw>,
},
header: {
required: true,
type: String,
},
loading: {
default: false,
type: Boolean,
},
value: {
required: true,
type: String,
},
valueExtraClass: {
default: null,
type: String,
},
},
})
</script>
<style scoped>
.pointer-click {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<StatusPanel
:header="$t('dashboard.activeRaffles.header')"
:loading="loading"
:value="value"
:click-route="{name: 'rafflesList'}"
:caption="$t('dashboard.activeRaffles.caption')"
/>
</template>
<script lang="ts">
import BusEventTypes from '../../helpers/busevents'
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
value(): string {
return `${this.activeRaffles}`
},
},
data() {
return {
activeRaffles: 0,
loading: true,
}
},
methods: {
fetchRaffleCount(): void {
this.$root?.fetchJSON('raffle/')
.then((data: any) => {
this.activeRaffles = data.filter((raffle: any) => raffle.status === 'active').length
this.loading = false
})
},
},
mounted() {
// Refresh raffle counts when raffle changed
this.bus.on(BusEventTypes.RaffleChanged, () => this.fetchRaffleCount())
this.fetchRaffleCount()
},
name: 'DashboardBotRaffles',
})
</script>

View File

@ -0,0 +1,53 @@
<template>
<div class="card">
<div class="card-header">
{{ $t('dashboard.changelog.heading') }}
</div>
<div
class="card-body user-select-text"
v-html="changelog"
/>
</div>
</template>
<script lang="ts">
// @ts-ignore - Has an esbuild loader to be loaded as text
import ChangeLog from '../../../History.md'
import { defineComponent } from 'vue'
import { parse as marked } from 'marked'
export default defineComponent({
computed: {
changelog(): string {
const latestVersions = (ChangeLog as string)
.split('\n')
.filter((line: string) => line) // Remove empty lines to fix broken output
.join('\n')
.split('#')
.slice(0, 3) // Last 2 versions (first element is empty)
.join('###')
const parts = [
latestVersions,
'---',
this.$t('dashboard.changelog.fullLink'),
]
return marked(parts.join('\n'), {
async: false,
breaks: false,
extensions: null,
gfm: true,
hooks: null,
pedantic: false,
silent: true,
tokenizer: null,
walkTokens: null,
}) as string
},
},
name: 'DashboardChangelog',
})
</script>

View File

@ -0,0 +1,20 @@
<template>
<div class="card">
<div class="card-header">
{{ $t('dashboard.eventlog.heading') }}
</div>
<div
class="card-body"
>
Coming soon
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'DashboardEventlog',
})
</script>

View File

@ -0,0 +1,62 @@
<template>
<StatusPanel
:header="$t('dashboard.healthCheck.header')"
:loading="!status.checks"
:value="value"
:value-extra-class="valueClass"
:caption="$t('dashboard.healthCheck.caption')"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
nChecks(): Number {
return this.status?.checks?.length || 0
},
nSuccess(): Number {
return this.status?.checks?.filter((check: any) => check?.success).length || 0
},
value(): string {
return `${this.nSuccess} / ${this.nChecks}`
},
valueClass(): string {
return this.nSuccess === this.nChecks ? 'text-success' : 'text-danger'
},
},
data() {
return {
status: {} as any,
}
},
methods: {
fetchStatus(): void {
this.$root?.fetchJSON('status/status.json?fail-status=200')
.then((data: any) => {
this.status = data
})
},
},
mounted() {
this.$root?.registerTicker('dashboardHealthCheck', () => this.fetchStatus(), 30000)
this.fetchStatus()
},
name: 'DashboardHealthCheck',
unmounted() {
this.$root?.unregisterTicker('dashboardHealthCheck')
},
})
</script>

View File

@ -0,0 +1,62 @@
<template>
<StatusPanel
:header="$t('dashboard.botScopes.header')"
:loading="loading"
:value="value"
:value-extra-class="valueClass"
:caption="$t('dashboard.botScopes.caption')"
:click-route="{name:'botAuth'}"
/>
</template>
<script lang="ts">
import BusEventTypes from '../../helpers/busevents'
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
nMissing(): number {
return this.$root?.vars?.DefaultBotScopes
?.filter((scope: string) => !this.botScopes.includes(scope))
.length || 0
},
value(): string {
return `${this.nMissing}`
},
valueClass(): string {
return this.nMissing === 0 ? 'text-success' : 'text-warning'
},
},
data() {
return {
botScopes: [] as string[],
loading: true,
}
},
methods: {
fetchGeneralConfig(): void {
this.$root?.fetchJSON('config-editor/general')
.then((data: any) => {
this.botScopes = data.channel_scopes[data.bot_name] || []
this.loading = false
})
},
},
mounted() {
// Scopes might have changed due to authorization change
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig())
this.fetchGeneralConfig()
},
name: 'DashboardBotScopes',
})
</script>

192
src/components/editors.vue Normal file
View File

@ -0,0 +1,192 @@
<template>
<div class="container my-3">
<div class="row justify-content-center mb-3">
<div class="col-6">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-user fa-fw me-1" />
</span>
<input
v-model="inputAddEditor.text"
type="text"
:class="inputAddEditorClasses"
@keypress.enter="addEditor"
>
<button
class="btn btn-success"
:disabled="!inputAddEditor.valid"
@click="addEditor"
>
<i class="fas fa-plus fa-fw me-1" />
{{ $t('editors.btnAdd') }}
</button>
</div>
</div>
</div>
<div class="row justify-content-center">
<div
v-for="editor in editors"
:key="editor.id"
class="col-2"
>
<div class="card relative">
<div class="card-body text-center">
<p>
<img
:src="editor.profile_image_url"
class="img rounded-circle w-50"
>
</p>
<p class="mb-0">
<code>{{ editor.display_name }}</code>
</p>
</div>
<button
class="btn btn-danger btn-sm editor-delete"
:disabled="!editor.canDelete"
@click="removeEditor(editor)"
>
<i class="fas fa-trash fa-fw" />
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
import { successToast } from '../helpers/toasts'
export default defineComponent({
computed: {
editors(): Array<any> {
return (this.generalConfig.bot_editors || [])
.filter((user: string) => this.profiles[user])
.map((user: string) => ({
...this.profiles[user],
canDelete: this.$root?.userInfo?.id !== this.profiles[user].id,
}))
.sort((a: any, b: any) => a.login.localeCompare(b.login))
},
inputAddEditorClasses(): string {
const classes = ['form-control']
if (this.inputAddEditor.valid) {
classes.push('is-valid')
} else if (this.inputAddEditor.text) {
classes.push('is-invalid')
}
return classes.join(' ')
},
},
data() {
return {
generalConfig: {} as any,
inputAddEditor: {
text: '',
valid: false,
},
profiles: {} as any,
}
},
methods: {
/**
* Adds the editor entered into the input field to the list
*/
addEditor(): Promise<void> | undefined {
if (!this.inputAddEditor.valid) {
return
}
const editor = this.inputAddEditor.text
return this.updateGeneralConfig({
...this.generalConfig,
bot_editors: [
...this.generalConfig.bot_editors.filter((user: string) => user !== editor),
editor,
],
})
?.then(() => {
this.inputAddEditor.text = ''
this.bus.emit(BusEventTypes.Toast, successToast(this.$t('editors.toastEditorAdded')))
})
},
/**
* Fetches the general config object from the backend
*/
fetchGeneralConfig(): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general')
.then((data: any) => {
this.generalConfig = data
data.bot_editors.forEach((editor: string) => this.fetchProfile(editor))
})
},
/**
* Fetches a profile for the given user(-id)
*/
fetchProfile(user: string): Promise<void> | undefined {
return this.$root?.fetchJSON(`config-editor/user?user=${user}`)
.then((data: any) => {
this.profiles[user] = data
})
},
removeEditor(editor: any): Promise<void> | undefined {
return this.updateGeneralConfig({
...this.generalConfig,
bot_editors: this.generalConfig.bot_editors
.filter((user: string) => ![editor.login, editor.id, editor.display_name].includes(user)),
})
?.then(() => {
this.bus.emit(BusEventTypes.Toast, successToast(this.$t('editors.toastEditorRemoved')))
})
},
/**
* Writes general config back to backend
*
* @param config Configuration object to write (MUST contain all config)
*/
updateGeneralConfig(config: any): Promise<void> | undefined {
return this.$root?.fetchJSON('config-editor/general', {
body: JSON.stringify(config),
method: 'PUT',
})
},
},
mounted() {
// Reload config after it changed
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig())
// Do initial fetches
this.fetchGeneralConfig()
},
name: 'TwitchBotEditorBotEditors',
watch: {
'inputAddEditor.text'(to) {
this.inputAddEditor.valid = to.match(/^[a-zA-Z0-9_]{4,25}$/)
},
},
})
</script>
<style scoped>
.editor-delete {
position: absolute;
right: 5px;
top: 5px;
}
</style>

72
src/components/login.vue Normal file
View File

@ -0,0 +1,72 @@
<template>
<div class="h-100">
<head-nav :is-logged-in="false" />
<div class="content d-flex align-items-center justify-content-center">
<button
class="btn btn-twitch"
:disabled="loading"
@click="openAuthURL"
>
<i :class="{'fa-fw me-1': true, 'fab fa-twitch': !loading, 'fas fa-circle-notch fa-spin': loading }" />
Login with Twitch
</button>
</div>
<toaster />
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
import HeadNav from './_headNav.vue'
import Toaster from './_toaster.vue'
export default defineComponent({
components: { HeadNav, Toaster },
computed: {
authURL() {
const scopes: string[] = []
const params = new URLSearchParams()
params.set('client_id', this.$root?.vars?.TwitchClientID || '')
params.set('redirect_uri', window.location.href.split('#')[0].split('?')[0])
params.set('response_type', 'token')
params.set('scope', scopes.join(' '))
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`
},
},
data() {
return {
loading: false,
}
},
methods: {
openAuthURL(): void {
window.location.href = this.authURL
},
},
mounted() {
this.bus.on(BusEventTypes.LoginProcessing as string, (loading: unknown) => {
this.loading = loading as boolean
})
},
name: 'TwitchBotEditorLogin',
})
</script>
<style scoped>
.btn-twitch {
background-color: #6441a5;
}
.content {
height: calc(100vh - 56px);
}
</style>

View File

@ -1,26 +0,0 @@
export const BUILTIN_TEMPLATE_FUNCTIONS = [
'and',
'call',
'html',
'index',
'slice',
'js',
'len',
'not',
'or',
'print',
'printf',
'println',
'urlquery',
]
export const CRON_VALIDATION = /^(?:(?:@every (?:\d+(?:s|m|h))+)|(?:(?:(?:(?:\d+,)+\d+|(?:\d+(?:\/|-)\d+)|\d+|\*|\*\/\d+)(?: |$)){5}))$/
export const NANO = 1000000000
export const NOTIFY_CHANGE_PENDING = 'changePending'
export const NOTIFY_CONFIG_RELOAD = 'configReload'
export const NOTIFY_ERROR = 'error'
export const NOTIFY_FETCH_ERROR = 'fetchError'
export const NOTIFY_LOADING_DATA = 'loadingData'
export const REGEXP_USER = /^[a-z0-9_]{4,25}$/

View File

@ -1,778 +0,0 @@
<template>
<div>
<b-row>
<b-col>
<b-card no-body>
<b-card-header>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'hashtag']"
/>
Channels
</b-card-header>
<b-list-group flush>
<b-list-group-item
v-for="channel in sortedChannels"
:key="channel"
class="d-flex align-items-center align-middle"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'hashtag']"
/>
{{ channel }}
<span class="ml-auto mr-2">
<font-awesome-icon
v-if="!generalConfig.channel_has_token[channel]"
:id="`channelPublicWarn${channel}`"
fixed-width
class="ml-1 text-danger"
:icon="['fas', 'exclamation-triangle']"
/>
<font-awesome-icon
v-else-if="!hasAllExtendedScopes(channel)"
:id="`channelPublicWarn${channel}`"
fixed-width
class="ml-1 text-warning"
:icon="['fas', 'exclamation-triangle']"
/>
<b-tooltip
:target="`channelPublicWarn${channel}`"
triggers="hover"
>
<template v-if="!generalConfig.channel_has_token[channel]">
Bot is not authorized to access Twitch on behalf of this channels owner (tokens are missing).
Click pencil to grant permissions.
</template>
<template v-else>
Channel is missing {{ missingExtendedScopes(channel).length }} extended permissions.
Click pencil to change granted permissions.
</template>
</b-tooltip>
</span>
<b-button-group size="sm">
<b-button
variant="primary"
@click="editChannelPermissions(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'pencil-alt']"
/>
</b-button>
<b-button
variant="danger"
@click="removeChannel(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-button-group>
</b-list-group-item>
<b-list-group-item>
<b-input-group>
<b-form-input
v-model="models.addChannel"
:state="!!validateUserName(models.addChannel)"
@keyup.enter="addChannel"
/>
<b-input-group-append>
<b-button
variant="success"
:disabled="!validateUserName(models.addChannel)"
@click="addChannel"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'plus']"
/>
Add
</b-button>
</b-input-group-append>
</b-input-group>
</b-list-group-item>
</b-list-group>
</b-card>
</b-col>
<b-col>
<b-card
no-body
class="mb-3"
>
<b-card-header>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'users']"
/>
Bot-Editors
</b-card-header>
<b-list-group flush>
<b-list-group-item
v-for="editor in sortedEditors"
:key="editor"
class="d-flex align-items-center align-middle"
>
<b-avatar
class="mr-3"
:src="userProfiles[editor] ? userProfiles[editor].profile_image_url : ''"
/>
<span class="mr-auto">{{ userProfiles[editor] ? userProfiles[editor].display_name : editor }}</span>
<b-button
size="sm"
variant="danger"
@click="removeEditor(editor)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-list-group-item>
<b-list-group-item>
<b-input-group>
<b-form-input
v-model="models.addEditor"
:state="!!validateUserName(models.addEditor)"
@keyup.enter="addEditor"
/>
<b-input-group-append>
<b-button
variant="success"
:disabled="!validateUserName(models.addEditor)"
@click="addEditor"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'plus']"
/>
Add
</b-button>
</b-input-group-append>
</b-input-group>
</b-list-group-item>
</b-list-group>
</b-card>
<b-card
no-body
>
<b-card-header
class="d-flex align-items-center align-middle"
>
<span class="mr-auto">
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'ticket-alt']"
/>
Auth-Tokens
</span>
<b-button-group size="sm">
<b-button
variant="success"
@click="newAPIToken"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'plus']"
/>
</b-button>
</b-button-group>
</b-card-header>
<b-list-group flush>
<b-list-group-item
v-if="createdAPIToken"
variant="success"
>
Token was created, copy it within 30s as you will not see it again:<br>
<code>{{ createdAPIToken.token }}</code>
</b-list-group-item>
<b-list-group-item
v-for="(token, uuid) in apiTokens"
:key="uuid"
class="d-flex align-items-center align-middle"
>
<span class="mr-auto">
{{ token.name }}<br>
<b-badge
v-for="module in token.modules"
:key="module"
class="mr-1"
>{{ module === '*' ? 'ANY' : module }}</b-badge>
</span>
<b-button
size="sm"
variant="danger"
@click="removeAPIToken(uuid)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-list-group-item>
</b-list-group>
</b-card>
</b-col>
<b-col>
<b-card
no-body
class="mb-3"
:border-variant="botConnectionCardVariant"
>
<b-card-header
class="d-flex align-items-center align-middle"
:header-bg-variant="botConnectionCardVariant"
>
<span class="mr-auto">
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'sign-in-alt']"
/>
Bot Connection
</span>
<template v-if="generalConfig.bot_name">
<code
id="botUserName"
>
{{ generalConfig.bot_name }}
<b-tooltip
target="botUserName"
triggers="hover"
>
Twitch Login-Name of the bot user currently authorized
</b-tooltip>
</code>
</template>
<template v-else>
<font-awesome-icon
id="botUserNameDC"
fixed-width
class="mr-1 text-danger"
:icon="['fas', 'unlink']"
/>
<b-tooltip
target="botUserNameDC"
triggers="hover"
>
Bot is not currently authorized!
</b-tooltip>
</template>
</b-card-header>
<b-card-body>
<p>
Here you can manage your bots auth-token: it's required to communicate with Twitch Chat and APIs. The access will be valid as long as you don't change the password or revoke the apps permission in your bot account.
</p>
<ul>
<li>Copy the URL provided below</li>
<li><strong>Open an inkognito tab or different browser you are not logged into Twitch or are logged in with your bot account</strong></li>
<li>Open the copied URL, sign in with the bot account and accept the permissions</li>
<li>You will see a message containing the authorized account. If this account is wrong, just start over, the token will be overwritten.</li>
</ul>
<p
v-if="botMissingScopes > 0"
class="alert alert-warning"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'exclamation-triangle']"
/>
Bot is missing {{ botMissingScopes }} of its required scopes which will cause features not to work properly. Please re-authorize the bot using the URL below.
</p>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="botAuthTokenURL"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.botConnection"
@click="copyAuthURL('botConnection')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-card-body>
</b-card>
</b-col>
</b-row>
<!-- API-Token Editor -->
<b-modal
v-if="showAPITokenEditModal"
hide-header-close
:ok-disabled="!validateAPIToken"
ok-title="Save"
size="md"
:visible="showAPITokenEditModal"
title="New API-Token"
@hidden="showAPITokenEditModal=false"
@ok="saveAPIToken"
>
<b-form-group
label="Name"
label-for="formAPITokenName"
>
<b-form-input
id="formAPITokenName"
v-model="models.apiToken.name"
:state="Boolean(models.apiToken.name)"
type="text"
/>
</b-form-group>
<b-form-group
label="Enabled for Modules"
>
<b-form-checkbox-group
v-model="models.apiToken.modules"
class="mb-3"
:options="availableModules"
text-field="text"
value-field="value"
/>
</b-form-group>
</b-modal>
<!-- Channel Permission Editor -->
<b-modal
v-if="showPermissionEditModal"
hide-footer
size="lg"
title="Edit Permissions for Channel"
:visible="showPermissionEditModal"
@hidden="showPermissionEditModal=false"
>
<b-row>
<b-col>
<p>The bot should be able to&hellip;</p>
<b-form-checkbox-group
id="channelPermissions"
v-model="models.channelPermissions"
:options="extendedPermissions"
multiple
:select-size="extendedPermissions.length"
stacked
switches
/>
<p class="mt-3">
&hellip;on this channel.
</p>
</b-col>
<b-col>
<p>
In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
</p>
<ul>
<li>Select permissions on the left side</li>
<li>Copy the URL provided below</li>
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
<li>The bot will display a message containing the updated account</li>
</ul>
</b-col>
</b-row>
<b-row>
<b-col>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="extendedPermissionsURL"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.channelPermission"
@click="copyAuthURL('channelPermission')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-col>
</b-row>
</b-modal>
</div>
</template>
<script>
import * as constants from './const.js'
import axios from 'axios'
export default {
computed: {
availableModules() {
return [
{ text: 'ANY', value: '*' },
...[...this.modules || []].sort()
.filter(m => m !== 'config-editor')
.map(m => ({ text: m, value: m })),
]
},
botAuthTokenURL() {
if (!this.authURLs || !this.authURLs.update_bot_token) {
return ''
}
let scopes = [...this.$root.vars.DefaultBotScopes]
if (this.generalConfig && this.generalConfig.channel_scopes && this.generalConfig.channel_scopes[this.generalConfig.bot_name]) {
scopes = [
...new Set([
...scopes,
...this.generalConfig.channel_scopes[this.generalConfig.bot_name],
]),
]
}
const u = new URL(this.authURLs.update_bot_token)
u.searchParams.set('scope', scopes.join(' '))
return u.toString()
},
botConnectionCardVariant() {
if (this.$parent.status.overall_status_success) {
return 'secondary'
}
return 'warning'
},
botMissingScopes() {
let missing = 0
if (!this.generalConfig || !this.generalConfig.channel_scopes || !this.generalConfig.bot_name) {
return -1
}
const grantedScopes = [...this.generalConfig.channel_scopes[this.generalConfig.bot_name] || []]
for (const scope of this.$root.vars.DefaultBotScopes) {
if (!grantedScopes.includes(scope)) {
missing++
}
}
return missing
},
extendedPermissions() {
return Object.keys(this.authURLs.available_extended_scopes || {})
.map(v => ({ text: this.authURLs.available_extended_scopes[v], value: v }))
.sort((a, b) => a.value.localeCompare(b.value))
},
extendedPermissionsURL() {
if (!this.authURLs?.update_channel_scopes) {
return null
}
const u = new URL(this.authURLs.update_channel_scopes)
u.searchParams.set('scope', this.models.channelPermissions.join(' '))
return u.toString()
},
sortedChannels() {
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
},
sortedEditors() {
return [...this.generalConfig?.bot_editors || []].sort((a, b) => {
const an = this.userProfiles[a]?.login || a
const bn = this.userProfiles[b]?.login || b
return an.localeCompare(bn)
})
},
validateAPIToken() {
return this.models.apiToken.modules.length > 0 && Boolean(this.models.apiToken.name)
},
},
data() {
return {
apiTokens: {},
authURLs: {},
copyButtonVariant: {
botConnection: 'primary',
channelPermission: 'primary',
},
createdAPIToken: null,
generalConfig: {},
models: {
addChannel: '',
addEditor: '',
apiToken: {},
channelPermissions: [],
},
modules: [],
showAPITokenEditModal: false,
showPermissionEditModal: false,
userProfiles: {},
}
},
methods: {
addChannel() {
if (!this.validateUserName(this.models.addChannel)) {
return
}
this.generalConfig.channels.push(this.models.addChannel.replace(/^#*/, ''))
this.models.addChannel = ''
this.updateGeneralConfig()
},
addEditor() {
if (!this.validateUserName(this.models.addEditor)) {
return
}
this.fetchProfile(this.models.addEditor)
this.generalConfig.bot_editors.push(this.models.addEditor)
this.models.addEditor = ''
this.updateGeneralConfig()
},
copyAuthURL(type) {
let prom = null
let btnField = null
switch (type) {
case 'botConnection':
prom = navigator.clipboard.writeText(this.botAuthTokenURL)
btnField = 'botConnection'
break
case 'channelPermission':
prom = navigator.clipboard.writeText(this.extendedPermissionsURL)
btnField = 'channelPermission'
break
}
return prom
.then(() => {
this.copyButtonVariant[btnField] = 'success'
})
.catch(() => {
this.copyButtonVariant[btnField] = 'danger'
})
.finally(() => {
window.setTimeout(() => {
this.copyButtonVariant[btnField] = 'primary'
}, 2000)
})
},
editChannelPermissions(channel) {
let permissionSet = [...this.generalConfig.channel_scopes[channel] || []]
if (channel === this.generalConfig.bot_name) {
permissionSet = [
...permissionSet,
...this.$root.vars.DefaultBotScopes,
]
}
this.models.channelPermissions = [...new Set(permissionSet)]
this.showPermissionEditModal = true
},
fetchAPITokens() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
.then(resp => {
this.apiTokens = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
fetchAuthURLs() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-urls', this.$root.axiosOptions)
.then(resp => {
this.authURLs = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
fetchGeneralConfig() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/general', this.$root.axiosOptions)
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
.then(resp => {
this.generalConfig = resp.data
const promises = []
for (const editor of this.generalConfig.bot_editors) {
promises.push(this.fetchProfile(editor))
}
return Promise.all(promises)
})
},
fetchModules() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/modules')
.then(resp => {
this.modules = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
fetchProfile(user) {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get(`config-editor/user?user=${user}`, this.$root.axiosOptions)
.then(resp => {
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))
},
hasAllExtendedScopes(channel) {
if (!this.generalConfig.channel_scopes[channel]) {
return false
}
for (const scope in this.authURLs.available_extended_scopes) {
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
return false
}
}
return true
},
missingExtendedScopes(channel) {
if (!this.generalConfig.channel_scopes[channel]) {
return Object.keys(this.authURLs.available_extended_scopes || {})
}
const missing = []
for (const scope in this.authURLs.available_extended_scopes) {
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
missing.push(scope)
}
}
return missing
},
newAPIToken() {
this.$set(this.models, 'apiToken', {
modules: [],
name: '',
})
this.showAPITokenEditModal = true
},
removeAPIToken(uuid) {
axios.delete(`config-editor/auth-tokens/${uuid}`, this.$root.axiosOptions)
.then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
removeChannel(channel) {
this.generalConfig.channels = this.generalConfig.channels
.filter(ch => ch !== channel)
this.updateGeneralConfig()
},
removeEditor(editor) {
this.generalConfig.bot_editors = this.generalConfig.bot_editors
.filter(ed => ed !== editor)
this.updateGeneralConfig()
},
saveAPIToken(evt) {
if (!this.validateAPIToken) {
evt.preventDefault()
return
}
axios.post(`config-editor/auth-tokens`, this.models.apiToken, this.$root.axiosOptions)
.then(resp => {
this.createdAPIToken = resp.data
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
window.setTimeout(() => {
this.createdAPIToken = null
}, 30000)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
updateGeneralConfig() {
axios.put('config-editor/general', this.generalConfig, this.$root.axiosOptions)
.then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
validateUserName(user) {
return user.match(constants.REGEXP_USER)
},
},
mounted() {
this.$bus.$on(constants.NOTIFY_CONFIG_RELOAD, () => {
Promise.all([
this.fetchGeneralConfig(),
this.fetchAPITokens(),
this.fetchAuthURLs(),
]).then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
})
})
Promise.all([
this.fetchGeneralConfig(),
this.fetchAPITokens(),
this.fetchAuthURLs(),
this.fetchModules(),
]).then(() => this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false))
},
name: 'TwitchBotEditorAppGeneralConfig',
}
</script>

45
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,45 @@
/* eslint-disable no-unused-vars */
/* global RequestInit, TimerHandler */
import { Emitter, EventType } from 'mitt'
type CheckAccessFunction = (resp: Response) => Response
type EditorVars = {
DefaultBotScopes: string[]
IRCBadges: string[]
KnownEvents: string[]
TemplateFunctions: string[]
TwitchClientID: string
Version: string
}
type FetchJSONFunction = (path: string, opts: Object = {}) => Promise<any>
type ParseResponseFunction = (resp: Response) => Promise<any>
type TickerRegisterFunction = (id: string, func: TimerHandler, intervalMs: number) => void
type TickerUnregisterFunction = (id: string) => void
type UserInfo = {
display_name: string
id: string
login: string
profile_image_url: string
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
bus: Emitter<Record<EventType, unknown>>
// On the $root
check403: CheckAccessFunction
fetchJSON: FetchJSONFunction
fetchOpts: RequestInit
parseResponseFromJSON: ParseResponseFunction
registerTicker: TickerRegisterFunction
unregisterTicker: TickerUnregisterFunction
userInfo: UserInfo | null
vars: EditorVars | null
}
}
export {} // Important! See note.

17
src/helpers/busevents.ts Normal file
View File

@ -0,0 +1,17 @@
/* eslint-disable no-unused-vars */
enum BusEventTypes {
ChangePending = 'changePending',
ConfigReload = 'configReload',
Error = 'error',
FetchError = 'fetchError',
LoadingData = 'loadingData',
LoginProcessing = 'loginProcessing',
NotifySocketConnected = 'notifySocketConnected',
NotifySocketDisconnected = 'notifySocketDisconnected',
RaffleChanged = 'raffleChanged',
RaffleEntryChanged = 'raffleEntryChanged',
Toast = 'toast',
}
export default BusEventTypes

View File

@ -0,0 +1,53 @@
import BusEventTypes from './busevents'
class ConfigNotifyListener {
private backoff: number = 100
private eventListener: Function
private socket: WebSocket | null = null
constructor(listener: Function) {
this.eventListener = listener
this.connect()
}
private connect(): void {
if (this.socket) {
this.socket.close()
this.socket = null
}
const baseURL = window.location.href.split('#')[0].replace(/^http/, 'ws')
this.socket = new WebSocket(`${baseURL}config-editor/notify-config`)
this.socket.onopen = () => {
console.debug('[notify] Socket connected')
this.eventListener(BusEventTypes.NotifySocketConnected)
}
this.socket.onmessage = evt => {
const msg = JSON.parse(evt.data)
console.debug(`[notify] Socket message received type=${msg.msg_type}`)
this.backoff = 100 // We've received a message, reset backoff
if (msg.msg_type !== 'ping') {
this.eventListener(msg.msg_type)
}
}
this.socket.onclose = evt => {
console.debug(`[notify] Socket was closed wasClean=${evt.wasClean}`)
this.eventListener(BusEventTypes.NotifySocketDisconnected)
this.updateBackoffAndReconnect()
}
}
private updateBackoffAndReconnect(): void {
this.backoff = Math.min(this.backoff * 1.5, 10000)
window.setTimeout(() => this.connect(), this.backoff)
}
}
export default ConfigNotifyListener

15
src/helpers/template.ts Normal file
View File

@ -0,0 +1,15 @@
export const BuiltinTemplateFunctions = [
'and',
'call',
'html',
'index',
'slice',
'js',
'len',
'not',
'or',
'print',
'printf',
'println',
'urlquery',
]

40
src/helpers/toasts.ts Normal file
View File

@ -0,0 +1,40 @@
import { type ToastContent } from '../components/_toast.vue'
/**
* Create the content of an error-toast
*
* @param text The message to display to the user
* @returns The {ToastContent} for usage in `this.bus.emit(BusEventTypes.Toast, errorToast(...))`
*/
const errorToast = (text: string): ToastContent => ({
autoHide: false,
color: 'danger',
id: crypto.randomUUID(),
text,
})
/**
* Create the content of an info-toast
*
* @param text The message to display to the user
* @returns The {ToastContent} for usage in `this.bus.emit(BusEventTypes.Toast, infoToast(...))`
*/
const infoToast = (text: string): ToastContent => ({
color: 'info',
id: crypto.randomUUID(),
text,
})
/**
* Create the content of an success-toast
*
* @param text The message to display to the user
* @returns The {ToastContent} for usage in `this.bus.emit(BusEventTypes.Toast, successToast(...))`
*/
const successToast = (text: string): ToastContent => ({
color: 'success',
id: crypto.randomUUID(),
text,
})
export { errorToast, infoToast, successToast }

16
src/i18n.ts Normal file
View File

@ -0,0 +1,16 @@
import { createI18n } from 'vue-i18n'
import en from './langs/en.json'
const cookieSet = Object.fromEntries(document.cookie.split('; ')
.map(el => el.split('=')
.map(el => decodeURIComponent(el))))
export default createI18n({
fallbackLocale: 'en',
globalInjection: true,
legacy: false,
locale: cookieSet.lang || navigator?.language || 'en',
messages: {
en,
},
})

83
src/langs/en.json Normal file
View File

@ -0,0 +1,83 @@
{
"botauth": {
"description": "Here you can manage your bots auth-token: it's required to communicate with Twitch Chat and APIs. The access will be valid as long as you don't change the password or revoke the apps permission in your bot account.",
"directives": [
"Copy the URL provided below",
"Open an incognito-tab or different browser, you are not logged into Twitch or are logged in with your bot account",
"Open the copied URL, sign in with the bot account and accept the permissions",
"You will see a message containing the authorized account. If this account is wrong, just start over, the token will be overwritten."
],
"heading": "Updating Bot-Authorization"
},
"channel": {
"btnAdd": "Add Channel",
"permissionsAll": "…do all of the above!",
"permissionInfoHeader": "Explanation",
"permissionIntro": "In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!",
"permissionIntroBullets": [
"Select permissions on the left side",
"Copy the URL provided below",
"Pass the URL to the channel owner and tell them to open it with their personal account logged in",
"The bot will display a message containing the updated account"
],
"permissionStart": "For <strong>#{channel}</strong> the bot should be able to&hellip;",
"table": {
"colChannel": "Channel",
"colPermissions": "Permissions",
"textPermissions": "{granted} of {avail} granted",
"titleAllPermissions": "Bot can use all available extra features",
"titleNoPermissions": "Bot can not use features aside of chatting",
"titlePartialPermissions": "Bot can use some extra features"
},
"toastChannelAdded": "Channel added",
"toastChannelRemoved": "Channel removed"
},
"dashboard": {
"activeRaffles": {
"caption": "Active",
"header": "Raffles"
},
"botScopes": {
"caption": "Missing Scopes",
"header": "Bot-Scopes"
},
"changelog": {
"fullLink": "See full changelog in [History.md](https://github.com/Luzifer/twitch-bot/blob/master/History.md) on Github",
"heading": "Changelog"
},
"eventlog": {
"heading": "Eventlog"
},
"healthCheck": {
"caption": "Checks OK",
"header": "Bot-Health"
}
},
"editors": {
"btnAdd": "Add Editor",
"toastEditorAdded": "Editor added",
"toastEditorRemoved": "Editor removed"
},
"errors": {
"loginFailedAccessDenied": "Access denied to this bot instance",
"loginFailedUnexpectedStatus": "Login failed unexpectedly"
},
"menu": {
"headers": {
"chatInteraction": "Chat Interaction",
"core": "Core",
"modules": "Modules"
},
"autoMessages": "Auto-Messages",
"botAuth": "Bot Authorization",
"channels": "Channels",
"dashboard": "Dashboard",
"editors": "Editors",
"raffles": "Raffles",
"rules": "Rules",
"tokens": "Access-Tokens"
},
"nav": {
"signOut": "Sign-Out"
}
}

View File

@ -1,118 +0,0 @@
/* eslint-disable sort-imports */
// Darkly design
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import 'bootswatch/dist/darkly/bootstrap.css'
import axios from 'axios'
// Vue & BootstrapVue
import Vue from 'vue'
import { BootstrapVue } from 'bootstrap-vue'
import VueRouter from 'vue-router'
Vue.use(BootstrapVue)
Vue.use(VueRouter)
// FontAwesome
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, 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()
new Vue({
components: { App },
computed: {
axiosOptions() {
return {
headers: {
authorization: this.authToken,
},
}
},
},
data: {
authToken: null,
commonToastOpts: {
appendToast: true,
autoHideDelay: 3000,
bodyClass: 'd-none',
solid: true,
toaster: 'b-toaster-bottom-right',
},
vars: {},
},
el: '#app',
methods: {
fetchVars() {
return axios.get('editor/vars.json')
.then(resp => {
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() {
this.fetchVars()
const params = new URLSearchParams(window.location.hash.replace(/^[#/]+/, ''))
if (params.has('access_token')) {
this.authToken = params.get('access_token') || null
this.$router.replace({ name: 'general-config' })
}
},
name: 'TwitchBotEditor',
render(h) {
return h(App, { props: { isAuthenticated: Boolean(this.authToken) } })
},
router: Router,
})

217
src/main.ts Normal file
View File

@ -0,0 +1,217 @@
/* eslint-disable sort-imports */
/* global RequestInit, TimerHandler */
import './style.scss' // Internal global styles
import 'bootstrap/dist/css/bootstrap.css' // Bootstrap 5 Styles
import '@fortawesome/fontawesome-free/css/all.css' // All FA free icons
import { createApp, h } from 'vue'
import mitt from 'mitt'
import BusEventTypes from './helpers/busevents'
import ConfigNotifyListener from './helpers/configNotify'
import { errorToast } from './helpers/toasts'
import i18n from './i18n'
import router from './router'
import App from './components/app.vue'
import Login from './components/login.vue'
const app = createApp({
computed: {
fetchOpts(): RequestInit {
return {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
}
},
tokenRenewAt(): Date | null {
if (this.tokenExpiresAt === null) {
// We don't know when it expires, we can't renew
return null
}
// We renew 720sec before expiration (0.8 * 1h)
return new Date(this.tokenExpiresAt.getTime() - 720000)
},
},
data(): Object {
return {
now: new Date(),
tickers: {},
token: '',
tokenExpiresAt: null as Date | null,
tokenUser: '',
userInfo: null as null | {},
vars: {},
}
},
methods: {
/**
* Checks whether the API returned an 403 and in case it did triggers
* a logout and throws the user back into the login screen
*
* @param resp The response to the fetch request
* @returns The Response object from the resp parameter
*/
check403(resp: Response): Response {
if (resp.status === 403) {
/*
* User token is not valid and therefore should be removed
* which essentially triggers a logout
*/
this.logout()
throw new Error('user has been logged out')
}
return resp
},
fetchJSON(path: string, opts: Object = {}): Promise<any> {
return fetch(path, {
...this.fetchOpts,
...opts,
})
.then((resp: Response) => this.parseResponseFromJSON(resp))
},
loadVars(): Promise<void> {
return this.fetchJSON('editor/vars.json')
.then((data: any) => {
this.vars = data
})
},
login(token: string, expiresAt: Date, username: string): void {
this.token = token
this.tokenExpiresAt = expiresAt
this.tokenUser = username
window.localStorage.setItem('twitch-bot-token', JSON.stringify({ expiresAt, token, username }))
// Nuke the Twitch auth-response from the browser history
if (window.location.hash.includes('access_token=')) {
this.$router.replace({ name: 'dashboard' })
}
this.fetchJSON(`config-editor/user?user=${this.tokenUser}`)
.then((data: any) => {
this.userInfo = data
})
},
logout(): void {
window.localStorage.removeItem('twitch-bot-token')
this.token = ''
this.tokenExpiresAt = null
this.tokenUser = ''
},
parseResponseFromJSON(resp: Response): Promise<any> {
this.check403(resp)
if (resp.status === 204) {
// We can't expect content here
return new Promise(resolve => {
resolve({})
})
}
return resp.json()
},
registerTicker(id: string, func: TimerHandler, intervalMs: number): void {
this.unregisterTicker(id)
this.tickers[id] = window.setInterval(func, intervalMs)
},
renewToken(): void {
if (!this.tokenRenewAt || this.tokenRenewAt.getTime() > this.now.getTime()) {
return
}
this.fetchJSON('config-editor/refreshToken')
.then((data: any) => this.login(data.token, new Date(data.expiresAt), data.user))
.catch((err: Error) => {
// Being unable to renew a token is a reason to logout
this.logout()
throw err
})
},
unregisterTicker(id: string): void {
if (this.tickers[id]) {
window.clearInterval(this.tickers[id])
}
},
},
mounted(): void {
this.bus.on('logout', this.logout)
this.$root.registerTicker('updateRootNow', () => {
this.now = new Date()
}, 30000)
this.$root.registerTicker('renewToken', () => this.renewToken(), 60000)
// Start background-listen for config updates
new ConfigNotifyListener((msgType: string) => {
this.bus.emit(msgType)
})
this.loadVars()
const params = new URLSearchParams(window.location.hash.replace(/^[#/]+/, ''))
const authToken = params.get('access_token')
if (authToken) {
this.bus.emit(BusEventTypes.LoginProcessing, true)
fetch('config-editor/login', {
body: JSON.stringify({ token: authToken }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
.then((resp: Response): any => {
if (resp.status !== 200) {
let errorText = this.$t('errors.loginFailedUnexpectedStatus')
if (resp.status === 403) {
errorText = this.$t('errors.loginFailedAccessDenied')
}
this.bus.emit(BusEventTypes.LoginProcessing, false)
this.bus.emit(BusEventTypes.Toast, errorToast(errorText))
throw new Error(`login failed, status=${resp.status}`)
}
return resp.json()
})
.then((data: any) => this.login(data.token, new Date(data.expiresAt), data.user))
} else {
const tokenData = window.localStorage.getItem('twitch-bot-token')
if (tokenData !== null) {
const data = JSON.parse(tokenData)
this.login(data.token, new Date(data.expiresAt), data.username)
}
}
},
name: 'TwitchBotEditor',
render() {
if (this.token) {
return h(App)
}
return h(Login)
},
router,
})
app.config.globalProperties.bus = mitt()
app.use(i18n)
app.use(router)
app.mount('#app')

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +0,0 @@
/* eslint-disable sort-imports */
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 = [
{
component: GeneralConfig,
name: 'general-config',
path: '/',
},
{
component: Automessages,
name: 'edit-automessages',
path: '/automessages',
},
{
component: Raffle,
name: 'raffle',
path: '/raffle',
},
{
component: Rules,
name: 'edit-rules',
path: '/rules',
},
]
export default new VueRouter({
routes,
})

131
src/router.ts Normal file
View File

@ -0,0 +1,131 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
import BotAuth from './components/botauth.vue'
import BotEditors from './components/editors.vue'
import ChannelOverview from './components/channelOverview.vue'
import ChannelPermissions from './components/channelPermissions.vue'
import Dashboard from './components/dashboard.vue'
const routes = [
{
component: Dashboard,
name: 'dashboard',
path: '/',
},
// General settings
{
component: BotAuth,
name: 'botAuth',
path: '/bot-auth',
},
{
children: [
{
component: ChannelOverview,
name: 'channels',
path: '',
},
{
component: ChannelPermissions,
name: 'channelPermissions',
path: ':channel',
props: true,
},
],
path: '/channels',
},
{
component: BotEditors,
name: 'editors',
path: '/editors',
},
{
component: {},
name: 'tokens',
path: '/tokens',
},
// Auto-Messages
{
children: [
{
component: {},
name: 'autoMessagesList',
path: '',
},
{
component: {},
name: 'autoMessageEdit',
path: ':id',
},
{
component: {},
name: 'autoMessageNew',
path: 'new',
},
],
path: '/auto-messages',
},
// Rules
{
children: [
{
component: {},
name: 'rulesList',
path: '',
},
{
component: {},
name: 'rulesEdit',
path: ':id',
},
{
component: {},
name: 'rulesNew',
path: 'new',
},
],
path: '/rules',
},
// Raffles
{
children: [
{
component: {},
name: 'rafflesList',
path: '',
},
{
children: [
{
component: {},
name: 'rafflesEdit',
path: '',
},
{
component: {},
name: 'raffleEntries',
path: 'entries',
},
],
path: ':id',
},
{
component: {},
name: 'rafflesNew',
path: 'new',
},
],
path: '/raffles',
},
] as RouteRecordRaw[]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router

File diff suppressed because it is too large Load Diff

17
src/style.scss Normal file
View File

@ -0,0 +1,17 @@
* {
-webkit-user-drag: none !important;
}
html,
body,
#app {
height: 100%;
}
.nav-link.router-link-active {
font-weight: bold;
}
.user-select-text {
user-select: text !important;
}

View File

@ -1,195 +0,0 @@
<template>
<div>
<div :class="wrapClasses">
<div ref="editor" />
</div>
<div
v-if="!isValid && validationError"
class="d-block invalid-feedback"
>
{{ validationError }}
</div>
</div>
</template>
<script>
import * as constants from './const.js'
import axios from 'axios'
import { CodeJar } from 'codejar/codejar.js'
import Prism from 'prismjs'
import { withLineNumbers } from 'codejar/linenumbers.js'
export default {
computed: {
grammar() {
return {
template: {
inside: {
boolean: /\b(?:true|false)\b/,
comment: /\/\*[\s\S]*\*\//,
function: RegExp(`\\b(?:${[...constants.BUILTIN_TEMPLATE_FUNCTIONS, ...this.$root.vars.TemplateFunctions].join('|')})\\b`),
keyword: /\b(?:if|else|end|range)\b/,
number: /\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,
operator: /\b(?:eq|ne|lt|le|gt|ge)\b/,
string: {
greedy: true,
pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,
},
variable: /(^|\s)\.\w+\b/,
},
pattern: /\{\{.*?\}\}/s,
},
}
},
wrapClasses() {
return {
'form-control': true,
'is-invalid': this.state === false || !this.isValid,
'is-valid': this.state === true && this.isValid,
'template-editor': true,
}
},
},
data() {
return {
emittedCode: '',
isValid: true,
jar: null,
validationError: '',
}
},
methods: {
highlight(editor) {
const code = editor.textContent
editor.innerHTML = Prism.highlight(code, this.grammar, 'template')
},
validateTemplate(template) {
if (template === '') {
this.isValid = true
this.validationError = ''
this.$emit('valid-template', true)
return
}
return axios.put(`config-editor/validate-template?template=${encodeURIComponent(template)}`)
.then(() => {
this.isValid = true
this.validationError = ''
this.$emit('valid-template', true)
})
.catch(resp => {
this.isValid = false
this.validationError = resp.response.data.split(':1:')[1]
this.$emit('valid-template', false)
})
},
},
mounted() {
this.jar = CodeJar(this.$refs.editor, withLineNumbers(this.highlight), {
indentOn: /[{(]$/,
tab: ' '.repeat(2),
})
this.jar.onUpdate(code => {
this.validateTemplate(code)
this.emittedCode = code
this.$emit('input', code)
})
this.jar.updateCode(this.value)
},
name: 'TwitchBotEditorAppTemplateEditor',
props: {
state: {
default: null,
required: false,
type: Boolean,
},
value: {
default: '',
required: false,
type: String,
},
},
watch: {
value(to, from) {
if (to === from || to === this.emittedCode) {
return
}
this.jar.updateCode(to)
},
},
}
</script>
<style>
.template-editor {
color: #444;
font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
font-size: 87.5%;
height: fit-content;
padding: 0;
}
.template-editor .codejar-wrap {
background-color: #fff;
border-radius: 0.25rem;
}
.template-editor .codejar-linenumbers {
padding-right: 0.5em;
text-align: right;
}
.template-editor .codejar-linenumbers div {
padding-bottom: 0.5em;
padding-top: 0.5em;
}
.template-editor .codejar-linenumbers + div {
margin-left: 35px;
padding-bottom: 0.5em;
padding-left: 0.5em !important;
padding-top: 0.5em;
}
.template-editor .token.comment {
color: #7D8B99;
}
.template-editor .token.operator {
color: #5F6364;
}
.template-editor .token.boolean,
.template-editor .token.number {
color: #c92c2c;
}
.template-editor .token.keyword {
color: #e83e8c;
}
.template-editor .token.string {
color: #2f9c0a;
}
.template-editor .token.variable {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.template-editor .token.function {
color: #1990b8;
}
</style>

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"src/**/*"
],
"exclude": [
"src/components/_template.vue"
]
}