From b37f035ea653e6f238a7cdfa0f636f518a692dc4 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 25 Jan 2026 10:32:22 +0000 Subject: [PATCH] fix: Move templating functionality to global, making it possible to replace templates across the config --- service/internal/api/api.go | 3 +- service/internal/api/apiActions.go | 23 ++++-- service/internal/api/api_test.go | 6 +- service/internal/api/dashboard_entities.go | 19 ++--- service/internal/api/dashboards.go | 3 +- service/internal/config/config.go | 3 +- service/internal/config/sanitize.go | 21 +++++ service/internal/entities/entities.go | 2 +- service/internal/entities/storage.go | 68 ++++------------ service/internal/executor/arguments.go | 7 +- service/internal/executor/executor.go | 3 +- .../internal/{entities => tpl}/templates.go | 78 +++++++++++++++---- 12 files changed, 145 insertions(+), 91 deletions(-) rename service/internal/{entities => tpl}/templates.go (65%) diff --git a/service/internal/api/api.go b/service/internal/api/api.go index c7ee481..fc4857f 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -27,6 +27,7 @@ import ( entities "github.com/OliveTin/OliveTin/internal/entities" executor "github.com/OliveTin/OliveTin/internal/executor" installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo" + "github.com/OliveTin/OliveTin/internal/tpl" connectproto "go.akshayshah.org/connectproto" ) @@ -709,7 +710,7 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum return connect.NewResponse(res), nil } - jsonstring, _ := json.MarshalIndent(entities.GetAll(), "", " ") + jsonstring, _ := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ") fmt.Printf("%s", &jsonstring) res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore" diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 55728b9..3ca12de 100644 --- a/service/internal/api/apiActions.go +++ b/service/internal/api/apiActions.go @@ -13,6 +13,7 @@ import ( config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" executor "github.com/OliveTin/OliveTin/internal/executor" + "github.com/OliveTin/OliveTin/internal/tpl" ) type DashboardRenderRequest struct { @@ -66,7 +67,7 @@ func evaluateEnabledExpression(action *config.Action, entity *entities.Entity) b return true } - result := entities.ParseTemplateWith(action.EnabledExpression, entity) + result := tpl.ParseTemplateWith(action.EnabledExpression, entity) result = strings.TrimSpace(result) if result == "" { @@ -105,6 +106,16 @@ func evaluateResultValue(result string) bool { return false } +func getDefaultValue(cfgArg config.ActionArgument, entity *entities.Entity) string { + defaultValue := cfgArg.Default + + if defaultValue != "" { + defaultValue = tpl.ParseTemplateWith(defaultValue, entity) + } + + return defaultValue +} + func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action { action := actionBinding.Action @@ -120,8 +131,8 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque btn := apiv1.Action{ BindingId: actionBinding.ID, - Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity), - Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity), + Title: tpl.ParseTemplateWith(action.Title, actionBinding.Entity), + Icon: tpl.ParseTemplateWith(action.Icon, actionBinding.Entity), CanExec: aclCanExec && enabledExprCanExec, PopupOnStart: action.PopupOnStart, Order: int32(actionBinding.ConfigOrder), @@ -135,7 +146,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque Title: cfgArg.Title, Type: cfgArg.Type, Description: cfgArg.Description, - DefaultValue: cfgArg.Default, + DefaultValue: getDefaultValue(cfgArg, actionBinding.Entity), Choices: buildChoices(cfgArg), Suggestions: cfgArg.Suggestions, SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey, @@ -162,8 +173,8 @@ func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle str for _, ent := range entList { ret = append(ret, &apiv1.ActionArgumentChoice{ - Value: entities.ParseTemplateWith(firstChoice.Value, ent), - Title: entities.ParseTemplateWith(firstChoice.Title, ent), + Value: tpl.ParseTemplateWith(firstChoice.Value, ent), + Title: tpl.ParseTemplateWith(firstChoice.Title, ent), }) } diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index c2977f7..790e315 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -119,9 +119,9 @@ func TestGetEntities(t *testing.T) { } func setupTestEntities() { - entities.ClearEntities("server") - entities.ClearEntities("database") - entities.ClearEntities("application") + entities.ClearEntitiesOfType("server") + entities.ClearEntitiesOfType("database") + entities.ClearEntitiesOfType("application") entities.AddEntity("server", "zebra", map[string]any{"title": "Server Zebra", "hostname": "zebra.example.com"}) entities.AddEntity("server", "alpha", map[string]any{"title": "Server Alpha", "hostname": "alpha.example.com"}) diff --git a/service/internal/api/dashboard_entities.go b/service/internal/api/dashboard_entities.go index 68a053b..8ae21a0 100644 --- a/service/internal/api/dashboard_entities.go +++ b/service/internal/api/dashboard_entities.go @@ -4,6 +4,7 @@ import ( apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/tpl" log "github.com/sirupsen/logrus" ) @@ -23,14 +24,14 @@ func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr return ret } -func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent { +func buildEntityFieldset(component *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent { return &apiv1.DashboardComponent{ - Title: entities.ParseTemplateWith(tpl.Title, ent), + Title: tpl.ParseTemplateWith(component.Title, ent), Type: "fieldset", - Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, tpl.Entity, rr)), - CssClass: entities.ParseTemplateWith(tpl.CssClass, ent), - Action: rr.findAction(tpl.Title), - EntityType: tpl.Entity, + Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)), + CssClass: tpl.ParseTemplateWith(component.CssClass, ent), + Action: rr.findAction(component.Title), + EntityType: component.Entity, EntityKey: ent.UniqueKey, } } @@ -68,7 +69,7 @@ func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *ent func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, rr *DashboardRenderRequest) *apiv1.DashboardComponent { clone := &apiv1.DashboardComponent{} - clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent) + clone.CssClass = tpl.ParseTemplateWith(subitem.CssClass, ent) if isLinkType(subitem.Type) { return cloneLinkItem(subitem, ent, clone, rr) @@ -83,7 +84,7 @@ func isLinkType(itemType string) bool { func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent { clone.Type = "link" - clone.Title = entities.ParseTemplateWith(subitem.Title, ent) + clone.Title = tpl.ParseTemplateWith(subitem.Title, ent) // Prefer an entity-specific action when available, but fall back to a // non-entity-scoped action with the same title. This allows inline actions // defined inside entity dashboards to work without requiring an explicit @@ -98,7 +99,7 @@ func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clo } func cloneNonLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent { - clone.Title = entities.ParseTemplateWith(subitem.Title, ent) + clone.Title = tpl.ParseTemplateWith(subitem.Title, ent) clone.Type = subitem.Type if isDirectoryWithEntity(clone.Type, ent, entityType) { diff --git a/service/internal/api/dashboards.go b/service/internal/api/dashboards.go index 12ae2d5..08ef241 100644 --- a/service/internal/api/dashboards.go +++ b/service/internal/api/dashboards.go @@ -6,6 +6,7 @@ import ( apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/tpl" log "github.com/sirupsen/logrus" "golang.org/x/exp/slices" ) @@ -236,7 +237,7 @@ func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, title := subitem.Title if entity != nil { - title = entities.ParseTemplateWith(subitem.Title, entity) + title = tpl.ParseTemplateWith(subitem.Title, entity) } newitem := &apiv1.DashboardComponent{ diff --git a/service/internal/config/config.go b/service/internal/config/config.go index d22e86f..3082428 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -170,7 +170,8 @@ type Config struct { BannerCSS string `koanf:"bannerCss"` Include string `koanf:"include"` - sourceFiles []string + sourceFiles []string + passwordTemplateParser func(string, interface{}) string } type AuthLocalUsersConfig struct { diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index f626775..779cfe1 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -13,6 +13,7 @@ func (cfg *Config) Sanitize() { cfg.sanitizeLogLevel() cfg.sanitizeAuthRequireGuestsToLogin() cfg.sanitizeLogHistoryPageSize() + cfg.sanitizeLocalUserPasswords() // log.Infof("cfg %p", cfg) @@ -172,6 +173,26 @@ func (cfg *Config) sanitizeLogHistoryPageSize() { } } +// SetPasswordTemplateParser sets the function to use for parsing password templates. +// This is called from main.go to avoid import cycles (config can't import entities). +func (cfg *Config) SetPasswordTemplateParser(parser func(string, interface{}) string) { + cfg.passwordTemplateParser = parser +} + +func (cfg *Config) sanitizeLocalUserPasswords() { + if cfg.passwordTemplateParser == nil { + return + } + + for _, user := range cfg.AuthLocalUsers.Users { + if user.Password != "" { + // Parse password as template to support environment variables and other template values + // Note: .CurrentEntity is nil in this context as local users are not entity-bound + user.Password = cfg.passwordTemplateParser(user.Password, nil) + } + } +} + func getActionID(action *Action) string { if action.ID == "" { return uuid.NewString() diff --git a/service/internal/entities/entities.go b/service/internal/entities/entities.go index 639a4a9..386fb20 100644 --- a/service/internal/entities/entities.go +++ b/service/internal/entities/entities.go @@ -135,7 +135,7 @@ func loadEntityFileYaml(filename string, entityname string) { } func updateSvFromFile(entityname string, data []map[string]any) { - ClearEntities(entityname) + ClearEntitiesOfType(entityname) for i, mapp := range data { AddEntity(entityname, fmt.Sprintf("%d", i), mapp) diff --git a/service/internal/entities/storage.go b/service/internal/entities/storage.go index 53cdbbc..1ae9780 100644 --- a/service/internal/entities/storage.go +++ b/service/internal/entities/storage.go @@ -10,72 +10,31 @@ package entities */ import ( - "os" "strings" "sync" - - "github.com/OliveTin/OliveTin/internal/installationinfo" ) type entityInstancesByKey map[string]*Entity -type entitiesByClass map[string]entityInstancesByKey - -type variableBase struct { - OliveTin installationInfo - Entities entitiesByClass - - CurrentEntity interface{} - Arguments map[string]string - Env map[string]string -} - -type installationInfo struct { - Build *installationinfo.BuildInfo - Runtime *installationinfo.RuntimeInfo -} +type EntitiesByClass map[string]entityInstancesByKey var ( - contents *variableBase rwmutex = sync.RWMutex{} + Entities EntitiesByClass ) func init() { rwmutex.Lock() - - envMap := make(map[string]string) - for _, env := range os.Environ() { - parts := strings.SplitN(env, "=", 2) - if len(parts) == 2 { - envMap[parts[0]] = parts[1] - } - } - - contents = &variableBase{ - OliveTin: installationInfo{ - Build: installationinfo.Build, - Runtime: installationinfo.Runtime, - }, - Entities: make(entitiesByClass, 0), - Env: envMap, - } - + Entities = make(EntitiesByClass, 0) rwmutex.Unlock() } -func GetAll() *variableBase { - rwmutex.RLock() - defer rwmutex.RUnlock() - - return contents -} - -func GetEntities() entitiesByClass { +func GetEntities() EntitiesByClass { rwmutex.RLock() - copiedEntities := make(entitiesByClass, len(contents.Entities)) + copiedEntities := make(EntitiesByClass, len(Entities)) - for entityName, entityInstances := range contents.Entities { + for entityName, entityInstances := range Entities { copiedInstances := make(entityInstancesByKey, len(entityInstances)) for key, entity := range entityInstances { @@ -93,7 +52,7 @@ func GetEntityInstances(entityName string) entityInstancesByKey { rwmutex.RLock() defer rwmutex.RUnlock() - if entities, ok := contents.Entities[entityName]; ok { + if entities, ok := Entities[entityName]; ok { copiedInstances := make(entityInstancesByKey, len(entities)) for key, entity := range entities { @@ -108,11 +67,11 @@ func GetEntityInstances(entityName string) entityInstancesByKey { func AddEntity(entityName string, entityKey string, data any) { rwmutex.Lock() - if _, ok := contents.Entities[entityName]; !ok { - contents.Entities[entityName] = make(entityInstancesByKey, 0) + if _, ok := Entities[entityName]; !ok { + Entities[entityName] = make(entityInstancesByKey, 0) } - contents.Entities[entityName][entityKey] = &Entity{ + Entities[entityName][entityKey] = &Entity{ Data: data, UniqueKey: entityKey, Title: findEntityTitle(data), @@ -144,3 +103,10 @@ func findEntityTitle(data any) string { return "Untitled Entity" } + +func ClearEntitiesOfType(entityType string) { + rwmutex.Lock() + defer rwmutex.Unlock() + + delete(Entities, entityType) +} diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index 843ea16..89d984e 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -3,6 +3,7 @@ package executor import ( config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/tpl" log "github.com/sirupsen/logrus" "fmt" @@ -75,7 +76,7 @@ func parseSingleExec(a string, values map[string]string, entity *entities.Entity if err != nil { return "", err } - return entities.ParseTemplateWithArgs(arg, entity, values), nil + return tpl.ParseTemplateWithArgs(arg, entity, values), nil } func validateArguments(values map[string]string, action *config.Action) error { @@ -117,7 +118,7 @@ func parseActionArguments(values map[string]string, action *config.Action, entit }).Debugf("Arg assigned") } - parsedShellCommand := entities.ParseTemplateWithArgs(rawShellCommand, entity, values) + parsedShellCommand := tpl.ParseTemplateWithArgs(rawShellCommand, entity, values) redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values) if err != nil { @@ -256,7 +257,7 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error { templateChoice := arg.Choices[0].Value for _, ent := range entities.GetEntityInstances(arg.Entity) { - choice := entities.ParseTemplateWith(templateChoice, ent) + choice := tpl.ParseTemplateWith(templateChoice, ent) if value == choice { return nil diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 21f862b..b9d7f77 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -6,6 +6,7 @@ import ( 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/tpl" "github.com/google/uuid" log "github.com/sirupsen/logrus" @@ -728,7 +729,7 @@ func stepRequestAction(req *ExecutionRequest) bool { req.logEntry.Binding = req.Binding req.logEntry.ActionConfigTitle = req.Binding.Action.Title - req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity) + req.logEntry.ActionTitle = tpl.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity) req.logEntry.ActionIcon = req.Binding.Action.Icon req.logEntry.Tags = req.Tags diff --git a/service/internal/entities/templates.go b/service/internal/tpl/templates.go similarity index 65% rename from service/internal/entities/templates.go rename to service/internal/tpl/templates.go index 21db7c0..a0aea20 100644 --- a/service/internal/entities/templates.go +++ b/service/internal/tpl/templates.go @@ -1,19 +1,75 @@ -package entities +package tpl import ( "fmt" + "os" "regexp" "strings" "text/template" + "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/installationinfo" log "github.com/sirupsen/logrus" ) var tpl = template.New("tpl") +type olivetinInfo struct { + Build *installationinfo.BuildInfo + Runtime *installationinfo.RuntimeInfo +} + var legacyArgumentRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+) }}`) var legacyEntityPropertiesRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.([a-zA-Z0-9_\.]+) }}`) +type generalTemplateContext struct { + OliveTin olivetinInfo + Env map[string]string +} + +type actionTemplateContext struct { + CurrentEntity interface{} + Arguments map[string]string + + // These are deliberately repeated because embedding structs + // won't work in text/template. + OliveTin olivetinInfo + Env map[string]string +} + +var ( + cachedOliveTinInfo olivetinInfo + cachedEnvMap map[string]string +) + +func init() { + cachedOliveTinInfo = olivetinInfo{ + Build: installationinfo.Build, + Runtime: installationinfo.Runtime, + } + + cachedEnvMap = buildEnvMap() +} + +func GetNewGeneralTemplateContext() *generalTemplateContext { + return &generalTemplateContext{ + OliveTin: cachedOliveTinInfo, + Env: cachedEnvMap, + } +} + +func buildEnvMap() map[string]string { + envMap := make(map[string]string) + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + envMap[parts[0]] = parts[1] + } + } + + return envMap +} + func migrateLegacyEntityProperties(rawShellCommand string) string { foundArgumentNames := legacyEntityPropertiesRegex.FindAllStringSubmatch(rawShellCommand, -1) @@ -68,7 +124,7 @@ func migrateLegacyArgumentNames(rawShellCommand string) string { return rawShellCommand } -func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string { +func ParseTemplateWithArgs(source string, ent *entities.Entity, args map[string]string) string { source = migrateLegacyArgumentNames(source) source = migrateLegacyEntityProperties(source) @@ -90,11 +146,12 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s entdata = ent.Data } - templateVariables := &variableBase{ - OliveTin: GetAll().OliveTin, + templateVariables := &actionTemplateContext{ + OliveTin: cachedOliveTinInfo, + Env: cachedEnvMap, + Arguments: args, CurrentEntity: entdata, - Env: GetAll().Env, } var sb strings.Builder @@ -114,21 +171,14 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s return ret } -func ParseTemplateWith(source string, ent *Entity) string { +func ParseTemplateWith(source string, ent *entities.Entity) string { return ParseTemplateWithArgs(source, ent, nil) } -func ParseTemplateBoolWith(source string, ent *Entity) bool { +func ParseTemplateBoolWith(source string, ent *entities.Entity) bool { source = strings.TrimSpace(source) tplBool := ParseTemplateWith(source, ent) return tplBool == "true" } - -func ClearEntities(entityType string) { - rwmutex.Lock() - defer rwmutex.Unlock() - - delete(contents.Entities, entityType) -}