Files
gronod 6926a91c80
Build and publish / Test and build (darwin) (pull_request) Successful in 1m52s
Build and publish / Test and build (linux) (pull_request) Successful in 2m30s
Build and publish / Test and build (windows) (pull_request) Successful in 3m7s
Build and publish / Test and build (windows) (push) Successful in 3m7s
Build and publish / Test and build (darwin) (push) Successful in 1m45s
Build and publish / Test and build (linux) (push) Successful in 2m12s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m49s
Build and publish / Build and publish Docker image (push) Successful in 2m4s
Fix M7 admin/server wire contracts
2026-09-19 18:36:03 +01:00

167 lines
4.1 KiB
Go

package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
)
const (
maxLogBodyBytes = 512 * 1024
defaultLogLimit = 100
maxLogLimit = 500
)
// logFileID is the opaque identifier exposed for a log file name;
// it can only map back to a name that appears in a live listing.
func logFileID(name string) string {
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:16])
}
// logFileName extracts the upstream file name from a listing entry.
func logFileName(m map[string]any) string {
return jstr(m, "fileName", "filename", "name")
}
// logFileNames decodes a listing whose elements are bare file names on Ombi
// 4.53.x. Older deployments that return objects with a name field remain
// supported. Entries with no usable name are ignored.
func (o *op) logFileNames(raw []byte) ([]string, *ToolResult) {
var els []json.RawMessage
if err := json.Unmarshal(raw, &els); err != nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response was not a JSON array: "+sanitizeErr(err), false)
}
names := []string{}
for _, el := range els {
var name string
if err := json.Unmarshal(el, &name); err == nil {
if name != "" {
names = append(names, name)
}
continue
}
var record map[string]any
if err := json.Unmarshal(el, &record); err == nil {
if name := logFileName(record); name != "" {
names = append(names, name)
}
}
}
return names, nil
}
// read_logs — list sanitized log file IDs or read a bounded,
// sanitized slice of a single log file.
func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_logs", false)
var a LogsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.logsList()
case "read":
return o.logsRead(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) logsList() *ToolResult {
raw, fail := o.call("GET", "/api/v2/System/logs", nil, nil)
if fail != nil {
return fail
}
names, fail := o.logFileNames(raw)
if fail != nil {
return fail
}
sort.Strings(names)
out := &Logs{Kind: "logs", Files: []LogFile{}}
for _, n := range names {
out.Files = append(out.Files, LogFile{FileID: logFileID(n), Name: n})
}
if len(out.Files) > 100 {
out.Files = out.Files[:100]
o.truncated = true
out.Truncated = true
}
return o.ok(out)
}
func (o *op) logsRead(a *LogsArgs) *ToolResult {
if !nonempty(a.FileID) {
return o.invalid("file_id", "file_id is required")
}
limit := defaultLogLimit
if a.Limit != nil {
limit = *a.Limit
}
if limit < 1 || limit > maxLogLimit {
return o.invalid("limit", "limit must be between 1 and %d", maxLogLimit)
}
offset := 0
if a.Offset != nil {
offset = *a.Offset
}
if offset < 0 {
return o.invalid("offset", "offset must be a non-negative integer")
}
// Resolve the opaque ID to a live listing name — a file_id that
// does not match any current file is rejected, never traversed.
raw, fail := o.call("GET", "/api/v2/System/logs", nil, nil)
if fail != nil {
return fail
}
names, fail := o.logFileNames(raw)
if fail != nil {
return fail
}
name := ""
for _, n := range names {
if logFileID(n) == a.FileID {
name = n
break
}
}
if name == "" {
return o.fail("NOT_FOUND", "no log file matches the supplied file_id", false)
}
raw, fail = o.call("GET", "/api/v2/System/logs/"+seg(name), nil, nil)
if fail != nil {
return fail
}
if len(raw) > maxLogBodyBytes {
raw = raw[:maxLogBodyBytes]
o.truncated = true
}
// Sanitize individual lines so control characters and unbounded text do
// not leak while preserving the upstream line boundaries for pagination.
lines := strings.Split(string(raw), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
for i := range lines {
lines[i] = sanitizeText(lines[i], maxLogBodyBytes)
}
out := &Logs{Kind: "logs", Lines: []string{}, Offset: &offset}
if offset < len(lines) {
end := offset + limit
if end > len(lines) {
end = len(lines)
}
out.Lines = lines[offset:end]
if end < len(lines) {
next := end
out.NextOffset = &next
}
}
return o.ok(out)
}