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
167 lines
4.1 KiB
Go
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)
|
|
}
|