Files
ombi-mcp/internal/tools/settings.go

446 lines
14 KiB
Go

package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// Section → upstream GET/POST paths from docs/schema/07-settings-types.md.
var settingsSections = map[string]string{
"custom_page": "/api/v1/CustomPage",
"ombi": "/api/v1/Settings/ombi",
"plex": "/api/v1/Settings/plex",
"emby": "/api/v1/Settings/emby",
"jellyfin": "/api/v1/Settings/jellyfin",
"landingpage": "/api/v1/Settings/landingpage",
"customization": "/api/v1/Settings/customization",
"sonarr": "/api/v1/Settings/sonarr",
"radarr": "/api/v1/Settings/radarr",
"lidarr": "/api/v1/Settings/lidarr",
"authentication": "/api/v1/Settings/authentication",
"update": "/api/v1/Settings/Update",
"user_management": "/api/v1/Settings/UserManagement",
"couchpotato": "/api/v1/Settings/CouchPotato",
"dognzb": "/api/v1/Settings/DogNzb",
"sickrage": "/api/v1/Settings/SickRage",
"jobs": "/api/v1/Settings/jobs",
"issues": "/api/v1/Settings/Issues",
"vote": "/api/v1/Settings/vote",
"themoviedb": "/api/v1/Settings/themoviedb",
"notifications.email": "/api/v1/Settings/notifications/email",
"notifications.discord": "/api/v1/Settings/notifications/discord",
"notifications.telegram": "/api/v1/Settings/notifications/telegram",
"notifications.pushbullet": "/api/v1/Settings/notifications/pushbullet",
"notifications.pushover": "/api/v1/Settings/notifications/pushover",
"notifications.slack": "/api/v1/Settings/notifications/slack",
"notifications.mattermost": "/api/v1/Settings/notifications/mattermost",
"notifications.twilio": "/api/v1/Settings/notifications/twilio",
"notifications.mobile": "/api/v1/Settings/notifications/mobile",
"notifications.gotify": "/api/v1/Settings/notifications/gotify",
"notifications.ntfy": "/api/v1/Settings/notifications/ntfy",
"notifications.webhook": "/api/v1/Settings/notifications/webhook",
"notifications.newsletter": "/api/v1/Settings/notifications/newsletter",
// Read-only sections.
"base_url": "/api/v1/Settings/baseurl",
"client_id": "/api/v1/Settings/clientid",
"default_language": "/api/v1/Settings/defaultlanguage",
"themes": "/api/v1/Settings/themes",
"lidarrenabled": "/api/v1/Settings/lidarrenabled",
"issuesenabled": "/api/v1/Settings/issuesenabled",
"voteenabled": "/api/v1/Settings/voteenabled",
"notifications.email.enabled": "/api/v1/Settings/notifications/email/enabled",
}
// readOnlySections have no PATCH contract; they are never writable.
var readOnlySections = map[string]bool{
"base_url": true,
"client_id": true,
"default_language": true,
"themes": true,
"lidarrenabled": true,
"issuesenabled": true,
"voteenabled": true,
"notifications.email.enabled": true,
}
// excludedSettingsFields are removed recursively from projections and
// rejected in patches (07-settings-types.md).
var excludedSettingsFields = map[string]bool{
"accessToken": true, "accountSid": true, "administratorId": true,
"apiKey": true, "applicationToken": true, "applicationUrl": true,
"authToken": true, "authorizationHeader": true, "baseUrl": true,
"botApi": true, "customDonationUrl": true,
"disableCertificateChecking": true, "disableTLS": true,
"favicon": true, "hasMigratedOldTvDbData": true, "host": true,
"iconUrl": true, "id": true, "installId": true, "ip": true,
"logo": true, "machineIdentifier": true, "password": true,
"plexAuthToken": true, "port": true, "processName": true,
"scriptLocation": true, "serverHostname": true, "serverId": true,
"set": true, "ssl": true, "subDir": true, "useScript": true,
"userToken": true, "webhookUrl": true, "windowsService": true,
"windowsServiceName": true, "wizard": true,
}
// secretish reports additional credential-looking names excluded
// from read projections (allowlist caution beyond the patch list).
func secretish(name string) bool {
l := strings.ToLower(name)
for _, p := range []string{"token", "secret", "password", "apikey",
"api_key", "connectionstring", "privatekey", "credential"} {
if strings.Contains(l, p) {
return true
}
}
return false
}
// read_settings — allowlisted section projection with an opaque
// revision digest for safe patching.
func handleSettingsRead(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_settings", false)
var a SettingsReadArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
path, ok := settingsSections[a.Section]
if !ok {
return o.invalid("section", "unsupported section %q", a.Section)
}
raw2, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
var doc any
if fail := o.decodeJSON(raw2, &doc); fail != nil {
return fail
}
out := &Settings{Kind: "settings", Section: a.Section,
Values: []Change{}, OmittedFields: []string{}}
var omitted []string
flattenSettings(doc, "", &out.Values, &omitted)
out.OmittedFields = omitted
// Revision is offered only for sections with a safe patch route.
if !readOnlySections[a.Section] {
out.Revision = revisionOf(raw2)
}
return o.ok(out)
}
// revisionOf digests the raw section body — an opaque, section-bound
// compare token for the patch path.
func revisionOf(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:16])
}
func isDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// serverIdentityLeaf reports whether key is a server record identity
// field directly under /servers/<digits>.
func serverIdentityLeaf(parentPath, key string) bool {
switch key {
case "id", "serverId", "machineIdentifier":
default:
return false
}
rest, ok := strings.CutPrefix(parentPath, "/servers/")
if !ok {
return false
}
return isDigits(rest)
}
// flattenSettings walks a decoded settings document emitting one
// scalar leaf per Change with escaped-JSON-Pointer-style names.
// Excluded and secret-looking fields land in omitted, never values,
// with a scoped exemption for server identity fields.
func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
child := path + "/" + escapePointer(k)
if (excludedSettingsFields[k] || secretish(k)) && !serverIdentityLeaf(path, k) {
*omitted = append(*omitted, child)
continue
}
flattenSettings(t[k], child, out, omitted)
}
case []any:
for i, e := range t {
flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted)
}
case string, float64, bool, int, int64:
name := path
if name == "" {
name = "/"
}
*out = append(*out, Change{Name: name, Value: t})
case nil:
// Absent/null leaves are not projected.
}
if len(*out) > 100 {
*out = (*out)[:100]
}
}
func escapePointer(s string) string {
s = strings.ReplaceAll(s, "~", "~0")
return strings.ReplaceAll(s, "/", "~1")
}
// write_settings_patch — private GET, revision verify, typed merge
// preserving omitted/secret fields, full-model POST.
func handleSettingsPatch(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_settings_patch", true)
var a SettingsPatchArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "patch":
return o.settingsPatch(&a)
case "feature":
return o.settingsFeature(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) settingsPatch(a *SettingsPatchArgs) *ToolResult {
path, ok := settingsSections[a.Section]
if !ok {
return o.invalid("section", "unsupported section %q", a.Section)
}
if readOnlySections[a.Section] {
return o.invalid("section", "section %q is read-only", a.Section)
}
if !nonempty(a.Revision) {
return o.invalid("revision", "revision is required")
}
if len(a.Changes) == 0 {
return o.invalid("changes", "changes must contain at least one property")
}
if bad := findExcluded(a.Changes); bad != "" {
return o.invalid("changes", "field %q is not patchable", bad)
}
if bad := findNull(a.Changes); bad != "" {
return o.invalid("changes", "field %q may not be null", bad)
}
// Serialize read-modify-write cycles per instance.
o.env.settingsMu.Lock()
defer o.env.settingsMu.Unlock()
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
// An incomplete original means save is unsupported.
var orig map[string]any
if err := json.Unmarshal(raw, &orig); err != nil || orig == nil {
return o.fail("UNSUPPORTED_CAPABILITY",
"the saved section is not a complete object; patch is unsupported", false)
}
if revisionOf(raw) != a.Revision {
return o.fail("CONFLICT",
"settings revision is stale; re-read the section before patching", false)
}
changes := translateNotificationChanges(a.Changes)
merged, fail := mergeSettings(orig, changes)
if fail != nil {
return fail
}
if reflect.DeepEqual(orig, merged) {
return o.invalid("changes", "patch makes no effective change")
}
raw2, fail := o.call("POST", path, nil, merged)
if fail != nil {
return fail
}
// Most section POSTs return a boolean; jobs returns a result object.
if b, fail2 := o.decodeBool(raw2); fail2 == nil {
if !b {
return o.fail("UPSTREAM_REJECTED", "upstream rejected the settings save", false)
}
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed",
UpstreamResult: &b})
}
m, fail := o.decodeObject(raw2)
if fail != nil {
return fail
}
out := &Mutation{Kind: "mutation", Outcome: "completed"}
if v := jbool(m, "result"); v != nil {
out.UpstreamResult = v
if !*v {
return o.fail("UPSTREAM_REJECTED",
sanitizeText(jstr(m, "message"), maxSanitizedMsg), false)
}
}
return o.ok(out)
}
// findExcluded walks patch keys recursively for excluded names.
func findExcluded(m map[string]any) string {
for k, v := range m {
if excludedSettingsFields[k] || secretish(k) {
return k
}
if sub, ok := v.(map[string]any); ok {
if bad := findExcluded(sub); bad != "" {
return bad
}
}
if arr, ok := v.([]any); ok {
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
if bad := findExcluded(em); bad != "" {
return bad
}
}
}
}
}
return ""
}
// findNull walks patch values recursively rejecting JSON null.
func findNull(m map[string]any) string {
for k, v := range m {
if v == nil {
return k
}
if sub, ok := v.(map[string]any); ok {
if bad := findNull(sub); bad != "" {
return bad
}
}
}
return ""
}
// translateNotificationChanges rewrites notification_type/agent
// labels inside notificationTemplates elements into upstream
// integers before merging (07-settings-types.md).
func translateNotificationChanges(changes map[string]any) map[string]any {
out := make(map[string]any, len(changes))
for k, v := range changes {
if k == "notificationTemplates" {
if arr, ok := v.([]any); ok {
newArr := make([]any, 0, len(arr))
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
newArr = append(newArr, translateTemplateLabels(em))
continue
}
newArr = append(newArr, e)
}
out[k] = newArr
continue
}
}
out[k] = v
}
return out
}
func translateTemplateLabels(el map[string]any) map[string]any {
out := make(map[string]any, len(el))
for k, v := range el {
switch k {
case "notification_type":
if s, ok := v.(string); ok {
if n, err := translate.NotifTypeToWire(s); err == nil {
out["notificationType"] = n
continue
}
}
out[k] = v
case "agent":
if s, ok := v.(string); ok {
if n, err := translate.AgentToWire(s); err == nil {
out["agent"] = n
continue
}
}
out[k] = v
default:
out[k] = v
}
}
return out
}
// mergeSettings deep-merges a validated patch into the original
// document: nested objects merge by property, arrays replace whole,
// scalars overwrite. Omitted fields are preserved automatically.
func mergeSettings(orig map[string]any, changes map[string]any) (map[string]any, *ToolResult) {
merged := make(map[string]any, len(orig))
for k, v := range orig {
merged[k] = v
}
for k, v := range changes {
if sub, ok := v.(map[string]any); ok {
if len(sub) == 0 {
// An empty nested object is a no-op, not a deletion.
continue
}
base, _ := merged[k].(map[string]any)
if base == nil {
base = map[string]any{}
}
m, err := mergeSettings(base, sub)
if err != nil {
return nil, err
}
merged[k] = m
continue
}
merged[k] = v // scalars overwrite; arrays replace whole
}
return merged, nil
}
// settingsFeature — POST {name, enabled} to the enable/disable
// feature route selected by the boolean.
func (o *op) settingsFeature(a *SettingsPatchArgs) *ToolResult {
if !nonempty(a.Name) {
return o.invalid("name", "name is required")
}
if a.Enabled == nil {
return o.invalid("enabled", "enabled is required")
}
route := "enable"
if !*a.Enabled {
route = "disable"
}
_, fail := o.call("POST", "/api/v2/Features/"+route, nil,
ombi.FeatureEnablement{Name: a.Name, Enabled: *a.Enabled})
if fail != nil {
return fail
}
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed"})
}