mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-21 18:31:24 +02:00
Merge branch 'main' into dev
This commit is contained in:
commit
4e6b14a467
113
README.md
113
README.md
@ -264,59 +264,31 @@ Glance can also be installed through the following 3rd party channels:
|
||||
|
||||
<br>
|
||||
|
||||
## Building from source
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
## Common issues
|
||||
<details>
|
||||
<summary><strong>Build binary with Go</strong></summary>
|
||||
<br>
|
||||
<summary><strong>Requests timing out</strong></summary>
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
||||
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
||||
|
||||
To build the project for your current OS and architecture, run:
|
||||
|
||||
```bash
|
||||
go build -o build/glance .
|
||||
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
||||
```yaml
|
||||
networks:
|
||||
podman:
|
||||
external: true
|
||||
```
|
||||
|
||||
To build for a specific OS and architecture, run:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
||||
```
|
||||
|
||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
||||
|
||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
||||
<br>
|
||||
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
||||
|
||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
||||
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
||||
</details>
|
||||
|
||||
To build the project and image using just Docker, run:
|
||||
<details>
|
||||
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
||||
|
||||
*(replace `owner` with your name or organization)*
|
||||
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
||||
|
||||
```bash
|
||||
docker build -t owner/glance:latest .
|
||||
```
|
||||
|
||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
||||
|
||||
```bash
|
||||
docker push owner/glance:latest
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
@ -375,6 +347,63 @@ Feature requests are tagged with one of the following:
|
||||
|
||||
<br>
|
||||
|
||||
## Building from source
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
<details>
|
||||
<summary><strong>Build binary with Go</strong></summary>
|
||||
<br>
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
||||
|
||||
To build the project for your current OS and architecture, run:
|
||||
|
||||
```bash
|
||||
go build -o build/glance .
|
||||
```
|
||||
|
||||
To build for a specific OS and architecture, run:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
||||
```
|
||||
|
||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
||||
|
||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
||||
<br>
|
||||
|
||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
To build the project and image using just Docker, run:
|
||||
|
||||
*(replace `owner` with your name or organization)*
|
||||
|
||||
```bash
|
||||
docker build -t owner/glance:latest .
|
||||
```
|
||||
|
||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
||||
|
||||
```bash
|
||||
docker push owner/glance:latest
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Contributing guidelines
|
||||
|
||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
||||
|
@ -10,7 +10,7 @@
|
||||
- [Document](#document)
|
||||
- [Branding](#branding)
|
||||
- [Theme](#theme)
|
||||
- [Themes](#themes)
|
||||
- [Available themes](#available-themes)
|
||||
- [Pages & Columns](#pages--columns)
|
||||
- [Widgets](#widgets)
|
||||
- [RSS](#rss)
|
||||
@ -307,7 +307,7 @@ theme:
|
||||
contrast-multiplier: 1.1
|
||||
```
|
||||
|
||||
### Themes
|
||||
### Available themes
|
||||
If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for.
|
||||
|
||||
### Properties
|
||||
@ -1335,6 +1335,9 @@ Examples:
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| url | string | yes | |
|
||||
| headers | key (string) & value (string) | no | |
|
||||
| method | string | no | GET |
|
||||
| body-type | string | no | json |
|
||||
| body | any | no | |
|
||||
| frameless | boolean | no | false |
|
||||
| allow-insecure | boolean | no | false |
|
||||
| template | string | yes | |
|
||||
@ -1353,6 +1356,31 @@ headers:
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
##### `method`
|
||||
The HTTP method to use when making the request. Possible values are `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` and `HEAD`.
|
||||
|
||||
##### `body-type`
|
||||
The type of the body that will be sent with the request. Possible values are `json`, and `string`.
|
||||
|
||||
##### `body`
|
||||
The body that will be sent with the request. It can be a string or a map. Example:
|
||||
|
||||
```yaml
|
||||
body-type: json
|
||||
body:
|
||||
key1: value1
|
||||
key2: value2
|
||||
multiple-items:
|
||||
- item1
|
||||
- item2
|
||||
```
|
||||
|
||||
```yaml
|
||||
body-type: string
|
||||
body: |
|
||||
key1=value1&key2=value2
|
||||
```
|
||||
|
||||
##### `frameless`
|
||||
When set to `true`, removes the border and padding around the widget.
|
||||
|
||||
@ -1428,6 +1456,7 @@ Display a widget provided by an external source (3rd party). If you want to lear
|
||||
| url | string | yes | |
|
||||
| fallback-content-type | string | no | |
|
||||
| allow-potentially-dangerous-html | boolean | no | false |
|
||||
| headers | key & value | no | |
|
||||
| parameters | key & value | no | |
|
||||
|
||||
##### `url`
|
||||
@ -1436,6 +1465,14 @@ The URL of the extension. **Note that the query gets stripped from this URL and
|
||||
##### `fallback-content-type`
|
||||
Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
|
||||
|
||||
##### `headers`
|
||||
Optionally specify the headers that will be sent with the request. Example:
|
||||
|
||||
```yaml
|
||||
headers:
|
||||
x-api-key: ${SECRET_KEY}
|
||||
```
|
||||
|
||||
##### `allow-potentially-dangerous-html`
|
||||
Whether to allow the extension to display HTML.
|
||||
|
||||
|
@ -325,13 +325,24 @@ The following helper functions provided by Glance are available:
|
||||
- `toFloat(i int) float`: Converts an integer to a float.
|
||||
- `toInt(f float) int`: Converts a float to an integer.
|
||||
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly".
|
||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
||||
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
||||
- `add(a, b float) float`: Adds two numbers.
|
||||
- `sub(a, b float) float`: Subtracts two numbers.
|
||||
- `mul(a, b float) float`: Multiplies two numbers.
|
||||
- `div(a, b float) float`: Divides two numbers.
|
||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
||||
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
||||
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
||||
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
||||
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
||||
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
||||
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
||||
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
||||
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
||||
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||
|
||||
The following helper functions provided by Go's `text/template` are available:
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user