Merge branch 'main' into dev

This commit is contained in:
Svilen Markov
2025-03-26 19:46:16 +00:00
7 changed files with 286 additions and 62 deletions

View File

@@ -103,7 +103,7 @@ func runDiagnostic() {
fmt.Println("Glance version: " + buildVersion)
fmt.Println("Go version: " + runtime.Version())
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
fmt.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no"))
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
@@ -129,7 +129,7 @@ func runDiagnostic() {
fmt.Printf(
"%s %s %s| %dms\n",
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
ternary(step.err == nil, "✓ Can", "✗ Can't"),
step.name,
extraInfo,
step.elapsed.Milliseconds(),

View File

@@ -119,14 +119,6 @@ func parseRFC3339Time(t string) time.Time {
return parsed
}
func boolToString(b bool, trueValue, falseValue string) string {
if b {
return trueValue
}
return falseValue
}
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))

View File

@@ -3,6 +3,7 @@ package glance
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
@@ -10,6 +11,9 @@ import (
"log/slog"
"math"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -21,10 +25,14 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.
// Needs to be exported for the YAML unmarshaler to work
type CustomAPIRequest struct {
URL string `json:"url"`
AllowInsecure bool `json:"allow-insecure"`
Headers map[string]string `json:"headers"`
Parameters queryParametersField `json:"parameters"`
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
Headers map[string]string `yaml:"headers"`
Parameters queryParametersField `yaml:"parameters"`
Method string `yaml:"method"`
BodyType string `yaml:"body-type"`
Body any `yaml:"body"`
bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
}
@@ -83,7 +91,41 @@ func (req *CustomAPIRequest) initialize() error {
return errors.New("URL is required")
}
httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil)
if req.Body != nil {
if req.Method == "" {
req.Method = http.MethodPost
}
if req.BodyType == "" {
req.BodyType = "json"
}
if req.BodyType != "json" && req.BodyType != "string" {
return errors.New("invalid body type, must be either 'json' or 'string'")
}
switch req.BodyType {
case "json":
encoded, err := json.Marshal(req.Body)
if err != nil {
return fmt.Errorf("marshaling body: %v", err)
}
req.bodyReader = bytes.NewReader(encoded)
case "string":
bodyAsString, ok := req.Body.(string)
if !ok {
return errors.New("body must be a string when body-type is 'string'")
}
req.bodyReader = strings.NewReader(bodyAsString)
}
} else if req.Method == "" {
req.Method = http.MethodGet
}
httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader)
if err != nil {
return err
}
@@ -92,6 +134,10 @@ func (req *CustomAPIRequest) initialize() error {
httpReq.URL.RawQuery = req.Parameters.toQueryString()
}
if req.BodyType == "json" {
httpReq.Header.Set("Content-Type", "application/json")
}
for key, value := range req.Headers {
httpReq.Header.Add(key, value)
}
@@ -126,6 +172,10 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData
}
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
if req.bodyReader != nil {
req.bodyReader.Seek(0, io.SeekStart)
}
client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
resp, err := client.Do(req.httpRequest.WithContext(ctx))
if err != nil {
@@ -293,6 +343,22 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
}
var customAPITemplateFuncs = func() template.FuncMap {
var regexpCacheMu sync.Mutex
var regexpCache = make(map[string]*regexp.Regexp)
getCachedRegexp := func(pattern string) *regexp.Regexp {
regexpCacheMu.Lock()
defer regexpCacheMu.Unlock()
regex, exists := regexpCache[pattern]
if !exists {
regex = regexp.MustCompile(pattern)
regexpCache[pattern] = regex
}
return regex
}
funcs := template.FuncMap{
"toFloat": func(a int) float64 {
return float64(a)
@@ -322,6 +388,83 @@ var customAPITemplateFuncs = func() template.FuncMap {
// Shorthand to do both of the above with a single function call
return dynamicRelativeTimeAttrs(customAPIFuncParseTime(layout, value))
},
// The reason we flip the parameter order is so that you can chain multiple calls together like this:
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
// instead of doing this:
// {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }}
// since the piped value gets passed as the last argument to the function.
"trimPrefix": func(prefix, s string) string {
return strings.TrimPrefix(s, prefix)
},
"trimSuffix": func(suffix, s string) string {
return strings.TrimSuffix(s, suffix)
},
"trimSpace": strings.TrimSpace,
"replaceAll": func(old, new, s string) string {
return strings.ReplaceAll(s, old, new)
},
"findMatch": func(pattern, s string) string {
if s == "" {
return ""
}
return getCachedRegexp(pattern).FindString(s)
},
"findSubmatch": func(pattern, s string) string {
if s == "" {
return ""
}
regex := getCachedRegexp(pattern)
return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "")
},
"sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
if order == "asc" {
return results[a].String(key) < results[b].String(key)
}
return results[a].String(key) > results[b].String(key)
})
return results
},
"sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
if order == "asc" {
return results[a].Int(key) < results[b].Int(key)
}
return results[a].Int(key) > results[b].Int(key)
})
return results
},
"sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
if order == "asc" {
return results[a].Float(key) < results[b].Float(key)
}
return results[a].Float(key) > results[b].Float(key)
})
return results
},
"sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
timeA := customAPIFuncParseTime(layout, results[a].String(key))
timeB := customAPIFuncParseTime(layout, results[b].String(key))
if order == "asc" {
return timeA.Before(timeB)
}
return timeA.After(timeB)
})
return results
},
}
for key, value := range globalTemplateFunctions {
@@ -335,6 +478,13 @@ var customAPITemplateFuncs = func() template.FuncMap {
func customAPIFuncParseTime(layout, value string) time.Time {
switch strings.ToLower(layout) {
case "unix":
asInt, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return time.Unix(0, 0)
}
return time.Unix(asInt, 0)
case "rfc3339":
layout = time.RFC3339
case "rfc3339nano":
@@ -343,8 +493,6 @@ func customAPIFuncParseTime(layout, value string) time.Time {
layout = time.DateTime
case "dateonly":
layout = time.DateOnly
case "timeonly":
layout = time.TimeOnly
}
parsed, err := time.Parse(layout, value)

View File

@@ -22,6 +22,7 @@ type extensionWidget struct {
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters queryParametersField `yaml:"parameters"`
Headers map[string]string `yaml:"headers"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
@@ -46,6 +47,7 @@ func (widget *extensionWidget) update(ctx context.Context) {
URL: widget.URL,
FallbackContentType: widget.FallbackContentType,
Parameters: widget.Parameters,
Headers: widget.Headers,
AllowHtml: widget.AllowHtml,
})
@@ -85,6 +87,7 @@ type extensionRequestOptions struct {
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters queryParametersField `yaml:"parameters"`
Headers map[string]string `yaml:"headers"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
@@ -113,6 +116,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) {
request.URL.RawQuery = options.Parameters.toQueryString()
}
for key, value := range options.Headers {
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("Failed fetching extension", "url", options.URL, "error", err)