From 3e23fed3d616d1e3ea222f4555ea6f7f46fc1971 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 21:38:18 +0000 Subject: [PATCH 01/26] feat: Enable/Disable actions based on rules --- .../tests/enabledExpression/config.yaml | 39 +++++ .../enabledExpression/enabledExpression.mjs | 88 ++++++++++ .../enabledExpression/entities/lights.yaml | 5 + service/internal/api/api_test.go | 162 ++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 integration-tests/tests/enabledExpression/config.yaml create mode 100644 integration-tests/tests/enabledExpression/enabledExpression.mjs create mode 100644 integration-tests/tests/enabledExpression/entities/lights.yaml diff --git a/integration-tests/tests/enabledExpression/config.yaml b/integration-tests/tests/enabledExpression/config.yaml new file mode 100644 index 0000000..66ea639 --- /dev/null +++ b/integration-tests/tests/enabledExpression/config.yaml @@ -0,0 +1,39 @@ +# +# Integration Test Config: EnabledExpression +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: Turn On Light + shell: echo "Turning on light" + icon: light + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on false }}" + + - title: Turn Off Light + shell: echo "Turning off light" + icon: light + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on true }}" + + - title: Always Enabled Action + shell: echo "Always enabled" + icon: check + +entities: + - file: entities/lights.yaml + name: light + +dashboards: + - title: Lights Dashboard + contents: + - title: Light Controls + type: fieldset + entity: light + contents: + - title: Turn On Light + - title: Turn Off Light diff --git a/integration-tests/tests/enabledExpression/enabledExpression.mjs b/integration-tests/tests/enabledExpression/enabledExpression.mjs new file mode 100644 index 0000000..ce15b30 --- /dev/null +++ b/integration-tests/tests/enabledExpression/enabledExpression.mjs @@ -0,0 +1,88 @@ +import { describe, it, before, after } from 'mocha' +import { expect } from 'chai' +import { By, until } from 'selenium-webdriver' +import { + getRootAndWait, + takeScreenshotOnFailure, +} from '../../lib/elements.js' + +describe('config: enabledExpression', function () { + before(async function () { + await runner.start('enabledExpression') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver); + }); + + it('Action with enabledExpression false is disabled', async function() { + await getRootAndWait() + + // Navigate to the Lights Dashboard + await webdriver.get(runner.baseUrl() + '/dashboard/Lights%20Dashboard') + + // Wait for dashboard to load + await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + + // Find action buttons + const actionButtons = await webdriver.findElements(By.css('.action-button button')) + + // Find "Turn On Light" button for "Living Room Light" (powered_on: false, so Turn On should be enabled) + // Find "Turn Off Light" button for "Bedroom Light" (powered_on: true, so Turn Off should be enabled) + let turnOnButton = null + let turnOffButton = null + + for (const btn of actionButtons) { + const title = await btn.getAttribute('title') + if (title && title.includes('Turn On Light') && title.includes('Living Room')) { + turnOnButton = btn + } + if (title && title.includes('Turn Off Light') && title.includes('Bedroom')) { + turnOffButton = btn + } + } + + expect(turnOnButton).to.not.be.null + expect(turnOffButton).to.not.be.null + + // Check that Turn On button is enabled (light is off) + const turnOnDisabled = await turnOnButton.getAttribute('disabled') + expect(turnOnDisabled).to.be.null + + // Check that Turn Off button is enabled (light is on) + const turnOffDisabled = await turnOffButton.getAttribute('disabled') + expect(turnOffDisabled).to.be.null + }) + + it('Action without enabledExpression is always enabled', async function() { + await getRootAndWait() + + // Navigate to actions view + await webdriver.get(runner.baseUrl()) + + // Wait for action buttons + await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + + // Find "Always Enabled Action" button + const actionButtons = await webdriver.findElements(By.css('.action-button button')) + let alwaysEnabledButton = null + + for (const btn of actionButtons) { + const title = await btn.getAttribute('title') + if (title === 'Always Enabled Action') { + alwaysEnabledButton = btn + break + } + } + + expect(alwaysEnabledButton).to.not.be.null + + // Check that it's enabled + const disabled = await alwaysEnabledButton.getAttribute('disabled') + expect(disabled).to.be.null + }) +}) diff --git a/integration-tests/tests/enabledExpression/entities/lights.yaml b/integration-tests/tests/enabledExpression/entities/lights.yaml new file mode 100644 index 0000000..628fcc1 --- /dev/null +++ b/integration-tests/tests/enabledExpression/entities/lights.yaml @@ -0,0 +1,5 @@ +- name: "Living Room Light" + powered_on: false + +- name: "Bedroom Light" + powered_on: true diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 68341ae..9d20559 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -11,6 +11,7 @@ import ( apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/executor" @@ -173,3 +174,164 @@ func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceCli } } } + +func TestEvaluateEnabledExpression(t *testing.T) { + tests := []struct { + name string + expression string + entity *entities.Entity + expectedResult bool + }{ + { + name: "empty expression returns true", + expression: "", + entity: nil, + expectedResult: true, + }, + { + name: "literal true returns true", + expression: "true", + entity: nil, + expectedResult: true, + }, + { + name: "literal True returns true (case insensitive)", + expression: "True", + entity: nil, + expectedResult: true, + }, + { + name: "literal 1 returns true", + expression: "1", + entity: nil, + expectedResult: true, + }, + { + name: "literal false returns false", + expression: "false", + entity: nil, + expectedResult: false, + }, + { + name: "literal 0 returns false", + expression: "0", + entity: nil, + expectedResult: false, + }, + { + name: "empty result returns false", + expression: "{{ .NonExistent }}", + entity: nil, + expectedResult: false, + }, + { + name: "expression with CurrentEntity true", + expression: "{{ eq .CurrentEntity.powered_on true }}", + entity: &entities.Entity{Data: map[string]any{"powered_on": true}}, + expectedResult: true, + }, + { + name: "expression with CurrentEntity false", + expression: "{{ eq .CurrentEntity.powered_on true }}", + entity: &entities.Entity{Data: map[string]any{"powered_on": false}}, + expectedResult: false, + }, + { + name: "expression with CurrentEntity integer 1", + expression: "{{ .CurrentEntity.status }}", + entity: &entities.Entity{Data: map[string]any{"status": 1}}, + expectedResult: true, + }, + { + name: "expression with CurrentEntity integer 0", + expression: "{{ .CurrentEntity.status }}", + entity: &entities.Entity{Data: map[string]any{"status": 0}}, + expectedResult: false, + }, + { + name: "template parse error returns false", + expression: "{{ invalid syntax }}", + entity: nil, + expectedResult: false, + }, + { + name: "template exec error returns false", + expression: "{{ .CurrentEntity.nonexistent }}", + entity: nil, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + action := &config.Action{ + EnabledExpression: tt.expression, + } + result := evaluateEnabledExpression(action, tt.entity) + assert.Equal(t, tt.expectedResult, result, "evaluateEnabledExpression should return expected result") + }) + } +} + +func TestBuildActionWithEnabledExpression(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DefaultPermissions.Exec = true + + action := &config.Action{ + Title: "Test Action", + Shell: "echo test", + EnabledExpression: "{{ eq .CurrentEntity.enabled true }}", + } + cfg.Actions = append(cfg.Actions, action) + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + binding := findBindingByTitle(ex, "Test Action") + assert.NotNil(t, binding, "Binding should be found") + + rr := &DashboardRenderRequest{ + AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "testuser"}, + cfg: cfg, + ex: ex, + } + + testWithEntity(t, binding, rr, true, true, "Action should be executable when entity.enabled is true") + testWithEntity(t, binding, rr, false, false, "Action should not be executable when entity.enabled is false") + + bindingNoExpr := findBindingByTitle(ex, "Test Action No Expression") + if bindingNoExpr == nil { + actionNoExpression := &config.Action{ + Title: "Test Action No Expression", + Shell: "echo test", + } + cfg.Actions = append(cfg.Actions, actionNoExpression) + ex.RebuildActionMap() + bindingNoExpr = findBindingByTitle(ex, "Test Action No Expression") + } + + actionResult := buildAction(bindingNoExpr, rr) + assert.True(t, actionResult.CanExec, "Action without enabledExpression should be executable") +} + +func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBinding { + ex.MapActionIdToBindingLock.RLock() + defer ex.MapActionIdToBindingLock.RUnlock() + + for _, b := range ex.MapActionIdToBinding { + if b.Action.Title == title { + return b + } + } + return nil +} + +func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) { + binding.Entity = &entities.Entity{ + UniqueKey: "test-entity", + Data: map[string]any{"enabled": enabled}, + } + + actionResult := buildAction(binding, rr) + assert.Equal(t, expectedCanExec, actionResult.CanExec, message) +} From 654ed15dde2813a573697444a3381846fa186a2c Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 21:39:46 +0000 Subject: [PATCH 02/26] fix: Add missing enabled expression file --- service/internal/api/apiActions.go | 54 +++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 6046691..b653510 100644 --- a/service/internal/api/apiActions.go +++ b/service/internal/api/apiActions.go @@ -1,6 +1,11 @@ package api import ( + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" acl "github.com/OliveTin/OliveTin/internal/acl" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" @@ -55,14 +60,61 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo return ret } +func evaluateEnabledExpression(action *config.Action, entity *entities.Entity) bool { + if action.EnabledExpression == "" { + return true + } + + result := entities.ParseTemplateWith(action.EnabledExpression, entity) + result = strings.TrimSpace(result) + + if result == "" { + return false + } + + if isTemplateError(result, action) { + return false + } + + return evaluateResultValue(result) +} + +func isTemplateError(result string, action *config.Action) bool { + if !strings.HasPrefix(result, "tpl ") || !strings.Contains(result, "error") { + return false + } + + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "enabledExpression": action.EnabledExpression, + "result": result, + }).Warn("enabledExpression template evaluation failed, treating as disabled") + return true +} + +func evaluateResultValue(result string) bool { + if strings.EqualFold(result, "true") { + return true + } + + if num, err := strconv.Atoi(result); err == nil { + return num != 0 + } + + return false +} + func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action { action := actionBinding.Action + aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) + enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity) + btn := apiv1.Action{ BindingId: actionBinding.ID, Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity), Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity), - CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action), + CanExec: aclCanExec && enabledExprCanExec, PopupOnStart: action.PopupOnStart, Order: int32(actionBinding.ConfigOrder), Timeout: int32(action.Timeout), From f22b3953b1a744fb544f27a9077d79fdb80eb242 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 21:45:55 +0000 Subject: [PATCH 03/26] feat: webhooks support --- service/go.mod | 2 + service/go.sum | 5 + service/internal/config/config.go | 14 ++ service/internal/httpservers/frontend.go | 5 + service/internal/webhooks/auth.go | 129 +++++++++++++++++ service/internal/webhooks/github.go | 164 ++++++++++++++++++++++ service/internal/webhooks/handler.go | 153 +++++++++++++++++++++ service/internal/webhooks/jsonpath.go | 42 ++++++ service/internal/webhooks/matcher.go | 167 +++++++++++++++++++++++ 9 files changed, 681 insertions(+) create mode 100644 service/internal/webhooks/auth.go create mode 100644 service/internal/webhooks/github.go create mode 100644 service/internal/webhooks/handler.go create mode 100644 service/internal/webhooks/jsonpath.go create mode 100644 service/internal/webhooks/matcher.go diff --git a/service/go.mod b/service/go.mod index 132a6ec..3d53c97 100644 --- a/service/go.mod +++ b/service/go.mod @@ -10,6 +10,7 @@ require ( connectrpc.com/connect v1.19.1 github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc/v3 v3.7.0 + github.com/PaesslerAG/jsonpath v0.1.1 github.com/alexedwards/argon2id v1.0.0 github.com/bufbuild/buf v1.61.0 github.com/fsnotify/fsnotify v1.9.0 @@ -55,6 +56,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MicahParks/jwkset v0.11.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PaesslerAG/gval v1.0.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect diff --git a/service/go.sum b/service/go.sum index 8af88a2..9d82fe9 100644 --- a/service/go.sum +++ b/service/go.sum @@ -42,6 +42,11 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3 github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 722e492..55b5b29 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -22,12 +22,14 @@ type Action struct { ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"` ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"` ExecOnCalendarFile string `koanf:"execOnCalendarFile"` + ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"` Triggers []string `koanf:"triggers"` MaxConcurrent int `koanf:"maxConcurrent"` MaxRate []RateSpec `koanf:"maxRate"` Arguments []ActionArgument `koanf:"arguments"` PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + EnabledExpression string `koanf:"enabledExpression"` } // ActionArgument objects appear on Actions. @@ -55,6 +57,18 @@ type RateSpec struct { Duration string `koanf:"duration"` } +// WebhookConfig defines configuration for generic webhook triggers. +type WebhookConfig struct { + Secret string `koanf:"secret"` // Optional: secret for signature verification + AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none" + AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature") + MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers + MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath") + MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters + Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions + Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr") +} + // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type EntityFile struct { diff --git a/service/internal/httpservers/frontend.go b/service/internal/httpservers/frontend.go index cda9cb5..9860650 100644 --- a/service/internal/httpservers/frontend.go +++ b/service/internal/httpservers/frontend.go @@ -19,6 +19,7 @@ import ( "github.com/OliveTin/OliveTin/internal/auth/otoauth2" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" + "github.com/OliveTin/OliveTin/internal/webhooks" log "github.com/sirupsen/logrus" ) @@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { mux.HandleFunc("/readyz", handleReadyz) + webhookHandler := webhooks.NewWebhookHandler(cfg, ex) + mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook) + mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook) + webuiServer := NewWebUIServer(cfg) mux.HandleFunc("/theme.css", webuiServer.generateThemeCss) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go new file mode 100644 index 0000000..5e7f26c --- /dev/null +++ b/service/internal/webhooks/auth.go @@ -0,0 +1,129 @@ +package webhooks + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type AuthVerifier struct { + config config.WebhookConfig +} + +func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier { + return &AuthVerifier{config: cfg} +} + +func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool { + switch v.config.AuthType { + case "hmac-sha256": + return v.verifyHMAC256(r, payload) + case "hmac-sha1": + return v.verifyHMAC1(r, payload) + case "bearer": + return v.verifyBearer(r) + case "basic": + return v.verifyBasic(r) + case "none", "": + return true + default: + log.WithFields(log.Fields{ + "authType": v.config.AuthType, + }).Warnf("Unknown auth type, rejecting") + return false + } +} + +func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA256 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha256=") + + mac := hmac.New(sha256.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA1 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha1=") + + mac := hmac.New(sha1.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyBearer(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Bearer auth requires secret") + return false + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + log.Debugf("Missing or invalid Bearer token") + return false + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + return token == v.config.Secret +} + +func (v *AuthVerifier) verifyBasic(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Basic auth requires secret") + return false + } + + username, password, ok := r.BasicAuth() + if !ok { + log.Debugf("Missing Basic auth header") + return false + } + + parts := strings.SplitN(v.config.Secret, ":", 2) + if len(parts) == 2 { + return username == parts[0] && password == parts[1] + } + + return password == v.config.Secret +} diff --git a/service/internal/webhooks/github.go b/service/internal/webhooks/github.go new file mode 100644 index 0000000..97b3333 --- /dev/null +++ b/service/internal/webhooks/github.go @@ -0,0 +1,164 @@ +package webhooks + +import ( + "github.com/OliveTin/OliveTin/internal/config" +) + +// ApplyGitHubTemplate applies GitHub-specific template configurations +// This allows users to use simple template names instead of configuring everything manually +func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) { + switch template { + case "github-push": + applyGitHubPushTemplate(cfg) + case "github-pr", "github-pull-request": + applyGitHubPRTemplate(cfg) + case "github-release": + applyGitHubReleaseTemplate(cfg) + case "github-workflow": + applyGitHubWorkflowTemplate(cfg) + } +} + +func applyGitHubPushTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "push" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["git_ref"]; !exists { + cfg.Extract["git_ref"] = "$.ref" + } + if _, exists := cfg.Extract["git_commit"]; !exists { + cfg.Extract["git_commit"] = "$.head_commit.id" + } + if _, exists := cfg.Extract["git_branch"]; !exists { + cfg.Extract["git_branch"] = "$.ref" + } + if _, exists := cfg.Extract["git_message"]; !exists { + cfg.Extract["git_message"] = "$.head_commit.message" + } + if _, exists := cfg.Extract["git_author"]; !exists { + cfg.Extract["git_author"] = "$.head_commit.author.name" + } +} + +func applyGitHubPRTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "pull_request" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["pr_number"]; !exists { + cfg.Extract["pr_number"] = "$.number" + } + if _, exists := cfg.Extract["pr_title"]; !exists { + cfg.Extract["pr_title"] = "$.pull_request.title" + } + if _, exists := cfg.Extract["pr_author"]; !exists { + cfg.Extract["pr_author"] = "$.pull_request.user.login" + } + if _, exists := cfg.Extract["pr_action"]; !exists { + cfg.Extract["pr_action"] = "$.action" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["pr_state"]; !exists { + cfg.Extract["pr_state"] = "$.pull_request.state" + } + if _, exists := cfg.Extract["pr_head_sha"]; !exists { + cfg.Extract["pr_head_sha"] = "$.pull_request.head.sha" + } +} + +func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "release" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["release_action"]; !exists { + cfg.Extract["release_action"] = "$.action" + } + if _, exists := cfg.Extract["release_tag"]; !exists { + cfg.Extract["release_tag"] = "$.release.tag_name" + } + if _, exists := cfg.Extract["release_name"]; !exists { + cfg.Extract["release_name"] = "$.release.name" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["release_author"]; !exists { + cfg.Extract["release_author"] = "$.release.author.login" + } +} + +func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "workflow_run" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["workflow_name"]; !exists { + cfg.Extract["workflow_name"] = "$.workflow_run.name" + } + if _, exists := cfg.Extract["workflow_status"]; !exists { + cfg.Extract["workflow_status"] = "$.workflow_run.status" + } + if _, exists := cfg.Extract["workflow_conclusion"]; !exists { + cfg.Extract["workflow_conclusion"] = "$.workflow_run.conclusion" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["git_commit"]; !exists { + cfg.Extract["git_commit"] = "$.workflow_run.head_sha" + } + if _, exists := cfg.Extract["git_branch"]; !exists { + cfg.Extract["git_branch"] = "$.workflow_run.head_branch" + } +} diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go new file mode 100644 index 0000000..45e6717 --- /dev/null +++ b/service/internal/webhooks/handler.go @@ -0,0 +1,153 @@ +package webhooks + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/OliveTin/OliveTin/internal/auth" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + log "github.com/sirupsen/logrus" +) + +type ActionWebhookConfig struct { + Action *config.Action + Config config.WebhookConfig +} + +type WebhookHandler struct { + cfg *config.Config + executor *executor.Executor +} + +func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler { + return &WebhookHandler{ + cfg: cfg, + executor: ex, + } +} + +func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + maxSize := int64(1024 * 1024) + payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize)) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Warnf("Failed to read webhook payload") + http.Error(w, "Failed to read payload", http.StatusBadRequest) + return + } + + var bodyData interface{} + if err := json.Unmarshal(payload, &bodyData); err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Debugf("Webhook payload is not valid JSON") + } + + matchingActions := h.findMatchingActions(r, payload, bodyData) + + if len(matchingActions) == 0 { + log.WithFields(log.Fields{ + "path": r.URL.Path, + "method": r.Method, + }).Debugf("No matching webhook actions found") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + return + } + + processed := 0 + for _, actionConfig := range matchingActions { + if h.processWebhook(actionConfig, r, payload) { + processed++ + } + } + + log.WithFields(log.Fields{ + "matched": len(matchingActions), + "processed": processed, + }).Infof("Webhook processed") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte, bodyData interface{}) []ActionWebhookConfig { + var matches []ActionWebhookConfig + + for _, action := range h.cfg.Actions { + for _, webhookConfig := range action.ExecOnWebhook { + webhookConfigCopy := webhookConfig + + if webhookConfigCopy.Template != "" { + ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) + } + + matcher := NewWebhookMatcher(webhookConfigCopy, r, payload, bodyData) + + if matcher.Matches() { + matches = append(matches, ActionWebhookConfig{ + Action: action, + Config: webhookConfigCopy, + }) + } + } + } + + return matches +} + +func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool { + verifier := NewAuthVerifier(actionConfig.Config) + if !verifier.Verify(r, payload) { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "authType": actionConfig.Config.AuthType, + }).Warnf("Webhook authentication failed") + return false + } + + var bodyData interface{} + json.Unmarshal(payload, &bodyData) + + matcher := NewWebhookMatcher(actionConfig.Config, r, payload, bodyData) + + args, err := matcher.ExtractArguments() + if err != nil { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "error": err, + }).Warnf("Failed to extract webhook arguments") + return false + } + + h.executeAction(actionConfig.Action, args) + return true +} + +func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) { + binding := h.executor.FindBindingWithNoEntity(action) + if binding == nil { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + }).Warnf("Action binding not found, skipping execution") + return + } + + req := &executor.ExecutionRequest{ + Binding: binding, + Cfg: h.cfg, + Tags: []string{"webhook"}, + Arguments: args, + AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"), + } + + h.executor.ExecRequest(req) +} diff --git a/service/internal/webhooks/jsonpath.go b/service/internal/webhooks/jsonpath.go new file mode 100644 index 0000000..2793df5 --- /dev/null +++ b/service/internal/webhooks/jsonpath.go @@ -0,0 +1,42 @@ +package webhooks + +import ( + "encoding/json" + "fmt" + + "github.com/PaesslerAG/jsonpath" +) + +type JSONMatcher struct { + payload interface{} +} + +func NewJSONMatcher(payload []byte) (*JSONMatcher, error) { + var data interface{} + if err := json.Unmarshal(payload, &data); err != nil { + return nil, err + } + return &JSONMatcher{payload: data}, nil +} + +func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return false, err + } + + valueStr := fmt.Sprintf("%v", value) + return valueStr == expectedValue, nil +} + +func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return "", err + } + return fmt.Sprintf("%v", value), nil +} + +func (m *JSONMatcher) GetPayload() interface{} { + return m.payload +} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go new file mode 100644 index 0000000..3031db3 --- /dev/null +++ b/service/internal/webhooks/matcher.go @@ -0,0 +1,167 @@ +package webhooks + +import ( + "net/http" + "regexp" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type WebhookMatcher struct { + config config.WebhookConfig + req *http.Request + body interface{} + bodyBytes []byte +} + +func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte, body interface{}) *WebhookMatcher { + return &WebhookMatcher{ + config: cfg, + req: r, + body: body, + bodyBytes: bodyBytes, + } +} + +func (m *WebhookMatcher) Matches() bool { + if !m.matchHeaders() { + return false + } + + if !m.matchQuery() { + return false + } + + if !m.matchPath() { + return false + } + + return true +} + +func (m *WebhookMatcher) matchHeaders() bool { + if len(m.config.MatchHeaders) == 0 { + return true + } + + for key, expectedValue := range m.config.MatchHeaders { + actualValue := m.req.Header.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "header": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Header mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchQuery() bool { + if len(m.config.MatchQuery) == 0 { + return true + } + + query := m.req.URL.Query() + for key, expectedValue := range m.config.MatchQuery { + actualValue := query.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "query": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Query parameter mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchPath() bool { + if m.config.MatchPath == "" { + return true + } + + parts := strings.SplitN(m.config.MatchPath, "=", 2) + jsonPath := parts[0] + expectedValue := "" + if len(parts) == 2 { + expectedValue = parts[1] + } + + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Debugf("Failed to create JSON matcher") + return false + } + + if expectedValue == "" { + _, err := matcher.ExtractValue(jsonPath) + return err == nil + } + + matches, err := matcher.MatchPath(jsonPath, expectedValue) + if err != nil { + log.WithFields(log.Fields{ + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to match JSONPath") + return false + } + return matches +} + +func (m *WebhookMatcher) compareValues(actual, expected string) bool { + if strings.HasPrefix(expected, "regex:") { + pattern := strings.TrimPrefix(expected, "regex:") + matched, err := regexp.MatchString(pattern, actual) + if err != nil { + log.WithFields(log.Fields{ + "pattern": pattern, + "error": err, + }).Warnf("Invalid regex pattern") + return false + } + return matched + } + return actual == expected +} + +func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { + args := make(map[string]string) + + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + return nil, err + } + + for argName, jsonPath := range m.config.Extract { + value, err := matcher.ExtractValue(jsonPath) + if err != nil { + log.WithFields(log.Fields{ + "argName": argName, + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to extract value") + continue + } + args[argName] = value + } + + args["webhook_method"] = m.req.Method + args["webhook_path"] = m.req.URL.Path + args["webhook_query"] = m.req.URL.RawQuery + + for key, values := range m.req.Header { + if len(values) > 0 { + args["webhook_header_"+strings.ToLower(key)] = values[0] + } + } + + return args, nil +} From 3d5268d1c915d55ca8e2b56a3e77a586772c6f26 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 21:51:35 +0000 Subject: [PATCH 04/26] chore: enableExpression, broken test and config --- integration-tests/tests/enabledExpression/enabledExpression.mjs | 2 +- service/internal/config/config.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/integration-tests/tests/enabledExpression/enabledExpression.mjs b/integration-tests/tests/enabledExpression/enabledExpression.mjs index ce15b30..c9901cb 100644 --- a/integration-tests/tests/enabledExpression/enabledExpression.mjs +++ b/integration-tests/tests/enabledExpression/enabledExpression.mjs @@ -19,7 +19,7 @@ describe('config: enabledExpression', function () { takeScreenshotOnFailure(this.currentTest, webdriver); }); - it('Action with enabledExpression false is disabled', async function() { + it('Action with enabledExpression for lights enable the correct action', async function() { await getRootAndWait() // Navigate to the Lights Dashboard diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 722e492..267dda4 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -28,6 +28,7 @@ type Action struct { Arguments []ActionArgument `koanf:"arguments"` PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + EnabledExpression string `koanf:"enabledExpression"` } // ActionArgument objects appear on Actions. From 11278ff6c275bd82579b02ef4a79d41d1e89e645 Mon Sep 17 00:00:00 2001 From: James Read Date: Tue, 6 Jan 2026 22:09:17 +0000 Subject: [PATCH 05/26] fix: Use constant-time comparison for Basic auth verification. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- service/internal/webhooks/auth.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 5e7f26c..31daf80 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -120,10 +120,21 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } +import ( + "crypto/subtle" + // ... existing imports +) + +func (v *AuthVerifier) verifyBasic(r *http.Request) bool { + // ... existing checks ... + parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { - return username == parts[0] && password == parts[1] + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(parts[1])) + return usernameMatch == 1 && passwordMatch == 1 } - return password == v.config.Secret + return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 +} } From 0b072db36df3460b5878800b05c81daa22c0d0cc Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 22:11:41 +0000 Subject: [PATCH 06/26] fix: Constant time comparison for webhook authentication --- service/internal/webhooks/auth.go | 5 ++++- service/internal/webhooks/matcher.go | 15 +++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 5e7f26c..c3d6a5a 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -2,6 +2,7 @@ package webhooks import ( "crypto/hmac" + "crypto/subtle" "crypto/sha1" "crypto/sha256" "encoding/hex" @@ -105,7 +106,9 @@ func (v *AuthVerifier) verifyBearer(r *http.Request) bool { } token := strings.TrimPrefix(authHeader, "Bearer ") - return token == v.config.Secret + tokenBytes := []byte(token) + secretBytes := []byte(v.config.Secret) + return len(tokenBytes) == len(secretBytes) && subtle.ConstantTimeCompare(tokenBytes, secretBytes) == 1 } func (v *AuthVerifier) verifyBasic(r *http.Request) bool { diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index 3031db3..8b43165 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -12,7 +12,6 @@ import ( type WebhookMatcher struct { config config.WebhookConfig req *http.Request - body interface{} bodyBytes []byte } @@ -50,9 +49,9 @@ func (m *WebhookMatcher) matchHeaders() bool { actualValue := m.req.Header.Get(key) if !m.compareValues(actualValue, expectedValue) { log.WithFields(log.Fields{ - "header": key, - "expected": expectedValue, - "actual": actualValue, + "header": key, + "expected": expectedValue, + "actual": actualValue, }).Debugf("Header mismatch") return false } @@ -70,9 +69,9 @@ func (m *WebhookMatcher) matchQuery() bool { actualValue := query.Get(key) if !m.compareValues(actualValue, expectedValue) { log.WithFields(log.Fields{ - "query": key, - "expected": expectedValue, - "actual": actualValue, + "query": key, + "expected": expectedValue, + "actual": actualValue, }).Debugf("Query parameter mismatch") return false } @@ -144,7 +143,7 @@ func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { value, err := matcher.ExtractValue(jsonPath) if err != nil { log.WithFields(log.Fields{ - "argName": argName, + "argName": argName, "jsonPath": jsonPath, "error": err, }).Debugf("Failed to extract value") From 0368fb10dbb74c6b8f51e45dccd0dfa1117cd2b5 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 22:15:37 +0000 Subject: [PATCH 07/26] fix: wonky merge conflict, and missing body parameter from matcher --- service/internal/webhooks/auth.go | 11 +---------- service/internal/webhooks/matcher.go | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index b81ec31..7c56304 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -2,9 +2,9 @@ package webhooks import ( "crypto/hmac" - "crypto/subtle" "crypto/sha1" "crypto/sha256" + "crypto/subtle" "encoding/hex" "net/http" "strings" @@ -123,14 +123,6 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } -import ( - "crypto/subtle" - // ... existing imports -) - -func (v *AuthVerifier) verifyBasic(r *http.Request) bool { - // ... existing checks ... - parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) @@ -140,4 +132,3 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 } -} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index 8b43165..dbea358 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -19,7 +19,6 @@ func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []by return &WebhookMatcher{ config: cfg, req: r, - body: body, bodyBytes: bodyBytes, } } From c714dc0c62808a9f824ac54c2085ff44724e52e8 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 23:23:47 +0000 Subject: [PATCH 08/26] chore: Webhooks fixes --- service/internal/webhooks/auth.go | 73 +++++--- service/internal/webhooks/github.go | 230 ++++++++++---------------- service/internal/webhooks/handler.go | 99 ++++++----- service/internal/webhooks/jsonpath.go | 17 +- service/internal/webhooks/matcher.go | 44 +++-- 5 files changed, 244 insertions(+), 219 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 7c56304..b03ce99 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -22,23 +22,47 @@ func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier { } func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool { - switch v.config.AuthType { - case "hmac-sha256": - return v.verifyHMAC256(r, payload) - case "hmac-sha1": - return v.verifyHMAC1(r, payload) - case "bearer": - return v.verifyBearer(r) - case "basic": - return v.verifyBasic(r) - case "none", "": - return true - default: - log.WithFields(log.Fields{ - "authType": v.config.AuthType, - }).Warnf("Unknown auth type, rejecting") - return false + verifier := v.getVerifier() + if verifier == nil { + return v.handleUnknownAuthType() } + return verifier(r, payload) +} + +type authVerifierFunc func(*http.Request, []byte) bool + +func (v *AuthVerifier) getVerifier() authVerifierFunc { + if v.config.AuthType == "" || v.config.AuthType == "none" { + return func(_ *http.Request, _ []byte) bool { + return true + } + } + + verifierMap := v.buildVerifierMap() + if verifier, ok := verifierMap[v.config.AuthType]; ok { + return verifier + } + return nil +} + +func (v *AuthVerifier) buildVerifierMap() map[string]authVerifierFunc { + return map[string]authVerifierFunc{ + "hmac-sha256": v.verifyHMAC256, + "hmac-sha1": v.verifyHMAC1, + "bearer": func(r *http.Request, _ []byte) bool { + return v.verifyBearer(r) + }, + "basic": func(r *http.Request, _ []byte) bool { + return v.verifyBasic(r) + }, + } +} + +func (v *AuthVerifier) handleUnknownAuthType() bool { + log.WithFields(log.Fields{ + "authType": v.config.AuthType, + }).Warnf("Unknown auth type, rejecting") + return false } func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool { @@ -123,12 +147,23 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } + return v.verifyBasicCredentials(username, password) +} + +func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool { parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { - usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) - passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(parts[1])) - return usernameMatch == 1 && passwordMatch == 1 + return v.verifyBasicWithUsername(username, password, parts[0], parts[1]) } + return v.verifyBasicPasswordOnly(password) +} +func (v *AuthVerifier) verifyBasicWithUsername(username, password, expectedUsername, expectedPassword string) bool { + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) + return usernameMatch == 1 && passwordMatch == 1 +} + +func (v *AuthVerifier) verifyBasicPasswordOnly(password string) bool { return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 } diff --git a/service/internal/webhooks/github.go b/service/internal/webhooks/github.go index 97b3333..431e6ba 100644 --- a/service/internal/webhooks/github.go +++ b/service/internal/webhooks/github.go @@ -7,158 +7,106 @@ import ( // ApplyGitHubTemplate applies GitHub-specific template configurations // This allows users to use simple template names instead of configuring everything manually func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) { - switch template { - case "github-push": - applyGitHubPushTemplate(cfg) - case "github-pr", "github-pull-request": - applyGitHubPRTemplate(cfg) - case "github-release": - applyGitHubReleaseTemplate(cfg) - case "github-workflow": - applyGitHubWorkflowTemplate(cfg) + applier := getTemplateApplier(template) + if applier != nil { + applier(cfg) + } +} + +type templateApplier func(*config.WebhookConfig) + +func getTemplateApplier(template string) templateApplier { + templateMap := map[string]templateApplier{ + "github-push": applyGitHubPushTemplate, + "github-pr": applyGitHubPRTemplate, + "github-pull-request": applyGitHubPRTemplate, + "github-release": applyGitHubReleaseTemplate, + "github-workflow": applyGitHubWorkflowTemplate, + } + return templateMap[template] +} + +func setDefaultAuth(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } +} + +func ensureMatchHeaders(cfg *config.WebhookConfig) { + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } +} + +func ensureExtract(cfg *config.WebhookConfig) { + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } +} + +func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.Extract[key]; !exists { + cfg.Extract[key] = value + } +} + +func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.MatchHeaders[key]; !exists { + cfg.MatchHeaders[key] = value } } func applyGitHubPushTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "push" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["git_ref"]; !exists { - cfg.Extract["git_ref"] = "$.ref" - } - if _, exists := cfg.Extract["git_commit"]; !exists { - cfg.Extract["git_commit"] = "$.head_commit.id" - } - if _, exists := cfg.Extract["git_branch"]; !exists { - cfg.Extract["git_branch"] = "$.ref" - } - if _, exists := cfg.Extract["git_message"]; !exists { - cfg.Extract["git_message"] = "$.head_commit.message" - } - if _, exists := cfg.Extract["git_author"]; !exists { - cfg.Extract["git_author"] = "$.head_commit.author.name" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push") + ensureExtract(cfg) + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_ref", "$.ref") + setExtractIfMissing(cfg, "git_commit", "$.head_commit.id") + setExtractIfMissing(cfg, "git_branch", "$.ref") + setExtractIfMissing(cfg, "git_message", "$.head_commit.message") + setExtractIfMissing(cfg, "git_author", "$.head_commit.author.name") } func applyGitHubPRTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "pull_request" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["pr_number"]; !exists { - cfg.Extract["pr_number"] = "$.number" - } - if _, exists := cfg.Extract["pr_title"]; !exists { - cfg.Extract["pr_title"] = "$.pull_request.title" - } - if _, exists := cfg.Extract["pr_author"]; !exists { - cfg.Extract["pr_author"] = "$.pull_request.user.login" - } - if _, exists := cfg.Extract["pr_action"]; !exists { - cfg.Extract["pr_action"] = "$.action" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["pr_state"]; !exists { - cfg.Extract["pr_state"] = "$.pull_request.state" - } - if _, exists := cfg.Extract["pr_head_sha"]; !exists { - cfg.Extract["pr_head_sha"] = "$.pull_request.head.sha" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request") + ensureExtract(cfg) + setExtractIfMissing(cfg, "pr_number", "$.number") + setExtractIfMissing(cfg, "pr_title", "$.pull_request.title") + setExtractIfMissing(cfg, "pr_author", "$.pull_request.user.login") + setExtractIfMissing(cfg, "pr_action", "$.action") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "pr_state", "$.pull_request.state") + setExtractIfMissing(cfg, "pr_head_sha", "$.pull_request.head.sha") } func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "release" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["release_action"]; !exists { - cfg.Extract["release_action"] = "$.action" - } - if _, exists := cfg.Extract["release_tag"]; !exists { - cfg.Extract["release_tag"] = "$.release.tag_name" - } - if _, exists := cfg.Extract["release_name"]; !exists { - cfg.Extract["release_name"] = "$.release.name" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["release_author"]; !exists { - cfg.Extract["release_author"] = "$.release.author.login" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release") + ensureExtract(cfg) + setExtractIfMissing(cfg, "release_action", "$.action") + setExtractIfMissing(cfg, "release_tag", "$.release.tag_name") + setExtractIfMissing(cfg, "release_name", "$.release.name") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "release_author", "$.release.author.login") } func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "workflow_run" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["workflow_name"]; !exists { - cfg.Extract["workflow_name"] = "$.workflow_run.name" - } - if _, exists := cfg.Extract["workflow_status"]; !exists { - cfg.Extract["workflow_status"] = "$.workflow_run.status" - } - if _, exists := cfg.Extract["workflow_conclusion"]; !exists { - cfg.Extract["workflow_conclusion"] = "$.workflow_run.conclusion" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["git_commit"]; !exists { - cfg.Extract["git_commit"] = "$.workflow_run.head_sha" - } - if _, exists := cfg.Extract["git_branch"]; !exists { - cfg.Extract["git_branch"] = "$.workflow_run.head_branch" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run") + ensureExtract(cfg) + setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name") + setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status") + setExtractIfMissing(cfg, "workflow_conclusion", "$.workflow_run.conclusion") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_commit", "$.workflow_run.head_sha") + setExtractIfMissing(cfg, "git_branch", "$.workflow_run.head_branch") } diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go index 45e6717..a530690 100644 --- a/service/internal/webhooks/handler.go +++ b/service/internal/webhooks/handler.go @@ -1,7 +1,6 @@ package webhooks import ( - "encoding/json" "io" "net/http" @@ -34,70 +33,83 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { return } + payload, err := h.readPayload(r) + if err != nil { + http.Error(w, "Failed to read payload", http.StatusBadRequest) + return + } + + matchingActions := h.findMatchingActions(r, payload) + if len(matchingActions) == 0 { + h.writeOKResponse(w, "no matching webhook actions") + return + } + + processed := h.processMatchingActions(matchingActions, r, payload) + log.WithFields(log.Fields{ + "matched": len(matchingActions), + "processed": processed, + }).Infof("Webhook processed") + + h.writeOKResponse(w, "webhook actions") +} + +func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) { maxSize := int64(1024 * 1024) payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize)) if err != nil { log.WithFields(log.Fields{ "error": err, }).Warnf("Failed to read webhook payload") - http.Error(w, "Failed to read payload", http.StatusBadRequest) - return + return nil, err } - var bodyData interface{} - if err := json.Unmarshal(payload, &bodyData); err != nil { - log.WithFields(log.Fields{ - "error": err, - }).Debugf("Webhook payload is not valid JSON") - } - - matchingActions := h.findMatchingActions(r, payload, bodyData) - - if len(matchingActions) == 0 { - log.WithFields(log.Fields{ - "path": r.URL.Path, - "method": r.Method, - }).Debugf("No matching webhook actions found") - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) - return + return payload, nil +} + +func (h *WebhookHandler) writeOKResponse(w http.ResponseWriter, context string) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("OK")); err != nil { + log.WithError(err).Warnf("Failed to write response for %s", context) } +} +func (h *WebhookHandler) processMatchingActions(matchingActions []ActionWebhookConfig, r *http.Request, payload []byte) int { processed := 0 for _, actionConfig := range matchingActions { if h.processWebhook(actionConfig, r, payload) { processed++ } } - - log.WithFields(log.Fields{ - "matched": len(matchingActions), - "processed": processed, - }).Infof("Webhook processed") - - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + return processed } -func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte, bodyData interface{}) []ActionWebhookConfig { +func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig { var matches []ActionWebhookConfig for _, action := range h.cfg.Actions { - for _, webhookConfig := range action.ExecOnWebhook { - webhookConfigCopy := webhookConfig + matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...) + } - if webhookConfigCopy.Template != "" { - ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) - } + return matches +} - matcher := NewWebhookMatcher(webhookConfigCopy, r, payload, bodyData) +func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig { + var matches []ActionWebhookConfig - if matcher.Matches() { - matches = append(matches, ActionWebhookConfig{ - Action: action, - Config: webhookConfigCopy, - }) - } + for _, webhookConfig := range action.ExecOnWebhook { + webhookConfigCopy := webhookConfig + + if webhookConfigCopy.Template != "" { + ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) + } + + matcher := NewWebhookMatcher(webhookConfigCopy, r, payload) + if matcher.Matches() { + matches = append(matches, ActionWebhookConfig{ + Action: action, + Config: webhookConfigCopy, + }) } } @@ -114,10 +126,7 @@ func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *htt return false } - var bodyData interface{} - json.Unmarshal(payload, &bodyData) - - matcher := NewWebhookMatcher(actionConfig.Config, r, payload, bodyData) + matcher := NewWebhookMatcher(actionConfig.Config, r, payload) args, err := matcher.ExtractArguments() if err != nil { diff --git a/service/internal/webhooks/jsonpath.go b/service/internal/webhooks/jsonpath.go index 2793df5..7fc5309 100644 --- a/service/internal/webhooks/jsonpath.go +++ b/service/internal/webhooks/jsonpath.go @@ -25,7 +25,13 @@ func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, er return false, err } - valueStr := fmt.Sprintf("%v", value) + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return false, fmt.Errorf("failed to marshal extracted value: %w", err) + } + + valueStr := string(jsonBytes) return valueStr == expectedValue, nil } @@ -34,7 +40,14 @@ func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("%v", value), nil + + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal extracted value: %w", err) + } + + return string(jsonBytes), nil } func (m *JSONMatcher) GetPayload() interface{} { diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index dbea358..3d30d5e 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -15,7 +15,7 @@ type WebhookMatcher struct { bodyBytes []byte } -func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte, body interface{}) *WebhookMatcher { +func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher { return &WebhookMatcher{ config: cfg, req: r, @@ -83,13 +83,7 @@ func (m *WebhookMatcher) matchPath() bool { return true } - parts := strings.SplitN(m.config.MatchPath, "=", 2) - jsonPath := parts[0] - expectedValue := "" - if len(parts) == 2 { - expectedValue = parts[1] - } - + jsonPath, expectedValue := m.parseMatchPath() matcher, err := NewJSONMatcher(m.bodyBytes) if err != nil { log.WithFields(log.Fields{ @@ -98,6 +92,20 @@ func (m *WebhookMatcher) matchPath() bool { return false } + return m.matchPathValue(matcher, jsonPath, expectedValue) +} + +func (m *WebhookMatcher) parseMatchPath() (string, string) { + parts := strings.SplitN(m.config.MatchPath, "=", 2) + jsonPath := parts[0] + expectedValue := "" + if len(parts) == 2 { + expectedValue = parts[1] + } + return jsonPath, expectedValue +} + +func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expectedValue string) bool { if expectedValue == "" { _, err := matcher.ExtractValue(jsonPath) return err == nil @@ -131,13 +139,21 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool { } func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { - args := make(map[string]string) - matcher, err := NewJSONMatcher(m.bodyBytes) if err != nil { return nil, err } + args := m.extractJSONPathValues(matcher) + m.addWebhookMetadata(args) + m.addWebhookHeaders(args) + + return args, nil +} + +func (m *WebhookMatcher) extractJSONPathValues(matcher *JSONMatcher) map[string]string { + args := make(map[string]string) + for argName, jsonPath := range m.config.Extract { value, err := matcher.ExtractValue(jsonPath) if err != nil { @@ -151,15 +167,19 @@ func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { args[argName] = value } + return args +} + +func (m *WebhookMatcher) addWebhookMetadata(args map[string]string) { args["webhook_method"] = m.req.Method args["webhook_path"] = m.req.URL.Path args["webhook_query"] = m.req.URL.RawQuery +} +func (m *WebhookMatcher) addWebhookHeaders(args map[string]string) { for key, values := range m.req.Header { if len(values) > 0 { args["webhook_header_"+strings.ToLower(key)] = values[0] } } - - return args, nil } From cbe6c8f20f48deb3df983cecced7485c4c9d9562 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 23:53:31 +0000 Subject: [PATCH 09/26] core: dep update --- frontend/package-lock.json | 52 +++++++++++++++++++------------------- frontend/package.json | 6 ++--- service/go.mod | 13 +++++----- service/go.sum | 19 ++++++++++++++ 4 files changed, 55 insertions(+), 35 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d28a834..8aabc5b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,17 +11,17 @@ "dependencies": { "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.1.0", + "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/vue": "^1.0.4", "@vitejs/plugin-vue": "^6.0.3", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.10.0", + "picocrank": "^1.12.1", "standard": "^17.1.2", "unplugin-vue-components": "^30.0.0", "vite": "^7.3.0", - "vue-i18n": "^11.2.7", + "vue-i18n": "^11.2.8", "vue-router": "^4.6.4" }, "devDependencies": { @@ -855,9 +855,9 @@ } }, "node_modules/@hugeicons/core-free-icons": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.0.tgz", - "integrity": "sha512-DVIdHcPxJ8MyaXaGe3appbaB5z4DWit5RLn8vTy3hsKTrpFEm4QftixfbcHXRqLpOMAoMa2+UXk35qNJ4ZWIsg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz", + "integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==", "license": "MIT" }, "node_modules/@hugeicons/vue": { @@ -911,13 +911,13 @@ "license": "MIT" }, "node_modules/@intlify/core-base": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.7.tgz", - "integrity": "sha512-+Ra9I/LAzXDnmv/IrTO03WMCiLya7pHRmGJvNl9fKwx/W4REJ0xaMk2PxCRqnxcBsX443amEMdebQ3R1geiuIw==", + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz", + "integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==", "license": "MIT", "dependencies": { - "@intlify/message-compiler": "11.2.7", - "@intlify/shared": "11.2.7" + "@intlify/message-compiler": "11.2.8", + "@intlify/shared": "11.2.8" }, "engines": { "node": ">= 16" @@ -927,12 +927,12 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.7.tgz", - "integrity": "sha512-TFamC+GzJAotAFwUNvbtRVBgvuSn2nCwKNresmPUHv3IIVMmXJt7QQJj/DORI1h8hs46ZF6L0Fs2xBohSOE4iQ==", + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz", + "integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==", "license": "MIT", "dependencies": { - "@intlify/shared": "11.2.7", + "@intlify/shared": "11.2.8", "source-map-js": "^1.0.2" }, "engines": { @@ -943,9 +943,9 @@ } }, "node_modules/@intlify/shared": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.7.tgz", - "integrity": "sha512-uvlkvc/0uQ4FDlHQZccpUnmcOwNcaI3i+69ck2YJ+GqM35AoVbuS63b+YfirV4G0SZh64Ij2UMcFRMmB4nr95w==", + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz", + "integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==", "license": "MIT", "engines": { "node": ">= 16" @@ -4607,9 +4607,9 @@ "license": "ISC" }, "node_modules/picocrank": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.10.0.tgz", - "integrity": "sha512-rpuFopcko5jKuQpnqpvN0Umg9M2qmDI6Z/tVzbnT9PSplwJmtw9wTjgDHyQXujoBBULyurRqtIeQKDPNFLlHOQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.12.1.tgz", + "integrity": "sha512-2qcIcveWQkkA2Wyo+KQdZANTbjb/9ydzinbpNN/1U/4x0BBUjyHhWoK5lNAx/KDVNl6ZM3xGo3eMb5/n6xWoVA==", "license": "ISC", "dependencies": { "@hugeicons/core-free-icons": "^3.1.0", @@ -6246,13 +6246,13 @@ } }, "node_modules/vue-i18n": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.7.tgz", - "integrity": "sha512-LPv8bAY5OA0UvFEXl4vBQOBqJzRrlExy92tWgRuwW7tbykHf7CH71G2Y4TM2OwGcIS4+hyqKHS2EVBqaYwPY9Q==", + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", + "integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.2.7", - "@intlify/shared": "11.2.7", + "@intlify/core-base": "11.2.8", + "@intlify/shared": "11.2.8", "@vue/devtools-api": "^6.5.0" }, "engines": { diff --git a/frontend/package.json b/frontend/package.json index 5f9cc55..3b6a2cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,17 +24,17 @@ "dependencies": { "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.1.0", + "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/vue": "^1.0.4", "@vitejs/plugin-vue": "^6.0.3", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.10.0", + "picocrank": "^1.12.1", "standard": "^17.1.2", "unplugin-vue-components": "^30.0.0", "vite": "^7.3.0", - "vue-i18n": "^11.2.7", + "vue-i18n": "^11.2.8", "vue-router": "^4.6.4" } } diff --git a/service/go.mod b/service/go.mod index 3d53c97..3805d38 100644 --- a/service/go.mod +++ b/service/go.mod @@ -12,13 +12,13 @@ require ( github.com/MicahParks/keyfunc/v3 v3.7.0 github.com/PaesslerAG/jsonpath v0.1.1 github.com/alexedwards/argon2id v1.0.0 - github.com/bufbuild/buf v1.61.0 + github.com/bufbuild/buf v1.63.0 github.com/fsnotify/fsnotify v1.9.0 github.com/fzipp/gocyclo v0.6.0 github.com/go-critic/go-critic v0.14.2 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c + github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -52,14 +52,14 @@ require ( buf.build/go/spdx v0.2.0 // indirect buf.build/go/standard v0.1.0 // indirect cel.dev/expr v0.25.1 // indirect - connectrpc.com/otelconnect v0.8.0 // indirect + connectrpc.com/otelconnect v0.9.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MicahParks/jwkset v0.11.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/PaesslerAG/gval v1.0.0 // indirect + github.com/PaesslerAG/gval v1.2.4 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect + github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 // indirect github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect @@ -114,7 +114,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect @@ -127,6 +127,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect diff --git a/service/go.sum b/service/go.sum index 9d82fe9..722c4a0 100644 --- a/service/go.sum +++ b/service/go.sum @@ -32,6 +32,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU= connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= +connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= +connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -44,6 +46,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= +github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= @@ -59,8 +63,12 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/buf v1.61.0 h1:JPaK/RM2eoheyzznW+1LxaFgN6xjBCi8s25q2kUbH9A= github.com/bufbuild/buf v1.61.0/go.mod h1:Xs3leBmxjL5tTnSVYfNwNXHXD1k5et3fR/tJyIyQl4s= +github.com/bufbuild/buf v1.63.0 h1:vMIRozWqYcOU992FqcGgAp8LjoWVTzr52qEolUT+xi4= +github.com/bufbuild/buf v1.63.0/go.mod h1:IWF+TIxwmk4DeyDmguN8WhxKFKQitcX2WnP+RlJlDiY= github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs= github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI= +github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 h1:cQYwUyAzyMmYr7AyJU1C6pVCpUrJJBkmx7UunZosxxs= +github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -157,6 +165,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM= github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE= +github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac h1:JQ6AC9tf2xhwTxMY9nuIeOPM7Cj0BDeCNgKPrNgQvtQ= +github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= @@ -221,6 +231,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4= @@ -247,12 +259,17 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -298,6 +315,7 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= @@ -310,6 +328,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= From 7428c160e748dde73c08f2b26e132d9dd01fd870 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 7 Jan 2026 00:24:21 +0000 Subject: [PATCH 10/26] chore: Refactor enabledExpression tests to use LightDashboard and improve button identification --- frontend/main.js | 2 +- frontend/resources/vue/Dashboard.vue | 15 +++ .../tests/enabledExpression/config.yaml | 2 +- .../enabledExpression/enabledExpression.mjs | 94 +++++++++++++++---- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/frontend/main.js b/frontend/main.js index 699743b..a434cce 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -93,7 +93,7 @@ function setupVue (i18nSettings) { function setupErrorDisplay (errorMessage) { const ErrorApp = { - render() { + render () { return h('section', { class: 'bad', style: 'padding: 2em; text-align: center; margin: 2em auto;' }, [ h('h2', 'OliveTin Init Failed'), h('p', errorMessage), diff --git a/frontend/resources/vue/Dashboard.vue b/frontend/resources/vue/Dashboard.vue index 16432bd..c6e2eff 100644 --- a/frontend/resources/vue/Dashboard.vue +++ b/frontend/resources/vue/Dashboard.vue @@ -108,6 +108,21 @@ function goBack() { async function getDashboard() { let title = props.title + // Decode URL-encoded title if present (Vue Router should decode automatically, + // but handle cases where it might not) + if (title) { + try { + // Try decoding in case it's URL-encoded (e.g., "Lights%20Dashboard" -> "Lights Dashboard") + const decoded = decodeURIComponent(title) + // Use decoded version if it's different from the original + if (decoded !== title) { + title = decoded + } + } catch (e) { + // If decoding fails (e.g., invalid encoding), use original title + } + } + // If no specific title was provided or it's the placeholder 'default', // prefer the first configured root dashboard (e.g., "Test"). if ((!title || title === 'default') && window.initResponse.rootDashboards && window.initResponse.rootDashboards.length > 0) { diff --git a/integration-tests/tests/enabledExpression/config.yaml b/integration-tests/tests/enabledExpression/config.yaml index 66ea639..30c718f 100644 --- a/integration-tests/tests/enabledExpression/config.yaml +++ b/integration-tests/tests/enabledExpression/config.yaml @@ -29,7 +29,7 @@ entities: name: light dashboards: - - title: Lights Dashboard + - title: LightDashboard contents: - title: Light Controls type: fieldset diff --git a/integration-tests/tests/enabledExpression/enabledExpression.mjs b/integration-tests/tests/enabledExpression/enabledExpression.mjs index c9901cb..98f32c0 100644 --- a/integration-tests/tests/enabledExpression/enabledExpression.mjs +++ b/integration-tests/tests/enabledExpression/enabledExpression.mjs @@ -1,12 +1,14 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until } from 'selenium-webdriver' +import { By, until, Condition } from 'selenium-webdriver' import { getRootAndWait, takeScreenshotOnFailure, } from '../../lib/elements.js' describe('config: enabledExpression', function () { + this.timeout(30000) // Increase timeout for async operations + before(async function () { await runner.start('enabledExpression') }) @@ -23,26 +25,86 @@ describe('config: enabledExpression', function () { await getRootAndWait() // Navigate to the Lights Dashboard - await webdriver.get(runner.baseUrl() + '/dashboard/Lights%20Dashboard') + // Use the path with space encoded as %20 - Vue Router should decode it + await webdriver.get(runner.baseUrl() + '/dashboards/LightDashboard') - // Wait for dashboard to load - await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + // Wait for the URL to change and the route to be processed + await webdriver.wait(new Condition('wait for URL to contain dashboards', async function() { + const url = await webdriver.getCurrentUrl() + return url.includes('/dashboards/') + }), 5000) - // Find action buttons - const actionButtons = await webdriver.findElements(By.css('.action-button button')) + // Wait for dashboard to load by checking the loaded-dashboard attribute + // The attribute should be set to the decoded title "LightDashboard" + await webdriver.wait(new Condition('wait for loaded-dashboard', async function() { + const body = await webdriver.findElement(By.tagName('body')) + const attr = await body.getAttribute('loaded-dashboard') + if (attr) { + console.log('Current loaded-dashboard attribute:', attr) + } + // Accept either decoded or encoded version (component should decode, but handle both) + return attr === 'LightDashboard' + }), 10000) - // Find "Turn On Light" button for "Living Room Light" (powered_on: false, so Turn On should be enabled) - // Find "Turn Off Light" button for "Bedroom Light" (powered_on: true, so Turn Off should be enabled) + // Verify we got the correct dashboard (prefer decoded, but accept encoded) + const body = await webdriver.findElement(By.tagName('body')) + const attr = await body.getAttribute('loaded-dashboard') + if (attr !== 'LightDashboard') { + const currentUrl = await webdriver.getCurrentUrl() + throw new Error(`Dashboard not loaded correctly. Expected "LightDashboard", got "${attr}". Current URL: ${currentUrl}`) + } + + // Wait for dashboard content to appear - check for dashboard rows first + await webdriver.wait(until.elementsLocated(By.css('.dashboard-row')), 5000) + + // Debug: Check what's on the page + const dashboardRows = await webdriver.findElements(By.css('.dashboard-row')) + console.log(`Found ${dashboardRows.length} dashboard rows`) + + for (let i = 0; i < dashboardRows.length; i++) { + const row = dashboardRows[i] + const h2Elements = await row.findElements(By.css('h2')) + if (h2Elements.length > 0) { + const h2Text = await h2Elements[0].getText() + console.log(`Row ${i} h2: "${h2Text}"`) + } + const fieldsets = await row.findElements(By.css('fieldset')) + console.log(`Row ${i} has ${fieldsets.length} fieldsets`) + if (fieldsets.length > 0) { + const buttons = await fieldsets[0].findElements(By.css('.action-button button')) + console.log(`Row ${i} fieldset has ${buttons.length} buttons`) + } + } + + // Find buttons by looking within entity fieldsets + // Both rows have h2 title "Light Controls", so we identify them by which buttons are enabled + // Living Room Light (powered_on: false) - Turn On should be enabled, Turn Off disabled + // Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled let turnOnButton = null let turnOffButton = null - - for (const btn of actionButtons) { - const title = await btn.getAttribute('title') - if (title && title.includes('Turn On Light') && title.includes('Living Room')) { - turnOnButton = btn - } - if (title && title.includes('Turn Off Light') && title.includes('Bedroom')) { - turnOffButton = btn + + for (const row of dashboardRows) { + // Get the fieldset in this row + const fieldsets = await row.findElements(By.css('fieldset')) + if (fieldsets.length === 0) continue + + const buttons = await fieldsets[0].findElements(By.css('.action-button button')) + + // Check each button to identify which entity this row represents + for (const btn of buttons) { + const title = await btn.getAttribute('title') + const disabled = await btn.getAttribute('disabled') + const isEnabled = disabled === null + + if (title === 'Turn On Light' && isEnabled) { + // This is the Living Room Light row (Turn On is enabled because powered_on: false) + turnOnButton = btn + } + + if (title === 'Turn Off Light' && isEnabled) { + // This is the Bedroom Light row (Turn Off is enabled because powered_on: true) + turnOffButton = btn + } } } From aeb66d494c121c51adbffef41156052574a4aa74 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 7 Jan 2026 00:43:22 +0000 Subject: [PATCH 11/26] fix: remove decodeURIComponent from Dashboard.vue --- frontend/resources/vue/Dashboard.vue | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/frontend/resources/vue/Dashboard.vue b/frontend/resources/vue/Dashboard.vue index c6e2eff..16432bd 100644 --- a/frontend/resources/vue/Dashboard.vue +++ b/frontend/resources/vue/Dashboard.vue @@ -108,21 +108,6 @@ function goBack() { async function getDashboard() { let title = props.title - // Decode URL-encoded title if present (Vue Router should decode automatically, - // but handle cases where it might not) - if (title) { - try { - // Try decoding in case it's URL-encoded (e.g., "Lights%20Dashboard" -> "Lights Dashboard") - const decoded = decodeURIComponent(title) - // Use decoded version if it's different from the original - if (decoded !== title) { - title = decoded - } - } catch (e) { - // If decoding fails (e.g., invalid encoding), use original title - } - } - // If no specific title was provided or it's the placeholder 'default', // prefer the first configured root dashboard (e.g., "Test"). if ((!title || title === 'default') && window.initResponse.rootDashboards && window.initResponse.rootDashboards.length > 0) { From e96270046ea150eafa9759b306fab7c79b0f8ad1 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 7 Jan 2026 01:20:29 +0000 Subject: [PATCH 12/26] fix: (#765) set X-Accel-Buffering header to disable nginx buffering for event stream --- service/internal/api/api.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/internal/api/api.go b/service/internal/api/api.go index d1bf652..1d44fd8 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -723,6 +723,10 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error { log.Debugf("EventStream: %v", req.Msg) + // Set X-Accel-Buffering header to disable nginx buffering for this stream + // https://github.com/OliveTin/OliveTin/issues/765 + srv.ResponseHeader().Set("X-Accel-Buffering", "no") + user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { From 2ffb8b0d819ec8cf4279f7858650bc605441e39e Mon Sep 17 00:00:00 2001 From: jamesread Date: Thu, 8 Jan 2026 23:19:31 +0000 Subject: [PATCH 13/26] feat: Logs now have a calendar view --- frontend/package-lock.json | 28 ++-- frontend/package.json | 4 +- frontend/resources/vue/router.js | 12 ++ .../resources/vue/views/LogsCalendarView.vue | 130 ++++++++++++++++++ frontend/resources/vue/views/LogsListView.vue | 125 ++++++++++++++++- lang/combined_output.json | 10 ++ lang/de-DE.yaml | 2 + lang/en.yaml | 2 + lang/es-ES.yaml | 2 + lang/it-IT.yaml | 2 + lang/zh-Hans-CN.yaml | 2 + 11 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 frontend/resources/vue/views/LogsCalendarView.vue diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8aabc5b..bd7ecde 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,10 +17,10 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.12.1", + "picocrank": "^1.12.5", "standard": "^17.1.2", "unplugin-vue-components": "^30.0.0", - "vite": "^7.3.0", + "vite": "^7.3.1", "vue-i18n": "^11.2.8", "vue-router": "^4.6.4" }, @@ -2961,9 +2961,9 @@ } }, "node_modules/femtocrank": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.4.12.tgz", - "integrity": "sha512-X2a4WVG1ADGjQcULUyH2FJ4njJNZobfP+iPO1MpAEtWRyVEDxps6dmktbOb3igoyCxNObF0T0uKwUC7zV21C0A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.0.tgz", + "integrity": "sha512-plV1HNS/fUzohWJ349kuCBZ3TCfXz7V4F/sY2lVbVWtGXUV+aHxLG6IddAMEf64k2LJ8j0KVrj+nIIKepFaKvg==", "license": "AGPL-3.0" }, "node_modules/file-entry-cache": { @@ -4607,17 +4607,17 @@ "license": "ISC" }, "node_modules/picocrank": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.12.1.tgz", - "integrity": "sha512-2qcIcveWQkkA2Wyo+KQdZANTbjb/9ydzinbpNN/1U/4x0BBUjyHhWoK5lNAx/KDVNl6ZM3xGo3eMb5/n6xWoVA==", + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.12.5.tgz", + "integrity": "sha512-z0EP/I56cFGzvXV4EAEpkczYDYkdHGtRfHQA+k7rbrBEHMO1fi7qW8VbDj7/2eqeG6IbNqWRIG1IexRQWZj7bQ==", "license": "ISC", "dependencies": { - "@hugeicons/core-free-icons": "^3.1.0", + "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/vue": "^1.0.4", "@vitejs/plugin-vue": "^6.0.3", - "femtocrank": "^2.4.12", + "femtocrank": "^2.5.0", "unplugin-vue-components": "^30.0.0", - "vite": "^7.3.0", + "vite": "^7.3.1", "vue": "^3.5.26", "vue-router": "^4.6.4" } @@ -6122,9 +6122,9 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", diff --git a/frontend/package.json b/frontend/package.json index 3b6a2cc..1d97045 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,10 +30,10 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.12.1", + "picocrank": "^1.12.5", "standard": "^17.1.2", "unplugin-vue-components": "^30.0.0", - "vite": "^7.3.0", + "vite": "^7.3.1", "vue-i18n": "^11.2.8", "vue-router": "^4.6.4" } diff --git a/frontend/resources/vue/router.js b/frontend/resources/vue/router.js index 4310696..28b13c9 100644 --- a/frontend/resources/vue/router.js +++ b/frontend/resources/vue/router.js @@ -35,6 +35,18 @@ const routes = [ icon: LeftToRightListDashIcon } }, + { + path: '/logs/calendar', + name: 'LogsCalendar', + component: () => import('./views/LogsCalendarView.vue'), + meta: { + title: 'Logs Calendar', + breadcrumb: [ + { name: "Logs", href: "/logs" }, + { name: "Calendar" }, + ] + } + }, { path: '/entities', name: 'Entities', diff --git a/frontend/resources/vue/views/LogsCalendarView.vue b/frontend/resources/vue/views/LogsCalendarView.vue new file mode 100644 index 0000000..2dc7046 --- /dev/null +++ b/frontend/resources/vue/views/LogsCalendarView.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/resources/vue/views/LogsListView.vue b/frontend/resources/vue/views/LogsListView.vue index 4a6c07d..b3b9594 100644 --- a/frontend/resources/vue/views/LogsListView.vue +++ b/frontend/resources/vue/views/LogsListView.vue @@ -1,6 +1,9 @@