providers: a custom provider's icon can come from its website (#12) — From the website, beside Choose a picture, finds the site behind the base URL (its host, then the domains it sits under: api.deepseek.com, then deepseek.com) and keeps the icon its home page names (an SVG first, then the apple-touch-icon, data: URIs too) or its /favicon.ico, like a picture picked by hand; only public https hosts are reached. Icons are fetched as magpie, since deepseek.com turns Go's own User-Agent away
This commit is contained in:
6 files changed
+341
-1
No files matched your search
@@ -2040,9 +2040,28 @@ function iconPicker(ed) {
|
||||
};
|
||||
const choose = el("button", "text", t("Choose a picture…"));
|
||||
choose.onclick = () => file.click();
|
||||
// the site's own icon, looked for from the base URL typed above
|
||||
const site = el("button", "text", t("From the website"));
|
||||
site.title = t("Look for the icon of the site the base URL is on");
|
||||
site.onclick = async () => {
|
||||
const base = draft[apiField[draft.api]] || draft.chat || draft.responses || draft.anthropic || "";
|
||||
site.disabled = true;
|
||||
site.textContent = t("Looking…");
|
||||
try {
|
||||
const data = await api("icons/favicon", { url: base });
|
||||
draft.icon = data.icon;
|
||||
editorError("");
|
||||
draw();
|
||||
syncHead();
|
||||
} catch (e) {
|
||||
editorError(e.message);
|
||||
site.disabled = false;
|
||||
site.textContent = t("From the website");
|
||||
}
|
||||
};
|
||||
const builtin = el("button", "text", t("Built-in icons"));
|
||||
builtin.onclick = () => { open = !open; draw(); };
|
||||
box.append(file, choose, builtin);
|
||||
box.append(file, choose, site, builtin);
|
||||
if (draft.icon && draft.icon !== "generic") {
|
||||
const reset = el("button", "text", t("Default"));
|
||||
reset.onclick = () => { draft.icon = ""; draw(); syncHead(); };
|
||||
|
||||
@@ -135,6 +135,9 @@ const I18N = {
|
||||
"Icon": "图标",
|
||||
"Choose a picture…": "选择图片…",
|
||||
"Built-in icons": "内置图标",
|
||||
"From the website": "从网站获取",
|
||||
"Look for the icon of the site the base URL is on": "按上面填的 Base URL 找到对应网站的图标",
|
||||
"Looking…": "查找中…",
|
||||
"what {agent} ships with": "{agent} 自带的默认值",
|
||||
"current value": "当前值",
|
||||
"use as typed": "按输入使用",
|
||||
|
||||
@@ -353,6 +353,20 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
}
|
||||
writeJSON(rw, map[string]string{"icon": icon})
|
||||
})
|
||||
// the site's own icon, found from the provider's base URL (#12)
|
||||
mux.HandleFunc("POST /api/icons/favicon", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ URL string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
icon, err := provider.FaviconFor(r.Context(), in.URL)
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
writeJSON(rw, map[string]string{"icon": icon})
|
||||
})
|
||||
mux.HandleFunc("GET /api/icons/{name}", func(rw http.ResponseWriter, r *http.Request) {
|
||||
f := provider.IconFile(r.PathValue("name"))
|
||||
if f == "" {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FaviconFor finds the site behind a provider's base URL and keeps its icon
|
||||
// like a picture picked by hand (#12). The API's own host is asked first,
|
||||
// then the domains it sits under (api.deepseek.com, then deepseek.com),
|
||||
// since an API host seldom has a page of its own. On each, the home page's
|
||||
// <link rel="icon"> pictures come before /favicon.ico. Only public https
|
||||
// hosts are reached, as for an import link's icon.
|
||||
func FaviconFor(ctx context.Context, base string) (string, error) {
|
||||
sites, err := faviconSites(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return favicon(ctx, guardClient(), sites, iconURL)
|
||||
}
|
||||
|
||||
// faviconSites are the https origins to look on for base's icon: its host
|
||||
// with its port, then each domain above it down to two labels.
|
||||
func faviconSites(base string) ([]string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil, errors.New("type the provider's base URL first: its site's icon is looked for there")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if local(host) {
|
||||
return nil, errors.New("the base URL is on this computer or the local network; its icon can't be fetched")
|
||||
}
|
||||
sites := []string{"https://" + u.Host}
|
||||
if host != u.Host {
|
||||
sites = append(sites, "https://"+host)
|
||||
}
|
||||
if strings.Trim(host, "0123456789.") != "" && !strings.Contains(host, ":") { // not an IP
|
||||
labels := strings.Split(host, ".")
|
||||
for i := 1; len(labels)-i >= 2; i++ {
|
||||
sites = append(sites, "https://"+strings.Join(labels[i:], "."))
|
||||
}
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
// favicon tries each site in turn; check vets a picture's URL before it is
|
||||
// fetched (iconURL; tests pass one that lets a local server through).
|
||||
func favicon(ctx context.Context, c *http.Client, sites []string, check func(string) (string, error)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
var last error
|
||||
for _, site := range sites {
|
||||
cands := append(pageIcons(ctx, c, site), site+"/favicon.ico")
|
||||
for _, cand := range cands {
|
||||
if data, ok := strings.CutPrefix(cand, "data:"); ok {
|
||||
if b := dataURI(data); b != nil {
|
||||
if icon, err := StoreIcon(b); err == nil {
|
||||
return icon, nil
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
u, err := check(cand)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
icon, err := fetchIcon(ctx, c, u)
|
||||
if err == nil {
|
||||
return icon, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
last = errors.New("no icon found")
|
||||
}
|
||||
return "", errorf("couldn't find the site's icon (%s): %v", strings.Join(sites, ", "), last)
|
||||
}
|
||||
|
||||
// iconUA names magpie to the sites icons are fetched from: some (deepseek.com)
|
||||
// turn Go's own User-Agent away with a 429.
|
||||
const iconUA = "magpie"
|
||||
|
||||
var (
|
||||
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
|
||||
tagAttr = regexp.MustCompile(`(?is)\b(rel|href|sizes|type)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
|
||||
headDone = regexp.MustCompile(`(?i)</head>|<body\b`)
|
||||
)
|
||||
|
||||
// pageIcons are the pictures a site's home page names as its icon, the
|
||||
// likeliest to look good first: an SVG, then the larger apple-touch-icon,
|
||||
// then the rest in page order.
|
||||
func pageIcons(ctx context.Context, c *http.Client, site string) []string {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, site+"/", nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("User-Agent", iconUA)
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 512<<10))
|
||||
html := string(b)
|
||||
if i := headDone.FindStringIndex(html); i != nil {
|
||||
html = html[:i[0]]
|
||||
}
|
||||
var svg, touch, rest []string
|
||||
for _, tag := range linkTag.FindAllString(html, -1) {
|
||||
attrs := map[string]string{}
|
||||
for _, m := range tagAttr.FindAllStringSubmatch(tag, -1) {
|
||||
attrs[strings.ToLower(m[1])] = m[2] + m[3] + m[4]
|
||||
}
|
||||
rel := strings.Fields(strings.ToLower(attrs["rel"]))
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
isIcon, isTouch := false, false
|
||||
for _, r := range rel {
|
||||
switch r {
|
||||
case "icon":
|
||||
isIcon = true
|
||||
case "apple-touch-icon", "apple-touch-icon-precomposed":
|
||||
isTouch = true
|
||||
}
|
||||
}
|
||||
if !isIcon && !isTouch {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(href, "data:") {
|
||||
ref, err := url.Parse(href)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
href = res.Request.URL.ResolveReference(ref).String()
|
||||
}
|
||||
switch {
|
||||
case attrs["type"] == "image/svg+xml" || strings.HasSuffix(strings.ToLower(strings.SplitN(href, "?", 2)[0]), ".svg"):
|
||||
svg = append(svg, href)
|
||||
case isTouch:
|
||||
touch = append(touch, href)
|
||||
default:
|
||||
rest = append(rest, href)
|
||||
}
|
||||
}
|
||||
return append(append(svg, touch...), rest...)
|
||||
}
|
||||
|
||||
// dataURI decodes what follows "data:" in a data URI, nil when it isn't one.
|
||||
func dataURI(s string) []byte {
|
||||
meta, data, ok := strings.Cut(s, ",")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
||||
b, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
d, err := url.PathUnescape(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []byte(d)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFaviconSites(t *testing.T) {
|
||||
for base, want := range map[string][]string{
|
||||
"https://api.deepseek.com/v1": {"https://api.deepseek.com", "https://deepseek.com"},
|
||||
"https://open.bigmodel.cn/api/paas/v4": {"https://open.bigmodel.cn", "https://bigmodel.cn"},
|
||||
"https://example.com:8443/v1": {"https://example.com:8443", "https://example.com"},
|
||||
"https://openrouter.ai/api/v1": {"https://openrouter.ai"},
|
||||
} {
|
||||
if got, err := faviconSites(base); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: %v %v, want %v", base, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, base := range []string{"", "http://127.0.0.1:11434/v1", "http://localhost:1234", "http://192.168.1.2/v1"} {
|
||||
if _, err := faviconSites(base); err == nil {
|
||||
t.Errorf("%q: looked for an icon", base)
|
||||
}
|
||||
}
|
||||
// the guard itself refuses a loopback site
|
||||
if _, err := FaviconFor(context.Background(), "http://localhost:3425/v1"); err == nil {
|
||||
t.Error("a local base URL's icon was fetched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavicon(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
|
||||
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
pages := map[string]string{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if p, ok := pages[r.Host]; ok && r.URL.Path == "/" {
|
||||
rw.Write([]byte(p))
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/static/touch.png", "/favicon.ico":
|
||||
if strings.HasPrefix(r.Host, "bare.") || r.URL.Path == "/static/touch.png" {
|
||||
rw.Write(png)
|
||||
return
|
||||
}
|
||||
case "/logo.svg":
|
||||
rw.Write(svg)
|
||||
return
|
||||
}
|
||||
// a site that answers every path with its page, the icon's too
|
||||
rw.Write([]byte("<html><body>not found</body></html>"))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
port := srv.URL[strings.LastIndex(srv.URL, ":"):]
|
||||
// every site is the test server, told apart by the Host it is asked as
|
||||
c := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, srv.Listener.Addr().String())
|
||||
}}}
|
||||
site := func(h string) string { return "http://" + h + port }
|
||||
pass := func(s string) (string, error) { return s, nil }
|
||||
stored := func(icon string, want []byte) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(IconFile(strings.TrimPrefix(icon, iconPrefix)))
|
||||
if err != nil || string(b) != string(want) {
|
||||
t.Errorf("stored %q: %q %v, want %q", icon, b, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// an SVG link is taken before the touch icon, whatever the order
|
||||
pages["svg.test"+port] = `<head><link rel="apple-touch-icon" href="/static/touch.png"><link rel="icon" type="image/svg+xml" href="/logo.svg"></head>`
|
||||
icon, err := favicon(context.Background(), c, []string{site("svg.test")}, pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored(icon, svg)
|
||||
|
||||
// a relative apple-touch-icon, in single quotes and odd case
|
||||
pages["touch.test"+port] = `<HEAD><LINK REL='apple-touch-icon' HREF='static/touch.png'></HEAD>`
|
||||
if icon, err = favicon(context.Background(), c, []string{site("touch.test")}, pass); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored(icon, png)
|
||||
|
||||
// a data: URI icon (#12)
|
||||
pages["data.test"+port] = `<head><link rel="shortcut icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E"></head>`
|
||||
if icon, err = favicon(context.Background(), c, []string{site("data.test")}, pass); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored(icon, svg)
|
||||
|
||||
// the API host has nothing; the domain above it has /favicon.ico
|
||||
if icon, err = favicon(context.Background(), c, []string{site("api.none"), site("bare.test")}, pass); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored(icon, png)
|
||||
|
||||
// a page answered for the icon is not a picture: nothing is found
|
||||
if _, err = favicon(context.Background(), c, []string{site("api.none")}, pass); err == nil {
|
||||
t.Error("an HTML page was kept as the icon")
|
||||
}
|
||||
// a link in the body is not the page's icon
|
||||
pages["body.test"+port] = `<head></head><body><link rel="icon" href="/logo.svg"></body>`
|
||||
if _, err = favicon(context.Background(), c, []string{site("body.test")}, pass); err == nil {
|
||||
t.Error("a link in the body was taken")
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ func fetchIcon(ctx context.Context, c *http.Client, u string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "image/*")
|
||||
req.Header.Set("User-Agent", iconUA)
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return "", errorf("could not fetch the icon: %v", err)
|
||||
|
||||
Reference in new issue
Block a user