446 lines
14 KiB
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"})
|
|
}
|