├── VERSION ├── Procfile ├── .gitignore ├── ico ├── broken.ico ├── addthis.ico ├── favicon.ico ├── github.ico ├── wowhead.ico ├── besticon.ico ├── codeplex.ico ├── icoparser │ └── icoparser.go ├── ico_test.go └── ico.go ├── besticon.paw ├── .licensed.yml ├── the-icon-finder.png ├── .gitattributes ├── besticon ├── testdata │ ├── mat.jpg │ ├── pixel.gif │ ├── pixel.jpg │ ├── pixel.png │ ├── rose.bmp │ ├── aol.com.vcr │ ├── ard.de.vcr │ ├── favicon.ico │ ├── car2go.com.vcr │ ├── eat24.com.vcr │ ├── github.com.vcr │ ├── random.org.vcr │ ├── alibaba.com.vcr │ ├── archive.org.vcr │ ├── example.com.vcr │ ├── kicktipp.de.vcr │ ├── netflix.com.vcr │ ├── xing.com_443.vcr │ ├── youtube.com.vcr │ ├── aws.amazon.com.vcr │ ├── mortenmøller.dk.vcr │ ├── printables.com.vcr │ ├── www.dnevnik.bg.vcr │ ├── daringfireball.net.vcr │ ├── storage.googleapis.com.vcr │ ├── svg.svg │ └── websites.txt ├── iconserver │ ├── assets │ │ ├── favicon.ico │ │ ├── 152x152_icon.png │ │ ├── apple-touch-icon.png │ │ ├── assets.go │ │ ├── main-min.css │ │ ├── icon.svg │ │ ├── not_found.html │ │ ├── main.css │ │ ├── popular.html │ │ ├── icons.html │ │ ├── index.html │ │ └── grids-responsive-0.5.0-min.css │ ├── prometheus.go │ ├── logging.go │ ├── server_test.go │ └── server.go ├── version.go ├── besticon │ └── cmd.go ├── sorting.go ├── size_range.go ├── logger.go ├── options.go ├── extract_test.go ├── caching.go ├── http.go ├── extract.go ├── besticon.go └── besticon_test.go ├── .github ├── FUNDING.yml ├── SECURITY.md └── workflows │ ├── go.yml │ ├── docker.yml │ └── codeql-analysis.yml ├── colorfinder ├── testdata │ ├── black1x1.png │ ├── icon01.png.gz │ ├── icon02.png.gz │ ├── icon03.png.gz │ ├── icon04.png.gz │ ├── icon05.png.gz │ ├── icon06.png.gz │ ├── icon07.png.gz │ ├── icon08.ico.gz │ ├── icon09.ico.gz │ ├── icon10.ico.gz │ ├── icon11.ico.gz │ ├── white1x1.gif │ ├── white1x1.jpg │ └── white1x1.png ├── colorfinder_test.go └── colorfinder.go ├── lettericon ├── testdata │ ├── A-16-123456.png │ ├── X-32-dfdfdf.png │ ├── ф-32-dfdfdf.png │ ├── A-123456.svg │ ├── X-dfdfdf.svg │ └── ф-dfdfdf.svg ├── fonts │ ├── noto_sans_regular.ttf │ └── LICENSE_OFL.txt ├── lettericon │ └── cmd.go ├── lettericon_test.go └── lettericon.go ├── .dockerignore ├── staticcheck.conf ├── .codeclimate.yml ├── docker_run.env ├── render.yaml ├── notices-more ├── icon-set └── noto-fonts ├── go.mod ├── LICENSE ├── Dockerfile ├── app.json ├── Makefile ├── vcr └── vcr.go ├── go.sum └── Readme.markdown /VERSION: -------------------------------------------------------------------------------- 1 | v3.21.0 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: iconserver 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | tags 3 | .licenses/ 4 | -------------------------------------------------------------------------------- /ico/broken.ico: -------------------------------------------------------------------------------- 1 |  (&  ( 2 | -------------------------------------------------------------------------------- /besticon.paw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon.paw -------------------------------------------------------------------------------- /ico/addthis.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/addthis.ico -------------------------------------------------------------------------------- /ico/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/favicon.ico -------------------------------------------------------------------------------- /ico/github.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/github.ico -------------------------------------------------------------------------------- /ico/wowhead.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/wowhead.ico -------------------------------------------------------------------------------- /.licensed.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | go: true 3 | 4 | source_path: besticon/iconserver 5 | -------------------------------------------------------------------------------- /ico/besticon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/besticon.ico -------------------------------------------------------------------------------- /ico/codeplex.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/ico/codeplex.ico -------------------------------------------------------------------------------- /the-icon-finder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/the-icon-finder.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | besticon/testdata/* linguist-vendored 2 | Godeps/* linguist-vendored 3 | 4 | -------------------------------------------------------------------------------- /besticon/testdata/mat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/mat.jpg -------------------------------------------------------------------------------- /besticon/testdata/pixel.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/pixel.gif -------------------------------------------------------------------------------- /besticon/testdata/pixel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/pixel.jpg -------------------------------------------------------------------------------- /besticon/testdata/pixel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/pixel.png -------------------------------------------------------------------------------- /besticon/testdata/rose.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/rose.bmp -------------------------------------------------------------------------------- /besticon/testdata/aol.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/aol.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/ard.de.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/ard.de.vcr -------------------------------------------------------------------------------- /besticon/testdata/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/favicon.ico -------------------------------------------------------------------------------- /besticon/testdata/car2go.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/car2go.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/eat24.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/eat24.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/github.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/github.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/random.org.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/random.org.vcr -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://www.buymeacoffee.com/der.matthias", "https://paypal.me/matthiasluedtke"] 2 | -------------------------------------------------------------------------------- /besticon/testdata/alibaba.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/alibaba.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/archive.org.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/archive.org.vcr -------------------------------------------------------------------------------- /besticon/testdata/example.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/example.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/kicktipp.de.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/kicktipp.de.vcr -------------------------------------------------------------------------------- /besticon/testdata/netflix.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/netflix.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/xing.com_443.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/xing.com_443.vcr -------------------------------------------------------------------------------- /besticon/testdata/youtube.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/youtube.com.vcr -------------------------------------------------------------------------------- /colorfinder/testdata/black1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/black1x1.png -------------------------------------------------------------------------------- /colorfinder/testdata/icon01.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon01.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon02.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon02.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon03.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon03.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon04.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon04.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon05.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon05.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon06.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon06.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon07.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon07.png.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon08.ico.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon08.ico.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon09.ico.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon09.ico.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon10.ico.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon10.ico.gz -------------------------------------------------------------------------------- /colorfinder/testdata/icon11.ico.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/icon11.ico.gz -------------------------------------------------------------------------------- /colorfinder/testdata/white1x1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/white1x1.gif -------------------------------------------------------------------------------- /colorfinder/testdata/white1x1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/white1x1.jpg -------------------------------------------------------------------------------- /colorfinder/testdata/white1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/colorfinder/testdata/white1x1.png -------------------------------------------------------------------------------- /lettericon/testdata/A-16-123456.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/lettericon/testdata/A-16-123456.png -------------------------------------------------------------------------------- /lettericon/testdata/X-32-dfdfdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/lettericon/testdata/X-32-dfdfdf.png -------------------------------------------------------------------------------- /lettericon/testdata/ф-32-dfdfdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/lettericon/testdata/ф-32-dfdfdf.png -------------------------------------------------------------------------------- /besticon/testdata/aws.amazon.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/aws.amazon.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/mortenmøller.dk.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/mortenmøller.dk.vcr -------------------------------------------------------------------------------- /besticon/testdata/printables.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/printables.com.vcr -------------------------------------------------------------------------------- /besticon/testdata/www.dnevnik.bg.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/www.dnevnik.bg.vcr -------------------------------------------------------------------------------- /besticon/iconserver/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/iconserver/assets/favicon.ico -------------------------------------------------------------------------------- /besticon/testdata/daringfireball.net.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/daringfireball.net.vcr -------------------------------------------------------------------------------- /lettericon/fonts/noto_sans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/lettericon/fonts/noto_sans_regular.ttf -------------------------------------------------------------------------------- /besticon/iconserver/assets/152x152_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/iconserver/assets/152x152_icon.png -------------------------------------------------------------------------------- /besticon/testdata/storage.googleapis.com.vcr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/testdata/storage.googleapis.com.vcr -------------------------------------------------------------------------------- /besticon/iconserver/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat/besticon/HEAD/besticon/iconserver/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /besticon/version.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | // Version string, same as VERSION, generated my Make 4 | const VersionString = "v3.21.0" 5 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/assets.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import "embed" 4 | 5 | //go:embed *.css *.ico *.html *.png *.svg 6 | var Assets embed.FS 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore all .log files 2 | *.log 3 | 4 | *.zip 5 | 6 | # Ignore specific directories 7 | config/ 8 | 9 | 10 | # Ignore other files 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | # staticcheck.conf 2 | 3 | # Based on the default from https://staticcheck.dev/docs/configuration/#example-configuration 4 | checks = ["all", "-SA9003", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1023", "-ST1013"] 5 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | csslint: 4 | enabled: false 5 | fixme: 6 | enabled: true 7 | golint: 8 | enabled: true 9 | govet: 10 | enabled: true 11 | ratings: 12 | paths: 13 | - "**.go" 14 | exclude_paths: 15 | - vendor/ 16 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you discover a potential security issue in this project we ask that you send a message to `legging.carafe_0c@icloud.com` with a subject including 'besticon security issue' as contents. 6 | 7 | -------------------------------------------------------------------------------- /lettericon/testdata/A-123456.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | A 4 | 5 | -------------------------------------------------------------------------------- /lettericon/testdata/X-dfdfdf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | X 4 | 5 | -------------------------------------------------------------------------------- /lettericon/testdata/ф-dfdfdf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ф 4 | 5 | -------------------------------------------------------------------------------- /docker_run.env: -------------------------------------------------------------------------------- 1 | 2 | ADDRESS=0.0.0.0 3 | CACHE_SIZE_MB=32 4 | CORS_ENABLED=false 5 | CORS_ALLOWED_HEADERS= 6 | CORS_ALLOWED_METHODS= 7 | CORS_ALLOWED_ORIGINS= 8 | CORS_ALLOW_CREDENTIALS= 9 | CORS_DEBUG=true 10 | HOST_ONLY_DOMAINS=* 11 | HTTP_CLIENT_TIMEOUT=5s 12 | HTTP_MAX_AGE_DURATION=720h 13 | HTTP_USER_AGENT='' 14 | POPULAR_SITES=bing.com,github.com,instagram.com,reddit.com 15 | PORT=8080 16 | SERVER_MODE=redirect 17 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: besticon 4 | runtime: go 5 | repo: https://github.com/mat/besticon 6 | buildCommand: go build -tags netgo -ldflags '-s -w' -o bin/iconserver github.com/mat/besticon/v3/besticon/iconserver 7 | startCommand: ./bin/iconserver 8 | plan: free 9 | envVars: 10 | - key: POPULAR_SITES 11 | value: "github.com,render.com,heroku.com" 12 | - key: HOST_ONLY_DOMAINS 13 | value: "*" 14 | - key: CACHE_SIZE_MB 15 | value: "32" 16 | - key: SERVER_MODE 17 | value: "redirect" 18 | autoDeploy: true 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Build + Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: ^1.25 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v5 23 | 24 | - name: Build 25 | run: go get github.com/mat/besticon/... 26 | 27 | - run: make test 28 | 29 | - run: make test_bench 30 | 31 | - run: make test_race 32 | -------------------------------------------------------------------------------- /ico/icoparser/icoparser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mat/besticon/v3/ico" 8 | ) 9 | 10 | func main() { 11 | for _, filename := range os.Args[1:] { 12 | fmt.Printf("%s: ", filename) 13 | f, err := os.Open(filename) 14 | if err != nil { 15 | fmt.Printf("failed to open %s\n", filename) 16 | continue 17 | } 18 | defer f.Close() 19 | 20 | dir, err := ico.ParseIco(f) 21 | if err != nil { 22 | fmt.Printf("Failed to parse %s as icon file\n", filename) 23 | } else if dir.Count == 1 { 24 | fmt.Printf("MS Windows icon resource - 1 icon\n") 25 | } else { 26 | best := dir.FindBestIcon() 27 | fmt.Printf("MS Windows icon resource - %d icons, %dx%d, %d-colors\n", dir.Count, 28 | best.Width, 29 | best.Height, 30 | best.ColorCount()) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lettericon/lettericon/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/mat/besticon/v3/lettericon" 10 | ) 11 | 12 | var ( 13 | letter = flag.String("letter", "X", "letter to draw") 14 | width = flag.Int("width", 144, "width/height of the icon") 15 | iconColor = flag.String("color", "#909090", "icon color, as hex string") 16 | ) 17 | 18 | func main() { 19 | flag.Parse() 20 | 21 | f, err := os.Create("out.png") 22 | if err != nil { 23 | log.Println(err) 24 | os.Exit(1) 25 | } 26 | defer f.Close() 27 | 28 | col, err := lettericon.ColorFromHex(*iconColor) 29 | if err != nil { 30 | log.Println(err) 31 | os.Exit(1) 32 | } 33 | 34 | err = lettericon.RenderPNG(*letter, col, *width, f) 35 | if err != nil { 36 | os.Exit(1) 37 | } 38 | fmt.Println("Wrote out.png OK.") 39 | } 40 | -------------------------------------------------------------------------------- /besticon/iconserver/prometheus.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promhttp" 8 | ) 9 | 10 | var ( 11 | duration *prometheus.HistogramVec 12 | ) 13 | 14 | func newPrometheusHandler(path string, f http.Handler) http.Handler { 15 | // return expvarHandler{counter: expvar.NewInt(path), handler: f} 16 | return promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"path": path}), f) 17 | } 18 | 19 | func init() { 20 | duration = prometheus.NewHistogramVec( 21 | prometheus.HistogramOpts{ 22 | Name: "request_duration_seconds", 23 | Help: "A histogram of latencies for requests.", 24 | Buckets: []float64{.25, .5, 1, 2.5, 5, 10}, 25 | }, 26 | []string{"code", "method", "path"}, 27 | ) 28 | prometheus.MustRegister(duration) 29 | } 30 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/main-min.css: -------------------------------------------------------------------------------- 1 | body{color:#777}.pure-img-responsive{max-width:100%;height:auto}td img{max-width:72px}td.url{word-wrap:break-word;max-width:400px}.lrpad{padding-left:1em;padding-right:1em}.example{padding-top:.7em;padding-left:1em}#error{box-sizing:border-box;color:#fafafa;text-align:center;background:#E82C0C;border:1px solid #fafafa;margin:2em 4px;padding:1em}#result{padding-top:.6em}#layout{position:relative;padding-left:0}.content{margin:0 auto 50px;padding:0 2em;max-width:800px;line-height:1.6em}.header{margin:0;color:#333;text-align:center;padding:2em 2em 0;border-bottom:1px solid #eee}.header h1{margin:.2em 0;font-size:3em;font-weight:300}.header h2{font-weight:300;color:#bbb;padding:0;margin-top:0}.header img.logo{margin-left:-7px;width:50px}.header .homelink{text-decoration:none;color:#333}.content-subhead{margin:50px 0 20px;font-weight:300;color:#888}@media (min-width:48em){.content,.header{padding-left:2em;padding-right:2em}}.l-box{padding:.5em 2em}.footer{background:#111;color:#888;text-align:center}.footer a{color:#ddd}@media (max-width:35.5em){td.url,th.url{display:none;visibility:hidden}} -------------------------------------------------------------------------------- /notices-more/icon-set: -------------------------------------------------------------------------------- 1 | 2 | ***** 3 | http://sixrevisions.com/freebies/icons/free-icons-1000/ 4 | 5 | Ultimate Free Icon Set: 1000 Free Icons 6 | 7 | This design resource freebie — containing 1,000 free vector icons — is the biggest free icon set that Six Revisions has ever released. These icons can help create a better reputation and user experience for your site. 8 | 9 | This freebie comes in three formats: 10 | 11 | PNG – a popular web format that’s ready to use (read about the PNG image format here) 12 | EPS – an editable graphics vector file that you can open and edit in vector-editing software like Adobe Illustrator and CorelDRAW 13 | AI – the original vector file that you can open and edit in Adobe Illustrator 14 | This freebie was created exclusively for the Six Revisions readers to enjoy by Freepik.com — a search engine that helps graphic and web designers find high-quality photos, vectors, illustrations, and PSD files for their creative projects. As you can see it is great for a large variety of industries, from a golf course’s site to a locksmith’s. There is an icon for just about everything in this set. 15 | -------------------------------------------------------------------------------- /besticon/besticon/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/mat/besticon/v3/besticon" 10 | ) 11 | 12 | func main() { 13 | all := flag.Bool("all", false, "Display all Icons, not just the best.") 14 | flag.Parse() 15 | 16 | if len(os.Args) <= 1 { 17 | fmt.Fprintf(os.Stderr, "please provide a URL.\n") 18 | os.Exit(100) 19 | } 20 | 21 | url := os.Args[len(os.Args)-1] 22 | 23 | b := besticon.New(besticon.WithLogger(besticon.NewDefaultLogger(io.Discard))) // Disable verbose logging 24 | 25 | finder := b.NewIconFinder() 26 | icons, err := finder.FetchIcons(url) 27 | if err != nil { 28 | fmt.Fprintf(os.Stderr, "%s: failed to fetch icons: %s\n", url, err) 29 | os.Exit(1) 30 | } 31 | 32 | if *all { 33 | for _, img := range icons { 34 | if img.Width > 0 { 35 | fmt.Printf("%s: %s\n", url, img.URL) 36 | } 37 | } 38 | } else { 39 | if len(icons) > 0 { 40 | best := icons[0] 41 | fmt.Printf("%s: %s\n", url, best.URL) 42 | } else { 43 | fmt.Fprintf(os.Stderr, "%s: no icons found\n", url) 44 | os.Exit(2) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /besticon/sorting.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import "sort" 4 | 5 | func sortIcons(icons []Icon, sizeDescending bool) { 6 | // Order after sorting: (width/height, bytes, url) 7 | sort.Stable(byURL(icons)) 8 | sort.Stable(byBytes(icons)) 9 | 10 | if sizeDescending { 11 | sort.Stable(sort.Reverse(byWidthHeight(icons))) 12 | } else { 13 | sort.Stable(byWidthHeight(icons)) 14 | } 15 | } 16 | 17 | type byWidthHeight []Icon 18 | 19 | func (a byWidthHeight) Len() int { return len(a) } 20 | func (a byWidthHeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 21 | func (a byWidthHeight) Less(i, j int) bool { 22 | return (a[i].Width < a[j].Width) || (a[i].Height < a[j].Height) 23 | } 24 | 25 | type byBytes []Icon 26 | 27 | func (a byBytes) Len() int { return len(a) } 28 | func (a byBytes) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 29 | func (a byBytes) Less(i, j int) bool { return (a[i].Bytes < a[j].Bytes) } 30 | 31 | type byURL []Icon 32 | 33 | func (a byURL) Len() int { return len(a) } 34 | func (a byURL) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 35 | func (a byURL) Less(i, j int) bool { return (a[i].URL < a[j].URL) } 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mat/besticon/v3 2 | 3 | go 1.25.0 4 | 5 | toolchain go1.25.4 6 | 7 | require ( 8 | github.com/PuerkitoBio/goquery v1.10.3 9 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 10 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 11 | github.com/prometheus/client_golang v1.23.2 12 | github.com/rs/cors v1.11.1 13 | golang.org/x/image v0.33.0 14 | golang.org/x/net v0.47.0 15 | ) 16 | 17 | require ( 18 | github.com/andybalholm/cascadia v1.3.3 // indirect 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/golang/protobuf v1.5.4 // indirect 22 | github.com/klauspost/compress v1.18.1 // indirect 23 | github.com/kr/text v0.2.0 // indirect 24 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 25 | github.com/prometheus/client_model v0.6.2 // indirect 26 | github.com/prometheus/common v0.67.2 // indirect 27 | github.com/prometheus/procfs v0.19.2 // indirect 28 | go.yaml.in/yaml/v2 v2.4.3 // indirect 29 | golang.org/x/sys v0.38.0 // indirect 30 | golang.org/x/text v0.31.0 // indirect 31 | google.golang.org/protobuf v1.36.10 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 Matthias Lüdtke, Hamburg - https://github.com/mat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /besticon/iconserver/logging.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "time" 8 | ) 9 | 10 | var logger = log.New(os.Stdout, "server: ", log.LstdFlags|log.Lmicroseconds) 11 | 12 | type loggingWriter struct { 13 | http.ResponseWriter 14 | status int 15 | length int 16 | } 17 | 18 | func (w *loggingWriter) WriteHeader(status int) { 19 | w.status = status 20 | w.ResponseWriter.WriteHeader(status) 21 | } 22 | 23 | func (w *loggingWriter) Write(b []byte) (int, error) { 24 | if w.status == 0 { 25 | w.status = 200 26 | } 27 | 28 | bytesWritten, err := w.ResponseWriter.Write(b) 29 | if err == nil { 30 | w.length += bytesWritten 31 | } 32 | return bytesWritten, err 33 | } 34 | 35 | func newLoggingMux() http.HandlerFunc { 36 | return func(w http.ResponseWriter, req *http.Request) { 37 | start := time.Now() 38 | writer := loggingWriter{w, 0, 0} 39 | http.DefaultServeMux.ServeHTTP(&writer, req) 40 | end := time.Now() 41 | duration := end.Sub(start) 42 | 43 | logger.Printf("%s %s %d \"%s\" %s %.2fms %d", 44 | req.Method, 45 | req.URL, 46 | writer.status, 47 | req.UserAgent(), 48 | req.Referer(), 49 | float64(duration)/float64(time.Millisecond), 50 | writer.length, 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /besticon/testdata/svg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /besticon/size_range.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // SizeRange represents the desired icon dimensions 10 | type SizeRange struct { 11 | Min int 12 | Perfect int 13 | Max int 14 | } 15 | 16 | var errBadSize = errors.New("besticon: bad size") 17 | 18 | // ParseSizeRange parses a string like 60..100..200 into a SizeRange 19 | func ParseSizeRange(s string, maxIconSize int) (*SizeRange, error) { 20 | parts := strings.SplitN(s, "..", 3) 21 | switch len(parts) { 22 | case 1: 23 | size, ok := parseSize(parts[0], maxIconSize) 24 | if !ok { 25 | return nil, errBadSize 26 | } 27 | return &SizeRange{size, size, maxIconSize}, nil 28 | case 3: 29 | n1, ok1 := parseSize(parts[0], maxIconSize) 30 | n2, ok2 := parseSize(parts[1], maxIconSize) 31 | n3, ok3 := parseSize(parts[2], maxIconSize) 32 | if !ok1 || !ok2 || !ok3 { 33 | return nil, errBadSize 34 | } 35 | if !((n1 <= n2) && (n2 <= n3)) { 36 | return nil, errBadSize 37 | } 38 | return &SizeRange{n1, n2, n3}, nil 39 | } 40 | 41 | return nil, errBadSize 42 | } 43 | 44 | func parseSize(s string, maxIconSize int) (int, bool) { 45 | size, err := strconv.Atoi(s) 46 | if err != nil || size < 0 || size > maxIconSize { 47 | return -1, false 48 | } 49 | return size, true 50 | } 51 | -------------------------------------------------------------------------------- /besticon/logger.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Logger interface { 11 | LogError(err error) 12 | // LogResponse is called when an HTTP request has been executed. The duration is the time it took to execute the 13 | // request. When error is nil, the response is the response object. Otherwise, the response is nil. 14 | LogResponse(req *http.Request, resp *http.Response, duration time.Duration, err error) 15 | } 16 | 17 | func NewDefaultLogger(w io.Writer) Logger { 18 | return &defaultLogger{ 19 | logger: log.New(w, "http: ", log.LstdFlags|log.Lmicroseconds), 20 | } 21 | } 22 | 23 | var _ Logger = (*defaultLogger)(nil) 24 | 25 | type defaultLogger struct { 26 | logger *log.Logger 27 | } 28 | 29 | func (d *defaultLogger) LogError(err error) { 30 | d.logger.Println("ERR:", err) 31 | } 32 | 33 | func (d *defaultLogger) LogResponse(req *http.Request, resp *http.Response, duration time.Duration, err error) { 34 | if err != nil { 35 | d.logger.Printf("Error: %s %s %s %.2fms", 36 | req.Method, 37 | req.URL, 38 | err, 39 | float64(duration)/float64(time.Millisecond), 40 | ) 41 | } else { 42 | d.logger.Printf("%s %s %d %.2fms %d", 43 | req.Method, 44 | req.URL, 45 | resp.StatusCode, 46 | float64(duration)/float64(time.Millisecond), 47 | resp.ContentLength, 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # github action to Build and publish Docker image 2 | 3 | name: Docker 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | environment: besticon-docker 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Go 1.x 16 | uses: actions/setup-go@v6 17 | with: 18 | go-version: ^1.25 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v5 22 | 23 | - name: Build 24 | run: go get github.com/mat/besticon/... 25 | 26 | - name: Login to Docker Hub 27 | uses: docker/login-action@v3 28 | with: 29 | username: ${{ vars.DOCKERHUB_USERNAME }} 30 | password: ${{ secrets.DOCKERHUB_TOKEN }} 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Read VERSION 36 | id: version 37 | run: echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV 38 | 39 | - name: Build and Push Multi-Platform Docker image 40 | run: | 41 | docker buildx build \ 42 | --platform linux/amd64,linux/arm64 \ 43 | --build-arg VERSION="$VERSION" \ 44 | --build-arg REVISION="$GITHUB_SHA" \ 45 | -t matthiasluedtke/iconserver:latest \ 46 | -t "matthiasluedtke/iconserver:$VERSION" \ 47 | --push . 48 | -------------------------------------------------------------------------------- /besticon/options.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type Option interface { 8 | applyOption(b *Besticon) 9 | } 10 | 11 | type httpClientOption struct { 12 | client *http.Client 13 | } 14 | 15 | func (h *httpClientOption) applyOption(b *Besticon) { 16 | b.httpClient = h.client 17 | } 18 | 19 | // WithHTTPClient sets the http client to use for requests. 20 | func WithHTTPClient(client *http.Client) Option { 21 | return &httpClientOption{ 22 | client: client, 23 | } 24 | } 25 | 26 | type loggerOption struct { 27 | logger Logger 28 | } 29 | 30 | func (l *loggerOption) applyOption(b *Besticon) { 31 | b.logger = l.logger 32 | } 33 | 34 | // WithLogger sets the logger to use for logging. 35 | func WithLogger(logger Logger) Option { 36 | return &loggerOption{ 37 | logger: logger, 38 | } 39 | } 40 | 41 | type defaultFormatsOption struct { 42 | formats []string 43 | } 44 | 45 | func (d *defaultFormatsOption) applyOption(b *Besticon) { 46 | b.defaultFormats = d.formats 47 | } 48 | 49 | // WithDefaultFormats sets the default accepted formats. 50 | func WithDefaultFormats(formats ...string) Option { 51 | return &defaultFormatsOption{ 52 | formats: formats, 53 | } 54 | } 55 | 56 | type discardImageBytesOption struct { 57 | discardImageBytes bool 58 | } 59 | 60 | func (k *discardImageBytesOption) applyOption(b *Besticon) { 61 | b.discardImageBytes = k.discardImageBytes 62 | } 63 | 64 | // WithDiscardImageBytes sets whether to discard image bodies. 65 | func WithDiscardImageBytes(discardImageBytes bool) Option { 66 | return &discardImageBytesOption{ 67 | discardImageBytes: discardImageBytes, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Adapted from 2 | # https://cloud.google.com/run/docs/quickstarts/build-and-deploy#containerizing 3 | 4 | # Use the official Golang image to create a build artifact. 5 | # https://hub.docker.com/_/golang 6 | FROM golang:1.25 as builder 7 | 8 | # Copy local code to the container image. 9 | WORKDIR /app 10 | COPY . . 11 | 12 | # TARGETARCH is set only by the docker buildx command - or manually 13 | ARG TARGETARCH 14 | 15 | # Build the command inside the container. 16 | # (You may fetch or manage dependencies here, 17 | # either manually or with a tool like "godep".) 18 | RUN make build_linux_${TARGETARCH} 19 | 20 | # Use a Docker multi-stage build to create a lean production image. 21 | # https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds 22 | FROM alpine:3.22 23 | 24 | 25 | # Have to define TARGETARCH again for the second stage 26 | ARG TARGETARCH 27 | 28 | # Copy the binary to the production image from the builder stage. 29 | COPY --from=builder /app/bin/linux_${TARGETARCH}/iconserver /iconserver 30 | 31 | ENV ADDRESS='' 32 | ENV CACHE_SIZE_MB=32 33 | ENV CORS_ENABLED=false 34 | ENV CORS_ALLOWED_HEADERS='' 35 | ENV CORS_ALLOWED_METHODS='' 36 | ENV CORS_ALLOWED_ORIGINS='' 37 | ENV CORS_ALLOW_CREDENTIALS='' 38 | ENV CORS_DEBUG='' 39 | ENV HOST_ONLY_DOMAINS=* 40 | ENV HTTP_CLIENT_TIMEOUT=5s 41 | ENV HTTP_MAX_AGE_DURATION=720h 42 | ENV HTTP_USER_AGENT='' 43 | ENV POPULAR_SITES=bing.com,github.com,instagram.com,reddit.com 44 | ENV PORT=8080 45 | ENV SERVER_MODE=redirect 46 | 47 | ARG VERSION='' 48 | ARG REVISION='' 49 | 50 | LABEL org.opencontainers.image.source="https://github.com/mat/besticon" 51 | LABEL org.opencontainers.image.licenses="MIT" 52 | LABEL org.opencontainers.image.version="${VERSION}" 53 | LABEL org.opencontainers.image.revision="${REVISION}" 54 | 55 | # Run the web service on container startup. 56 | CMD ["/iconserver"] 57 | -------------------------------------------------------------------------------- /besticon/extract_test.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | ) 7 | 8 | func mustFindIconLinks(html []byte) []string { 9 | doc, e := docFromHTML(html) 10 | check(e) 11 | links := extractIconTags(doc) 12 | sort.Strings(links) 13 | return links 14 | } 15 | 16 | func TestLinkExtraction(t *testing.T) { 17 | // invalid links 18 | invalid := []string{ 19 | "", 20 | "", 21 | "", 22 | "", 23 | "", 24 | } 25 | for _, html := range invalid { 26 | links := mustFindIconLinks([]byte(html)) 27 | if len(links) != 0 { 28 | t.Fatalf("%s shouldn't contain links", html) 29 | } 30 | } 31 | 32 | // test rel case 33 | valid := []string{ 34 | "", 35 | "", 36 | "", 37 | "", 38 | "", 39 | } 40 | for _, html := range valid { 41 | links := mustFindIconLinks([]byte(html)) 42 | if len(links) != 1 { 43 | t.Fatalf("%s should contain one link", html) 44 | } 45 | } 46 | 47 | // test some files 48 | links := mustFindIconLinks(mustReadFile("testdata/daringfireball.html")) 49 | assertEquals(t, []string{ 50 | "/graphics/apple-touch-icon.png", 51 | "/graphics/favicon.ico?v=005", 52 | }, links) 53 | links = mustFindIconLinks(mustReadFile("testdata/newyorker.html")) 54 | assertEquals(t, []string{ 55 | "/wp-content/assets/dist/img/icon/apple-touch-icon-114x114-precomposed.png", 56 | "/wp-content/assets/dist/img/icon/apple-touch-icon-144x144-precomposed.png", 57 | "/wp-content/assets/dist/img/icon/apple-touch-icon-57x57-precomposed.png", 58 | "/wp-content/assets/dist/img/icon/apple-touch-icon-precomposed.png", 59 | "/wp-content/assets/dist/img/icon/apple-touch-icon.png", 60 | "/wp-content/assets/dist/img/icon/favicon.ico", 61 | }, links) 62 | } 63 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/not_found.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | The Favicon Finder 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |

The Favicon Finder 29 | 30 |

31 |
32 |

A service finding icons on web sites

33 |
34 | 35 |
36 |

404 Not found

37 |

38 | The requested page does not exist :-( 39 |

40 | 41 |

42 | If you believe this is an error, please feel free to open an issue 43 | over at GitHub. 44 |

45 |
46 |
47 |
48 | 49 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /besticon/caching.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/golang/groupcache" 11 | ) 12 | 13 | type SiteURLKey string 14 | 15 | const contextKeySiteURL SiteURLKey = "siteURL" 16 | 17 | type result struct { 18 | Icons []Icon 19 | Error string 20 | } 21 | 22 | func (b *Besticon) resultFromCache(siteURL string) ([]Icon, error) { 23 | if b.iconCache == nil { 24 | return b.fetchIcons(siteURL) 25 | } 26 | 27 | c := context.WithValue(context.Background(), contextKeySiteURL, siteURL) 28 | var data []byte 29 | err := b.iconCache.Get(c, cacheKey(siteURL), groupcache.AllocatingByteSliceSink(&data)) 30 | if err != nil { 31 | b.logger.LogError(fmt.Errorf("failed to get icon from cache: %w", err)) 32 | return b.fetchIcons(siteURL) 33 | } 34 | 35 | res := &result{} 36 | err = json.Unmarshal(data, res) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | if res.Error != "" { 42 | return res.Icons, errors.New(res.Error) 43 | } 44 | return res.Icons, nil 45 | } 46 | 47 | func cacheKey(siteURL string) string { 48 | // Let results expire after a day 49 | now := time.Now() 50 | return fmt.Sprintf("%d-%02d-%02d-%s", now.Year(), now.Month(), now.Day(), siteURL) 51 | } 52 | 53 | func (b *Besticon) generatorFunc(ctx context.Context, key string, sink groupcache.Sink) error { 54 | siteURL := ctx.Value(contextKeySiteURL).(string) 55 | icons, err := b.fetchIcons(siteURL) 56 | if err != nil { 57 | // Don't cache errors 58 | return err 59 | } 60 | 61 | res := result{Icons: icons} 62 | bytes, err := json.Marshal(res) 63 | if err != nil { 64 | panic(err) 65 | } 66 | sink.SetBytes(bytes) 67 | 68 | return nil 69 | } 70 | 71 | type cacheOption struct { 72 | size int64 73 | } 74 | 75 | func (c *cacheOption) applyOption(b *Besticon) { 76 | b.iconCache = groupcache.NewGroup("icons", c.size<<20, groupcache.GetterFunc(b.generatorFunc)) 77 | } 78 | 79 | func WithCache(sizeInMB int64) Option { 80 | return &cacheOption{ 81 | size: sizeInMB, 82 | } 83 | } 84 | 85 | func (b *Besticon) CacheEnabled() bool { 86 | return b.iconCache != nil 87 | } 88 | 89 | // GetCacheStats returns cache statistics. 90 | func (b *Besticon) GetCacheStats() groupcache.CacheStats { 91 | return b.iconCache.CacheStats(groupcache.MainCache) 92 | } 93 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "besticon-favicon-service", 3 | "description": "A favicon service written in Go", 4 | "repository": "https://github.com/mat/besticon", 5 | "website": "https://github.com/mat/besticon", 6 | "logo": "https://besticon-demo.herokuapp.com/icon.svg", 7 | "env": { 8 | "ADDRESS": { 9 | "value": "0.0.0.0", 10 | "description": "HTTP server listen address, defaults to 0.0.0.0" 11 | }, 12 | "CACHE_SIZE_MB": { 13 | "value": "32", 14 | "description": "Size for the http://github.com/golang/groupcache, set to 0 to disable, defaults to 32" 15 | }, 16 | "HOST_ONLY_DOMAINS": { 17 | "value": "*" 18 | }, 19 | "CORS_ENABLED": { 20 | "value": "false", 21 | "description": "Enables the cors middleware, defaults to false" 22 | }, 23 | "CORS_ALLOWED_HEADERS": { 24 | "value": " ", 25 | "description": "Comma-separated, passed to middleware" 26 | }, 27 | "CORS_ALLOWED_METHODS": { 28 | "value": " ", 29 | "description": "Comma-separated, passed to middleware" 30 | }, 31 | "CORS_ALLOWED_ORIGINS": { 32 | "value": " ", 33 | "description": "Comma-separated, passed to middleware" 34 | }, 35 | "CORS_ALLOW_CREDENTIALS": { 36 | "value": " ", 37 | "description": "Boolean, passed to middleware" 38 | }, 39 | "CORS_DEBUG": { 40 | "value": " ", 41 | "description": "Boolean, passed to middleware" 42 | }, 43 | "HTTP_CLIENT_TIMEOUT": { 44 | "value": "5s", 45 | "description": "Timeout used for HTTP requests. Supports units like ms, s, m, defaults to 5s" 46 | }, 47 | "HTTP_MAX_AGE_DURATION": { 48 | "value": "720h", 49 | "description": "Cache duration for all dynamically generated HTTP responses. Supports units like ms, s, m" 50 | }, 51 | "HTTP_USER_AGENT": { 52 | "value": "Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A5297c Safari/602.1", 53 | "description": "User-Agent used for HTTP requests, defaults to iPhone user agent string" 54 | }, 55 | "POPULAR_SITES": { 56 | "value": "bing.com,github.com,instagram.com,reddit.com", 57 | "description": "Comma-separated list of domains used on /popular page" 58 | }, 59 | "SERVER_MODE": { 60 | "value": "redirect", 61 | "description": "Set to `download` to proxy downloads through besticon or `redirect` to let browser to download instead, defaults to redirect" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '39 16 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v5 39 | - name: Setup Go 40 | uses: actions/setup-go@v6 41 | with: 42 | go-version: '1.25' 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v3 67 | -------------------------------------------------------------------------------- /besticon/testdata/websites.txt: -------------------------------------------------------------------------------- 1 | craigslist.org 2 | qq.com 3 | tumblr.com 4 | google.com.au 5 | ettoday.net 6 | google.com.pk 7 | adobe.com 8 | buzzfeed.com 9 | amazon.co.jp 10 | cnet.com 11 | espn.go.com 12 | aliexpress.com 13 | ebay.co.uk 14 | gmw.cn 15 | etsy.com 16 | flickr.com 17 | google.com.tw 18 | baidu.com 19 | xinhuanet.com 20 | sogou.com 21 | yandex.ru 22 | onclickads.net 23 | github.com 24 | amazon.fr 25 | bongacams.com 26 | people.com.cn 27 | rakuten.co.jp 28 | ebay.de 29 | t.co 30 | stackoverflow.com 31 | directrev.com 32 | google.pl 33 | nytimes.com 34 | apple.com 35 | tmall.com 36 | microsoft.com 37 | msn.com 38 | indiatimes.com 39 | jd.com 40 | dailymail.co.uk 41 | taobao.com 42 | aol.com 43 | amazon.de 44 | tudou.com 45 | coccoc.com 46 | yelp.com 47 | ameblo.jp 48 | mail.ru 49 | globo.com 50 | blogspot.in 51 | blogger.com 52 | imgur.com 53 | pixnet.net 54 | bp.blogspot.com 55 | amazon.in 56 | soso.com 57 | target.com 58 | imdb.com 59 | akamaihd.net 60 | google.com.mx 61 | uol.com.br 62 | go.com 63 | youradexchange.com 64 | weather.com 65 | google.com.ar 66 | cnn.com 67 | amazonaws.com 68 | googleusercontent.com 69 | amazon.cn 70 | chase.com 71 | reddit.com 72 | instagram.com 73 | amazon.com 74 | google.co.th 75 | vk.com 76 | google.fr 77 | live.com 78 | bestbuy.com 79 | slideshare.net 80 | 163.com 81 | life.com.tw 82 | linkedin.com 83 | google.com.sa 84 | google.ru 85 | paypal.com 86 | google.de 87 | pconline.com.cn 88 | youku.com 89 | vimeo.com 90 | weibo.com 91 | alibaba.com 92 | wordpress.org 93 | gmail.com 94 | yahoo.com 95 | wikipedia.org 96 | yahoo.co.jp 97 | googleadservices.com 98 | alipay.com 99 | blogspot.com 100 | google.com.tr 101 | dropbox.com 102 | google.es 103 | google.com.br 104 | outbrain.com 105 | youtube.com 106 | bing.com 107 | ebay.com 108 | google.co.in 109 | odnoklassniki.ru 110 | china.com 111 | adcash.com 112 | amazon.co.uk 113 | bbc.co.uk 114 | google.co.kr 115 | ask.com 116 | wordpress.com 117 | twitter.com 118 | about.com 119 | naver.com 120 | facebook.com 121 | netflix.com 122 | fc2.com 123 | pinterest.com 124 | cntv.cn 125 | google.nl 126 | dailymotion.com 127 | wikia.com 128 | google.ca 129 | walmart.com 130 | google.it 131 | bankofamerica.com 132 | hao123.com 133 | booking.com 134 | huffingtonpost.com 135 | sohu.com 136 | google.co.uk 137 | google.co.jp 138 | flipkart.com 139 | google.com 140 | google.com.eg 141 | thepiratebay.se 142 | 360.cn 143 | google.com.hk 144 | sina.com.cn 145 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #777; 3 | } 4 | 5 | .pure-img-responsive { 6 | max-width: 100%; 7 | height: auto; 8 | } 9 | 10 | td img { 11 | max-width: 72px; 12 | } 13 | 14 | td.url { 15 | word-wrap: break-word; 16 | max-width: 400px; 17 | } 18 | 19 | .lrpad { 20 | padding-left: 1em; 21 | padding-right: 1em; 22 | } 23 | 24 | .example { 25 | padding-top: 0.7em; 26 | padding-left: 1em; 27 | } 28 | 29 | #error { 30 | box-sizing: border-box; 31 | color: #fafafa; 32 | text-align: center; 33 | background: #E82C0C; 34 | border: 1px solid #fafafa; 35 | margin: 2em 4px; 36 | padding: 1em; 37 | } 38 | 39 | #result { 40 | padding-top: 0.6em; 41 | } 42 | 43 | /* 44 | This is the parent `
` that contains the menu and the content area. 45 | */ 46 | #layout { 47 | position: relative; 48 | padding-left: 0; 49 | } 50 | 51 | /* 52 | The content `
` is where all your content goes. 53 | */ 54 | .content { 55 | margin: 0 auto; 56 | padding: 0 2em; 57 | max-width: 800px; 58 | margin-bottom: 50px; 59 | line-height: 1.6em; 60 | } 61 | 62 | .header { 63 | margin: 0; 64 | color: #333; 65 | text-align: center; 66 | padding: 2em 2em 0; 67 | border-bottom: 1px solid #eee; 68 | } 69 | .header h1 { 70 | margin: 0.2em 0; 71 | font-size: 3em; 72 | font-weight: 300; 73 | } 74 | .header h2 { 75 | font-weight: 300; 76 | color: #bbb; 77 | padding: 0; 78 | margin-top: 0; 79 | } 80 | 81 | .header img.logo { 82 | margin-left: -7px; 83 | width: 50px; 84 | } 85 | 86 | .header .homelink { 87 | text-decoration: none; 88 | color: #333; 89 | } 90 | 91 | 92 | .content-subhead { 93 | margin: 50px 0 20px 0; 94 | font-weight: 300; 95 | color: #888; 96 | } 97 | 98 | 99 | /* -- Responsive Styles (Media Queries) ------------------------------------- */ 100 | 101 | /* 102 | Hides the menu at `48em`, but modify this based on your app's needs. 103 | */ 104 | @media (min-width: 48em) { 105 | 106 | .header, 107 | .content { 108 | padding-left: 2em; 109 | padding-right: 2em; 110 | } 111 | } 112 | 113 | 114 | .l-box { 115 | padding: 0.5em 2em; 116 | } 117 | 118 | .footer { 119 | background: #111; 120 | color: #888; 121 | text-align: center; 122 | } 123 | .footer a { 124 | color: #ddd; 125 | } 126 | 127 | 128 | @media (max-width: 35.5em) { 129 | td.url, th.url { 130 | display:none; 131 | visibility:hidden; 132 | } 133 | } 134 | 135 | -------------------------------------------------------------------------------- /besticon/http.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "net/http" 8 | "net/http/cookiejar" 9 | "net/url" 10 | "time" 11 | 12 | "golang.org/x/net/idna" 13 | "golang.org/x/net/publicsuffix" 14 | ) 15 | 16 | var _ http.RoundTripper = (*httpTransport)(nil) 17 | 18 | type httpTransport struct { 19 | transport http.RoundTripper 20 | 21 | userAgent string 22 | } 23 | 24 | func (h *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) { 25 | req.Header.Set("Accept", "*/*") 26 | req.Header.Set("User-Agent", h.userAgent) 27 | return h.transport.RoundTrip(req) 28 | } 29 | 30 | func NewDefaultHTTPTransport(userAgent string) http.RoundTripper { 31 | return &httpTransport{ 32 | transport: http.DefaultTransport, 33 | userAgent: userAgent, 34 | } 35 | } 36 | 37 | func NewDefaultHTTPClient() *http.Client { 38 | return &http.Client{ 39 | Timeout: 5 * time.Second, 40 | Jar: mustInitCookieJar(), 41 | Transport: NewDefaultHTTPTransport("Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A5297c Safari/602.1"), 42 | } 43 | } 44 | 45 | func (b *Besticon) Get(urlstring string) (*http.Response, error) { 46 | u, e := url.Parse(urlstring) 47 | if e != nil { 48 | return nil, e 49 | } 50 | // Maybe we can get rid of this conversion someday 51 | // https://github.com/golang/go/issues/13835 52 | u.Host, e = idna.ToASCII(u.Host) 53 | if e != nil { 54 | return nil, e 55 | } 56 | 57 | ipAddr, e := net.ResolveIPAddr("ip", u.Hostname()) 58 | if e != nil { 59 | return nil, e 60 | } 61 | 62 | if isPrivateIP(ipAddr) { 63 | return nil, errors.New("private ip address disallowed") 64 | } 65 | 66 | req, e := http.NewRequest("GET", u.String(), nil) 67 | if e != nil { 68 | return nil, e 69 | } 70 | 71 | start := time.Now() 72 | resp, err := b.httpClient.Do(req) 73 | end := time.Now() 74 | duration := end.Sub(start) 75 | 76 | b.logger.LogResponse(req, resp, duration, err) 77 | 78 | return resp, err 79 | } 80 | 81 | func isPrivateIP(ipAddr *net.IPAddr) bool { 82 | if ipAddr == nil { 83 | return false 84 | } 85 | 86 | return ipAddr.IP.IsLoopback() || ipAddr.IP.IsPrivate() 87 | } 88 | 89 | func (b *Besticon) GetBodyBytes(r *http.Response) ([]byte, error) { 90 | limitReader := io.LimitReader(r.Body, b.maxResponseBodySize) 91 | data, e := io.ReadAll(limitReader) 92 | r.Body.Close() 93 | 94 | if int64(len(data)) >= b.maxResponseBodySize { 95 | return nil, errors.New("body too large") 96 | } 97 | return data, e 98 | } 99 | 100 | func mustInitCookieJar() *cookiejar.Jar { 101 | options := cookiejar.Options{ 102 | PublicSuffixList: publicsuffix.List, 103 | } 104 | jar, e := cookiejar.New(&options) 105 | if e != nil { 106 | panic(e) 107 | } 108 | 109 | return jar 110 | } 111 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/popular.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Favicon Examples 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |
26 |
27 |

The Favicon Finder 28 | 29 |

30 |
31 |

A service finding icons on web sites

32 |
33 | 34 |
35 | 36 |

Icon Examples

37 | 38 |
39 |
40 | 47 | 48 | 49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {{range $url := .URLs}} 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {{end}} 71 |
Site4080{{$.IconSize}} 
{{$url}}Details
72 |
73 |
74 |
75 | 76 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /besticon/extract.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/PuerkitoBio/goquery" 13 | ) 14 | 15 | var iconPaths = []string{ 16 | "/favicon.ico", 17 | "/apple-touch-icon.png", 18 | "/apple-touch-icon-precomposed.png", 19 | } 20 | 21 | const ( 22 | favIcon = "icon" 23 | appleTouchIcon = "apple-touch-icon" 24 | appleTouchIconPrecomposed = "apple-touch-icon-precomposed" 25 | ) 26 | 27 | type empty struct{} 28 | 29 | // Find all icons in this html. We use siteURL as the base url unless we detect 30 | // another base url in 31 | func findIconLinks(siteURL *url.URL, html []byte) ([]string, error) { 32 | doc, e := docFromHTML(html) 33 | if e != nil { 34 | return nil, e 35 | } 36 | 37 | baseURL := determineBaseURL(siteURL, doc) 38 | 39 | // Use a map to avoid dups 40 | links := make(map[string]empty) 41 | 42 | // Add common, hard coded icon paths 43 | for _, path := range iconPaths { 44 | links[urlFromBase(baseURL, path)] = empty{} 45 | } 46 | 47 | // Add icons found in page 48 | urls := extractIconTags(doc) 49 | for _, u := range urls { 50 | absoluteURL, e := absoluteURL(baseURL, u) 51 | if e == nil { 52 | links[absoluteURL] = empty{} 53 | } 54 | } 55 | 56 | // Turn unique keys into array 57 | var result []string 58 | for u := range links { 59 | result = append(result, u) 60 | } 61 | sort.Strings(result) 62 | 63 | return result, nil 64 | } 65 | 66 | // What is the baseURL for this doc? 67 | func determineBaseURL(siteURL *url.URL, doc *goquery.Document) *url.URL { 68 | baseTagHref := extractBaseTag(doc) 69 | if baseTagHref != "" { 70 | if strings.HasPrefix(baseTagHref, "/") { 71 | return siteURL.JoinPath(baseTagHref) 72 | } 73 | baseTagURL, e := url.Parse(baseTagHref) 74 | if e != nil { 75 | return siteURL 76 | } 77 | return baseTagURL 78 | } 79 | 80 | return siteURL 81 | } 82 | 83 | // Convert bytes => doc 84 | func docFromHTML(html []byte) (*goquery.Document, error) { 85 | doc, e := goquery.NewDocumentFromReader(bytes.NewReader(html)) 86 | if e != nil || doc == nil { 87 | return nil, errParseHTML 88 | } 89 | return doc, nil 90 | } 91 | 92 | var errParseHTML = errors.New("besticon: could not parse html") 93 | 94 | // Find 95 | func extractBaseTag(doc *goquery.Document) string { 96 | href := "" 97 | doc.Find("head base[href]").First().Each(func(i int, s *goquery.Selection) { 98 | href, _ = s.Attr("href") 99 | }) 100 | return href 101 | } 102 | 103 | var ( 104 | iconTypes = []string{favIcon, appleTouchIcon, appleTouchIconPrecomposed} 105 | iconTypesRe = regexp.MustCompile(fmt.Sprintf("^(%s)$", strings.Join(regexpQuoteMetaArray(iconTypes), "|"))) 106 | ) 107 | 108 | // Find icons from doc using goquery 109 | func extractIconTags(doc *goquery.Document) []string { 110 | var hits []string 111 | doc.Find("link[href][rel]").Each(func(i int, s *goquery.Selection) { 112 | href := extractIconTag(s) 113 | if href != "" { 114 | hits = append(hits, href) 115 | } 116 | }) 117 | return hits 118 | } 119 | 120 | func extractIconTag(s *goquery.Selection) string { 121 | // What sort of iconType is in this ? 122 | rel, _ := s.Attr("rel") 123 | if rel == "" { 124 | return "" 125 | } 126 | rel = strings.ToLower(rel) 127 | 128 | var iconType string 129 | for _, i := range strings.Fields(rel) { 130 | if iconTypesRe.MatchString(i) { 131 | iconType = i 132 | break 133 | } 134 | } 135 | if iconType == "" { 136 | return "" 137 | } 138 | 139 | href, _ := s.Attr("href") 140 | if href == "" { 141 | return "" 142 | } 143 | 144 | return href 145 | } 146 | 147 | // regexp.QuoteMeta an array of strings 148 | func regexpQuoteMetaArray(a []string) []string { 149 | quoted := make([]string, len(a)) 150 | for i, s := range a { 151 | quoted[i] = regexp.QuoteMeta(s) 152 | } 153 | return quoted 154 | } 155 | -------------------------------------------------------------------------------- /ico/ico_test.go: -------------------------------------------------------------------------------- 1 | package ico 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestParseICO(t *testing.T) { 13 | assertEquals(t, 3, GetNumberOfIconsInFile(t, "favicon.ico")) 14 | assertDecodesImage(t, "github.ico") 15 | assertDecodesImage(t, "besticon.ico") 16 | assertDecodesImage(t, "addthis.ico") 17 | assertDecodesImage(t, "wowhead.ico") 18 | } 19 | 20 | func TestParseICODetails(t *testing.T) { 21 | entries := []icondirEntry{ 22 | {Width: 0x30, Height: 0x30, PaletteCount: 0x10, Reserved: 0x0, ColorPlanes: 0x1, BitsPerPixel: 0x4, Size: 0x668, Offset: 0x36}, 23 | {Width: 0x20, Height: 0x20, PaletteCount: 0x10, Reserved: 0x0, ColorPlanes: 0x1, BitsPerPixel: 0x4, Size: 0x2e8, Offset: 0x69e}, 24 | {Width: 0x10, Height: 0x10, PaletteCount: 0x10, Reserved: 0x0, ColorPlanes: 0x1, BitsPerPixel: 0x4, Size: 0x128, Offset: 0x986}, 25 | } 26 | assertEquals(t, icondir{Reserved: 0, Type: 1, Count: 3, Entries: entries}, mustParseIcoFile(t, "favicon.ico")) 27 | } 28 | 29 | func TestFindBestIcon(t *testing.T) { 30 | dir := mustParseIcoFile(t, "favicon.ico") 31 | best := dir.FindBestIcon() 32 | 33 | assertEquals(t, 34 | &icondirEntry{Width: 0x30, Height: 0x30, PaletteCount: 0x10, Reserved: 0x0, ColorPlanes: 0x1, BitsPerPixel: 0x4, Size: 0x668, Offset: 0x36}, 35 | best) 36 | } 37 | 38 | func TestColorCount(t *testing.T) { 39 | dir := mustParseIcoFile(t, "favicon.ico") 40 | best := dir.FindBestIcon() 41 | assertEquals(t, 16, best.ColorCount()) 42 | 43 | dir = mustParseIcoFile(t, "github.ico") 44 | best = dir.FindBestIcon() 45 | assertEquals(t, 256, best.ColorCount()) 46 | } 47 | 48 | func TestDecodeConfig(t *testing.T) { 49 | f, err := os.Open("favicon.ico") 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | defer f.Close() 54 | 55 | imageConfig, _, err := image.DecodeConfig(f) 56 | 57 | assertEquals(t, nil, err) 58 | assertEquals(t, 59 | image.Config{ColorModel: nil, Width: 48, Height: 48}, 60 | imageConfig) 61 | } 62 | 63 | func TestDecodeConfigWithBrokenIco(t *testing.T) { 64 | f, err := os.Open("broken.ico") 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | defer f.Close() 69 | 70 | imageConfig, _, err := image.DecodeConfig(f) 71 | 72 | assertEquals(t, 73 | image.Config{}, 74 | imageConfig) 75 | 76 | assertEquals(t, errors.New("unexpected EOF"), err) 77 | } 78 | 79 | func TestParse256WidthHeightIco(t *testing.T) { 80 | assertEquals(t, 5, GetNumberOfIconsInFile(t, "codeplex.ico")) 81 | 82 | f, err := os.Open("codeplex.ico") 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | defer f.Close() 87 | 88 | imageConfig, _, err := image.DecodeConfig(f) 89 | 90 | assertEquals(t, nil, err) 91 | assertEquals(t, 92 | image.Config{ColorModel: nil, Width: 256, Height: 256}, 93 | imageConfig) 94 | 95 | assertDecodesImage(t, "codeplex.ico") 96 | } 97 | 98 | func GetNumberOfIconsInFile(t *testing.T, filename string) int { 99 | dir := mustParseIcoFile(t, filename) 100 | return int(dir.Count) 101 | } 102 | 103 | func parseIcoFile(filename string) (*icondir, error) { 104 | f, err := os.Open(filename) 105 | if err != nil { 106 | return nil, err 107 | } 108 | defer f.Close() 109 | 110 | return ParseIco(f) 111 | } 112 | 113 | func mustParseIcoFile(t *testing.T, filename string) icondir { 114 | dir, err := parseIcoFile(filename) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | return *dir 120 | } 121 | 122 | func assertEquals(t *testing.T, expected, actual interface{}) { 123 | if !reflect.DeepEqual(expected, actual) { 124 | fail(t, fmt.Sprintf("Not equal: %#v (expected)\n"+ 125 | " != %#v (actual)", expected, actual)) 126 | } 127 | } 128 | 129 | func assertDecodesImage(t *testing.T, file string) { 130 | f, err := os.Open(file) 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | defer f.Close() 135 | 136 | _, err = Decode(f) 137 | assertEquals(t, nil, err) 138 | } 139 | 140 | func fail(t *testing.T, failureMessage string) { 141 | t.Errorf("\t%s\n"+ 142 | "\r\t", 143 | failureMessage) 144 | } 145 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/icons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Favicon for {{ .Host }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |

The Favicon Finder 29 | 30 |

31 |
32 |

A service finding icons on web sites

33 |
34 | 35 |
36 |

Get Favicon

37 | 38 |
39 |
40 |
41 | 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 | {{ with .Error }}
{{ .}}
{{ end }} 53 | 54 |

Favicon for {{ .Host }}

55 | 56 |

57 | Icon link: 58 | https://besticon-demo.herokuapp.com/icon?url={{ .URL }}&size=80..120..200 59 |

60 | 61 | {{ if (gt (len .Icons) 0)}} 62 |

More Icons on {{ .Host }}

63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {{range .Icons}} 74 | 75 | 76 | 77 | 78 | 79 | 80 | {{end}} 81 |
SizeURLType
{{.Width}}x{{.Height}}{{.URL}}{{.Format}}
82 | 83 |

84 | JSON representation: 85 | https://besticon-demo.herokuapp.com/allicons.json?url={{ .URL }}. 86 |

87 | {{ end }} 88 | 89 | 91 |
92 |
93 |
94 | 95 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /lettericon/fonts/LICENSE_OFL.txt: -------------------------------------------------------------------------------- 1 | This Font Software is licensed under the SIL Open Font License, 2 | Version 1.1. 3 | 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | ----------------------------------------------------------- 8 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 9 | ----------------------------------------------------------- 10 | 11 | PREAMBLE 12 | The goals of the Open Font License (OFL) are to stimulate worldwide 13 | development of collaborative font projects, to support the font 14 | creation efforts of academic and linguistic communities, and to 15 | provide a free and open framework in which fonts may be shared and 16 | improved in partnership with others. 17 | 18 | The OFL allows the licensed fonts to be used, studied, modified and 19 | redistributed freely as long as they are not sold by themselves. The 20 | fonts, including any derivative works, can be bundled, embedded, 21 | redistributed and/or sold with any software provided that any reserved 22 | names are not used by derivative works. The fonts and derivatives, 23 | however, cannot be released under any other type of license. The 24 | requirement for fonts to remain under this license does not apply to 25 | any document created using the fonts or their derivatives. 26 | 27 | DEFINITIONS 28 | "Font Software" refers to the set of files released by the Copyright 29 | Holder(s) under this license and clearly marked as such. This may 30 | include source files, build scripts and documentation. 31 | 32 | "Reserved Font Name" refers to any names specified as such after the 33 | copyright statement(s). 34 | 35 | "Original Version" refers to the collection of Font Software 36 | components as distributed by the Copyright Holder(s). 37 | 38 | "Modified Version" refers to any derivative made by adding to, 39 | deleting, or substituting -- in part or in whole -- any of the 40 | components of the Original Version, by changing formats or by porting 41 | the Font Software to a new environment. 42 | 43 | "Author" refers to any designer, engineer, programmer, technical 44 | writer or other person who contributed to the Font Software. 45 | 46 | PERMISSION & CONDITIONS 47 | Permission is hereby granted, free of charge, to any person obtaining 48 | a copy of the Font Software, to use, study, copy, merge, embed, 49 | modify, redistribute, and sell modified and unmodified copies of the 50 | Font Software, subject to the following conditions: 51 | 52 | 1) Neither the Font Software nor any of its individual components, in 53 | Original or Modified Versions, may be sold by itself. 54 | 55 | 2) Original or Modified Versions of the Font Software may be bundled, 56 | redistributed and/or sold with any software, provided that each copy 57 | contains the above copyright notice and this license. These can be 58 | included either as stand-alone text files, human-readable headers or 59 | in the appropriate machine-readable metadata fields within text or 60 | binary files as long as those fields can be easily viewed by the user. 61 | 62 | 3) No Modified Version of the Font Software may use the Reserved Font 63 | Name(s) unless explicit written permission is granted by the 64 | corresponding Copyright Holder. This restriction only applies to the 65 | primary font name as presented to the users. 66 | 67 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 68 | Software shall not be used to promote, endorse or advertise any 69 | Modified Version, except to acknowledge the contribution(s) of the 70 | Copyright Holder(s) and the Author(s) or with their explicit written 71 | permission. 72 | 73 | 5) The Font Software, modified or unmodified, in part or in whole, 74 | must be distributed entirely under this license, and must not be 75 | distributed under any other license. The requirement for fonts to 76 | remain under this license does not apply to any document created using 77 | the Font Software. 78 | 79 | TERMINATION 80 | This license becomes null and void if any of the above conditions are 81 | not met. 82 | 83 | DISCLAIMER 84 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 85 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 86 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 87 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 88 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 89 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 90 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 91 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 92 | OTHER DEALINGS IN THE FONT SOFTWARE. 93 | -------------------------------------------------------------------------------- /colorfinder/colorfinder_test.go: -------------------------------------------------------------------------------- 1 | package colorfinder 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "fmt" 7 | "image" 8 | "io" 9 | "log" 10 | "os" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | ) 15 | 16 | func TestSimplePixel(t *testing.T) { 17 | assertFindsRightColor(t, "white1x1.png", "ffffff") 18 | assertFindsRightColor(t, "black1x1.png", "000000") 19 | } 20 | 21 | func TestImageFormats(t *testing.T) { 22 | assertFindsRightColor(t, "white1x1.gif", "ffffff") 23 | assertFindsRightColor(t, "white1x1.jpg", "ffffff") 24 | assertFindsRightColor(t, "white1x1.png", "ffffff") 25 | } 26 | 27 | func TestFindColors01(t *testing.T) { 28 | assertFindsRightColor(t, "icon01.png.gz", "113671") 29 | } 30 | 31 | func TestFindColors02(t *testing.T) { 32 | assertFindsRightColor(t, "icon02.png.gz", "cb1c1f") 33 | } 34 | 35 | func TestFindColors03(t *testing.T) { 36 | assertFindsRightColor(t, "icon03.png.gz", "f48024") 37 | } 38 | 39 | func TestFindColors04(t *testing.T) { 40 | assertFindsRightColor(t, "icon04.png.gz", "cfdc00") 41 | } 42 | 43 | func TestFindColors05(t *testing.T) { 44 | assertFindsRightColor(t, "icon05.png.gz", "ffa700") 45 | } 46 | 47 | func TestFindColors06(t *testing.T) { 48 | assertFindsRightColor(t, "icon06.png.gz", "ff6600") 49 | } 50 | 51 | func TestFindColors07(t *testing.T) { 52 | assertFindsRightColor(t, "icon07.png.gz", "e61a30") 53 | } 54 | 55 | func TestFindColors08(t *testing.T) { 56 | // .ico with png 57 | assertFindsRightColor(t, "icon08.ico.gz", "14e06e") 58 | } 59 | 60 | func TestFindColors09(t *testing.T) { 61 | // .ico with 32-bit bmp 62 | assertFindsRightColor(t, "icon09.ico.gz", "1c5182") 63 | } 64 | 65 | func TestFindColors10(t *testing.T) { 66 | // .ico with 8-bit bmp, ColorsUsed=0 67 | assertFindsRightColor(t, "icon10.ico.gz", "fe6d4c") 68 | } 69 | 70 | func TestFindColors11(t *testing.T) { 71 | // .ico with 8-bit bmp, ColorsUsed=256 72 | assertFindsRightColor(t, "icon11.ico.gz", "a30000") 73 | } 74 | 75 | func BenchmarkFindMainColor152x152(b *testing.B) { 76 | file, _ := os.Open(testdataDir + "icon02.png.gz") 77 | gzReader, _ := gzip.NewReader(file) 78 | byts, _ := io.ReadAll(gzReader) 79 | imgReader := bytes.NewReader(byts) 80 | img, _, err := image.Decode(imgReader) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | 85 | b.ResetTimer() 86 | 87 | cf := ColorFinder{} 88 | for i := 0; i < b.N; i++ { 89 | col, err := cf.FindMainColor(img) 90 | if err != nil { 91 | b.Errorf("Unexpected error: %#v", err) 92 | } 93 | if ColorToHex(col) != "cb1c1f" { 94 | b.Errorf("Wrong color: %s", ColorToHex(col)) 95 | } 96 | 97 | imgReader.Seek(0, 0) 98 | } 99 | } 100 | 101 | func BenchmarkFindMainColor57x57(b *testing.B) { 102 | file, _ := os.Open(testdataDir + "icon07.png.gz") 103 | gzReader, _ := gzip.NewReader(file) 104 | byts, _ := io.ReadAll(gzReader) 105 | imgReader := bytes.NewReader(byts) 106 | img, _, err := image.Decode(imgReader) 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | b.ResetTimer() 112 | 113 | cf := ColorFinder{} 114 | for i := 0; i < b.N; i++ { 115 | col, err := cf.FindMainColor(img) 116 | if err != nil { 117 | b.Errorf("Unexpected error: %#v", err) 118 | } 119 | if ColorToHex(col) != "e61a30" { 120 | b.Errorf("Wrong color: %s", ColorToHex(col)) 121 | } 122 | 123 | imgReader.Seek(0, 0) 124 | } 125 | } 126 | 127 | const testdataDir = "testdata/" 128 | 129 | func assertFindsRightColor(t *testing.T, fileName string, expectedHexColor string) { 130 | var imgReader io.ReadCloser 131 | 132 | path := testdataDir + fileName 133 | imgReader, err := os.Open(path) 134 | check(t, err) 135 | 136 | if strings.HasSuffix(path, ".gz") { 137 | imgReader, err = gzip.NewReader(imgReader) 138 | check(t, err) 139 | } 140 | 141 | defer imgReader.Close() 142 | img, _, err := image.Decode(imgReader) 143 | if err != nil { 144 | log.Fatal(err) 145 | } 146 | 147 | cf := ColorFinder{} 148 | actualColor, err := cf.FindMainColor(img) 149 | check(t, err) 150 | 151 | assertEquals(t, expectedHexColor, ColorToHex(actualColor)) 152 | } 153 | 154 | func check(t *testing.T, err error) { 155 | if err != nil { 156 | fail(t, fmt.Sprintf("Unexpected error: %#v", err)) 157 | } 158 | } 159 | 160 | func assertEquals(t *testing.T, expected, actual interface{}) { 161 | if !reflect.DeepEqual(expected, actual) { 162 | fail(t, fmt.Sprintf("Not equal: %#v (expected)\n"+ 163 | " != %#v (actual)", expected, actual)) 164 | } 165 | } 166 | 167 | func fail(t *testing.T, failureMessage string) { 168 | t.Errorf("\t%s\n"+ 169 | "\r\t", 170 | failureMessage) 171 | } 172 | -------------------------------------------------------------------------------- /notices-more/noto-fonts: -------------------------------------------------------------------------------- 1 | 2 | ***** 3 | https://github.com/googlefonts/noto-fonts 4 | 5 | Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) 6 | 7 | This Font Software is licensed under the SIL Open Font License, 8 | Version 1.1. 9 | 10 | This license is copied below, and is also available with a FAQ at: 11 | http://scripts.sil.org/OFL 12 | 13 | ----------------------------------------------------------- 14 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 15 | ----------------------------------------------------------- 16 | 17 | PREAMBLE 18 | The goals of the Open Font License (OFL) are to stimulate worldwide 19 | development of collaborative font projects, to support the font 20 | creation efforts of academic and linguistic communities, and to 21 | provide a free and open framework in which fonts may be shared and 22 | improved in partnership with others. 23 | 24 | The OFL allows the licensed fonts to be used, studied, modified and 25 | redistributed freely as long as they are not sold by themselves. The 26 | fonts, including any derivative works, can be bundled, embedded, 27 | redistributed and/or sold with any software provided that any reserved 28 | names are not used by derivative works. The fonts and derivatives, 29 | however, cannot be released under any other type of license. The 30 | requirement for fonts to remain under this license does not apply to 31 | any document created using the fonts or their derivatives. 32 | 33 | DEFINITIONS 34 | "Font Software" refers to the set of files released by the Copyright 35 | Holder(s) under this license and clearly marked as such. This may 36 | include source files, build scripts and documentation. 37 | 38 | "Reserved Font Name" refers to any names specified as such after the 39 | copyright statement(s). 40 | 41 | "Original Version" refers to the collection of Font Software 42 | components as distributed by the Copyright Holder(s). 43 | 44 | "Modified Version" refers to any derivative made by adding to, 45 | deleting, or substituting -- in part or in whole -- any of the 46 | components of the Original Version, by changing formats or by porting 47 | the Font Software to a new environment. 48 | 49 | "Author" refers to any designer, engineer, programmer, technical 50 | writer or other person who contributed to the Font Software. 51 | 52 | PERMISSION & CONDITIONS 53 | Permission is hereby granted, free of charge, to any person obtaining 54 | a copy of the Font Software, to use, study, copy, merge, embed, 55 | modify, redistribute, and sell modified and unmodified copies of the 56 | Font Software, subject to the following conditions: 57 | 58 | 1) Neither the Font Software nor any of its individual components, in 59 | Original or Modified Versions, may be sold by itself. 60 | 61 | 2) Original or Modified Versions of the Font Software may be bundled, 62 | redistributed and/or sold with any software, provided that each copy 63 | contains the above copyright notice and this license. These can be 64 | included either as stand-alone text files, human-readable headers or 65 | in the appropriate machine-readable metadata fields within text or 66 | binary files as long as those fields can be easily viewed by the user. 67 | 68 | 3) No Modified Version of the Font Software may use the Reserved Font 69 | Name(s) unless explicit written permission is granted by the 70 | corresponding Copyright Holder. This restriction only applies to the 71 | primary font name as presented to the users. 72 | 73 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 74 | Software shall not be used to promote, endorse or advertise any 75 | Modified Version, except to acknowledge the contribution(s) of the 76 | Copyright Holder(s) and the Author(s) or with their explicit written 77 | permission. 78 | 79 | 5) The Font Software, modified or unmodified, in part or in whole, 80 | must be distributed entirely under this license, and must not be 81 | distributed under any other license. The requirement for fonts to 82 | remain under this license does not apply to any document created using 83 | the Font Software. 84 | 85 | TERMINATION 86 | This license becomes null and void if any of the above conditions are 87 | not met. 88 | 89 | DISCLAIMER 90 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 91 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 92 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 93 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 94 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 95 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 96 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 97 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 98 | OTHER DEALINGS IN THE FONT SOFTWARE. 99 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | The Favicon Finder 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |

The Favicon Finder 29 | 30 |

31 |
32 |

A service finding icons on web sites

33 |
34 | 35 |
36 |

What Is This About?

37 |

38 | Web sites used to have a favicon.ico, or not. 39 | With the introduction of the apple-touch-icon.png finding “the” icon for a site became more complicated. 40 | This service finds and — if necessary — generates icons for web sites. 41 |

42 | 43 |

Find Icon

44 | 45 |
46 |
47 |
48 | 50 |
51 |
52 |
53 |
54 | 55 |
56 |
57 |
58 |
59 | Examples: 60 | github.com, 61 | yelp.com, 62 | Popular Sites 63 |
64 | 65 |

URL API

66 | 67 | You can just link to an icon using the required url and size parameters: 68 |

69 | 70 | 71 | https://besticon-demo.herokuapp.com/icon?url=github.com&size=80..120..200 72 | 73 |

74 | Find more detailed information in the Readme on GitHub. 75 | 76 | 77 |

Demo version & Self hosting

78 |

79 | This here is just a demo version of besticon running on Heroku. 80 | It will stop working once the free hours per month have been used. 81 |

82 | 83 |

84 | Therefore, if you want to depend on besticon in your own projects you should 85 | host your own version as explained over at https://github.com/mat/besticon. 86 | It's super easy, really. 87 |

88 | 89 |

Acknowledgments

90 | Fortunately this site is able to rely on several fine pieces of work from others. I'd like to thank 91 | 96 |
97 |
98 |
99 | 100 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build ./... 3 | test_all: build test test_bench 4 | go test -v github.com/mat/besticon/v3/besticon/iconserver 5 | 6 | test: 7 | go test -v github.com/mat/besticon/v3/ico 8 | go test -v github.com/mat/besticon/v3/besticon 9 | go test -v github.com/mat/besticon/v3/besticon/iconserver 10 | go test -v github.com/mat/besticon/v3/lettericon 11 | go test -v github.com/mat/besticon/v3/colorfinder 12 | 13 | test_race: 14 | go test -v -race github.com/mat/besticon/v3/ico 15 | go test -v -race github.com/mat/besticon/v3/besticon 16 | go test -v -race github.com/mat/besticon/v3/besticon/iconserver 17 | go test -v -race github.com/mat/besticon/v3/lettericon 18 | go test -v -race github.com/mat/besticon/v3/colorfinder 19 | 20 | test_bench: 21 | go test github.com/mat/besticon/v3/lettericon -bench . 22 | go test github.com/mat/besticon/v3/colorfinder -bench . 23 | 24 | deploy: 25 | git push heroku master 26 | heroku config:set DEPLOYED_AT=`date +%s` 27 | 28 | install: 29 | go get ./... 30 | 31 | run_server: 32 | go build -tags netgo -ldflags '-s -w' -o bin/iconserver github.com/mat/besticon/v3/besticon/iconserver 33 | PORT=3000 DEPLOYED_AT=`date +%s` HOST_ONLY_DOMAINS=* POPULAR_SITES=bing.com,github.com,instagram.com,reddit.com ./bin/iconserver 34 | 35 | coverage_besticon: 36 | go test -coverprofile=coverage.out -covermode=count github.com/mat/besticon/v3/besticon && go tool cover -html=coverage.out && unlink coverage.out 37 | 38 | coverage_ico: 39 | go test -coverprofile=coverage.out -covermode=count github.com/mat/besticon/v3/ico && go tool cover -html=coverage.out && unlink coverage.out 40 | 41 | coverage_iconserver: 42 | go test -coverprofile=coverage.out -covermode=count github.com/mat/besticon/v3/besticon/iconserver && go tool cover -html=coverage.out && unlink coverage.out 43 | 44 | test_websites: 45 | go get ./... 46 | cat besticon/testdata/websites.txt | xargs -P 10 -n 1 besticon 47 | 48 | minify_css: 49 | curl -X POST -s --data-urlencode 'input@besticon/iconserver/assets/main.css' http://cssminifier.com/raw > besticon/iconserver/assets/main-min.css 50 | 51 | gotags: 52 | gotags -tag-relative=true -R=true -sort=true -f="tags" -fields=+l . 53 | 54 | staticcheck: 55 | staticcheck ./... 56 | 57 | # 58 | ## Building ## 59 | # 60 | 61 | clean: 62 | rm -rf bin/* 63 | rm -f iconserver*.zip 64 | 65 | build_darwin_amd64: 66 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -a -installsuffix cgo -o bin/darwin_amd64/iconserver -ldflags "-s -w -X github.com/mat/besticon/v3/besticon.BuildDate=`date +'%Y-%m-%d'`" github.com/mat/besticon/v3/besticon/iconserver 67 | 68 | build_linux_amd64: 69 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -a -installsuffix cgo -o bin/linux_amd64/iconserver -ldflags "-s -w -X github.com/mat/besticon/v3/besticon.BuildDate=`date +'%Y-%m-%d'`" github.com/mat/besticon/v3/besticon/iconserver 70 | 71 | build_linux_arm64: 72 | GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -a -installsuffix cgo -o bin/linux_arm64/iconserver -ldflags "-s -w -X github.com/mat/besticon/v3/besticon.BuildDate=`date +'%Y-%m-%d'`" github.com/mat/besticon/v3/besticon/iconserver 73 | 74 | build_windows_amd64: 75 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -a -installsuffix cgo -o bin/windows_amd64/iconserver.exe -ldflags "-s -w -X github.com/mat/besticon/v3/besticon.BuildDate=`date +'%Y-%m-%d'`" github.com/mat/besticon/v3/besticon/iconserver 76 | 77 | build_all_platforms: build_darwin_amd64 build_linux_amd64 build_linux_arm64 build_windows_amd64 78 | find bin/ -type file | xargs file 79 | 80 | ## Docker ## 81 | docker_build_image: 82 | docker build --platform=linux/amd64 --build-arg TARGETARCH=amd64 -t matthiasluedtke/iconserver:latest -t matthiasluedtke/iconserver:`cat VERSION` . 83 | 84 | docker_run: 85 | docker run -p 3000:8080 --env-file docker_run.env matthiasluedtke/iconserver:latest 86 | 87 | docker_push_images_all: docker_push_image_latest docker_push_image_version 88 | 89 | docker_push_image_latest: 90 | docker push matthiasluedtke/iconserver:latest 91 | 92 | docker_push_image_version: 93 | docker push matthiasluedtke/iconserver:`cat VERSION` 94 | 95 | docker_release: docker_build_image docker_push_images_all 96 | 97 | ## New GitHub Release ## 98 | github_new_release: new_release_tag github_package 99 | gh release create $(shell cat VERSION) 100 | gh release upload $(shell cat VERSION) iconserver_*.zip 101 | 102 | github_package: clean build_all_platforms 103 | zip -o -j iconserver_darwin-amd64 bin/darwin_amd64/* Readme.markdown LICENSE NOTICES 104 | zip -o -j iconserver_linux_amd64 bin/linux_amd64/* Readme.markdown LICENSE NOTICES 105 | zip -o -j iconserver_linux_arm64 bin/linux_arm64/* Readme.markdown LICENSE NOTICES 106 | zip -o -j iconserver_windows_amd64 bin/windows_amd64/* Readme.markdown LICENSE NOTICES 107 | file iconserver*.zip 108 | ls -alht iconserver*.zip 109 | 110 | new_release_tag: update_notices_file bump_version rewrite-version.go git_tag_version 111 | 112 | bump_version: 113 | vi VERSION 114 | 115 | rewrite-version.go: 116 | echo "package besticon\n\n// Version string, same as VERSION, generated my Make\nconst VersionString = \"`cat VERSION`\"" > besticon/version.go 117 | 118 | git_tag_version: 119 | git commit VERSION besticon/version.go -m "Release `cat VERSION`" 120 | git tag `cat VERSION` 121 | git push --tags 122 | git push 123 | 124 | update_notices_file: 125 | licensed cache 126 | licensed notice 127 | cp .licenses/NOTICE NOTICES 128 | cat notices-more/* >> NOTICES 129 | git commit NOTICES -m "Update NOTICES" || echo "No change to NOTICES to commit" 130 | -------------------------------------------------------------------------------- /colorfinder/colorfinder.go: -------------------------------------------------------------------------------- 1 | package colorfinder 2 | 3 | // colorfinder takes an image and tries to find its main color. 4 | // It is a liberal port of 5 | // http://pieroxy.net/blog/pages/color-finder/demo.html 6 | 7 | import ( 8 | "fmt" 9 | "image" 10 | "io" 11 | "log" 12 | "math" 13 | "net/http" 14 | "os" 15 | "strings" 16 | 17 | "image/color" 18 | 19 | // Load supported image formats 20 | _ "image/gif" 21 | _ "image/jpeg" 22 | _ "image/png" 23 | 24 | _ "github.com/mat/besticon/v3/ico" 25 | ) 26 | 27 | //lint:ignore U1000 unused main function 28 | func main() { 29 | arg := os.Args[1] 30 | 31 | var imageReader io.ReadCloser 32 | if strings.HasPrefix(arg, "http") { 33 | var err error 34 | response, err := http.Get(arg) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | imageReader = response.Body 39 | } else { 40 | var err error 41 | fmt.Fprintln(os.Stderr, "Reading "+arg+"...") 42 | imageReader, err = os.Open(arg) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | } 47 | defer imageReader.Close() 48 | 49 | img, _, err := image.Decode(imageReader) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | 54 | cf := ColorFinder{} 55 | c, err := cf.FindMainColor(img) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | fmt.Println("#" + ColorToHex(c)) 60 | } 61 | 62 | type ColorFinder struct { 63 | img image.Image 64 | } 65 | 66 | // FindMainColor tries to identify the most important color in the given logo. 67 | func (cf *ColorFinder) FindMainColor(img image.Image) (color.RGBA, error) { 68 | cf.img = img 69 | 70 | colorMap := cf.buildColorMap() 71 | 72 | sRGB := cf.findMainColor(colorMap, 6, nil) 73 | sRGB = cf.findMainColor(colorMap, 4, &sRGB) 74 | sRGB = cf.findMainColor(colorMap, 2, &sRGB) 75 | sRGB = cf.findMainColor(colorMap, 0, &sRGB) 76 | 77 | return sRGB.rgb, nil 78 | } 79 | 80 | const sampleThreshold = 160 * 160 81 | 82 | func (cf *ColorFinder) buildColorMap() *map[color.RGBA]colorStats { 83 | colorMap := make(map[color.RGBA]colorStats) 84 | bounds := cf.img.Bounds() 85 | 86 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 87 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 88 | r, g, b, a := cf.img.At(x, y).RGBA() 89 | rgb := color.RGBA{} 90 | rgb.R = uint8(r >> shiftRGB) 91 | rgb.G = uint8(g >> shiftRGB) 92 | rgb.B = uint8(b >> shiftRGB) 93 | rgb.A = uint8(a >> shiftRGB) 94 | 95 | colrStats, exist := colorMap[rgb] 96 | if exist { 97 | colrStats.count++ 98 | } else { 99 | colrStats := colorStats{count: 1, weight: weight(&rgb)} 100 | if colrStats.weight <= 0 { 101 | colrStats.weight = 1e-10 102 | } 103 | colorMap[rgb] = colrStats 104 | } 105 | } 106 | } 107 | return &colorMap 108 | } 109 | 110 | // Turns out using this is faster than using 111 | // RGBAModel.Convert(img.At(x, y))).(color.RGBA) 112 | const shiftRGB = uint8(8) 113 | 114 | func (cf *ColorFinder) findMainColor(colorMap *map[color.RGBA]colorStats, shift uint, targetColor *shiftedRGBA) shiftedRGBA { 115 | colorWeights := make(map[shiftedRGBA]float64) 116 | 117 | bounds := cf.img.Bounds() 118 | stepLength := stepLength(bounds) 119 | 120 | for y := bounds.Min.Y; y < bounds.Max.Y; y += stepLength { 121 | for x := bounds.Min.X; x < bounds.Max.X; x += stepLength { 122 | r, g, b, a := cf.img.At(x, y).RGBA() 123 | color := color.RGBA{} 124 | color.R = uint8(r >> shiftRGB) 125 | color.G = uint8(g >> shiftRGB) 126 | color.B = uint8(b >> shiftRGB) 127 | color.A = uint8(a >> shiftRGB) 128 | 129 | if rgbMatchesTargetColor(targetColor, &color) { 130 | increaseColorWeight(&colorWeights, colorMap, &color, shift) 131 | } 132 | } 133 | } 134 | 135 | maxColor := shiftedRGBA{} 136 | maxWeight := 0.0 137 | for sRGB, weight := range colorWeights { 138 | if weight > maxWeight { 139 | maxColor = sRGB 140 | maxWeight = weight 141 | } 142 | } 143 | 144 | return maxColor 145 | } 146 | 147 | func increaseColorWeight(weightedColors *map[shiftedRGBA]float64, colorMap *map[color.RGBA]colorStats, rgb *color.RGBA, shift uint) { 148 | shiftedColor := color.RGBA{R: rgb.R >> shift, G: rgb.G >> shift, B: rgb.B >> shift} 149 | pixelGroup := shiftedRGBA{rgb: shiftedColor, shift: shift} 150 | colorStats := (*colorMap)[*rgb] 151 | (*weightedColors)[pixelGroup] += colorStats.weight * float64(colorStats.count) 152 | } 153 | 154 | type shiftedRGBA struct { 155 | rgb color.RGBA 156 | shift uint 157 | } 158 | 159 | func rgbMatchesTargetColor(targetCol *shiftedRGBA, rgb *color.RGBA) bool { 160 | if targetCol == nil { 161 | return true 162 | } 163 | 164 | return targetCol.rgb.R == (rgb.R>>targetCol.shift) && 165 | targetCol.rgb.G == (rgb.G>>targetCol.shift) && 166 | targetCol.rgb.B == (rgb.B>>targetCol.shift) 167 | } 168 | 169 | type colorStats struct { 170 | weight float64 171 | count int64 172 | } 173 | 174 | func stepLength(bounds image.Rectangle) int { 175 | width := bounds.Dx() 176 | height := bounds.Dy() 177 | pixelCount := width * height 178 | 179 | var stepLength int 180 | if pixelCount > sampleThreshold { 181 | stepLength = 2 182 | } else { 183 | stepLength = 1 184 | } 185 | 186 | return stepLength 187 | } 188 | 189 | func weight(rgb *color.RGBA) float64 { 190 | rr := float64(rgb.R) 191 | gg := float64(rgb.G) 192 | bb := float64(rgb.B) 193 | return (abs(rr-gg)*abs(rr-gg)+abs(rr-bb)*abs(rr-bb)+abs(gg-bb)*abs(gg-bb))/65535.0*1000.0 + 1 194 | } 195 | 196 | func abs(n float64) float64 { 197 | return math.Abs(float64(n)) 198 | } 199 | 200 | func ColorToHex(c color.RGBA) string { 201 | return fmt.Sprintf("%02x%02x%02x", c.R, c.G, c.B) 202 | } 203 | -------------------------------------------------------------------------------- /vcr/vcr.go: -------------------------------------------------------------------------------- 1 | package vcr 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/http/httputil" 12 | "os" 13 | "path" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | ) 18 | 19 | func Client(vcrPath string) (*http.Client, io.Closer, error) { 20 | gz, e := os.Open(vcrPath) 21 | 22 | // Record VCR 23 | if e != nil { 24 | f, e := os.Create(vcrPath) 25 | if e != nil { 26 | return nil, f, e 27 | } 28 | gz := gzip.NewWriter(f) 29 | client := NewRecordingClient(gz) 30 | return &client, gz, nil 31 | } 32 | 33 | // Replay VCR 34 | f, e := gzip.NewReader(gz) 35 | if e != nil { 36 | return nil, f, e 37 | } 38 | client, e := NewReplayerClient(f) 39 | if e != nil { 40 | return nil, f, e 41 | } 42 | 43 | return &client, f, nil 44 | } 45 | 46 | func logRequest(w io.Writer, req *http.Request) error { 47 | b, err := httputil.DumpRequestOut(req, false) 48 | if err != nil { 49 | return fmt.Errorf("vcr: could not dump request: %s", err) 50 | } 51 | fmt.Fprint(w, string(b)) 52 | return nil 53 | } 54 | 55 | func dumpResonse(w io.Writer, r *http.Response, body []byte) { 56 | // Status line 57 | text := r.Status 58 | protoMajor, protoMinor := strconv.Itoa(r.ProtoMajor), strconv.Itoa(r.ProtoMinor) 59 | statusCode := strconv.Itoa(r.StatusCode) + " " 60 | text = strings.TrimPrefix(text, statusCode) 61 | if _, err := io.WriteString(w, "HTTP/"+protoMajor+"."+protoMinor+" "+statusCode+text+"\r\n"); err != nil { 62 | panic(err) 63 | } 64 | 65 | r.Header.Write(w) 66 | 67 | if _, err := io.WriteString(w, "\r\n"); err != nil { 68 | panic(err) 69 | } 70 | 71 | fmt.Fprint(w, string(body)) 72 | } 73 | 74 | func logResponse(w io.Writer, res *http.Response, body bool) { 75 | var bodyBytes []byte 76 | var err error 77 | if body { 78 | defer res.Body.Close() 79 | bodyBytes, err = io.ReadAll(res.Body) 80 | if err != nil { 81 | fmt.Printf("could not record response: %s", err) 82 | } 83 | res.Body = io.NopCloser(bytes.NewReader(bodyBytes)) 84 | } 85 | dumpResonse(w, res, bodyBytes) 86 | } 87 | 88 | const recordSeparator string = "*************vcr*************\n" 89 | 90 | func logSeparator(w io.Writer) { 91 | fmt.Fprint(w, recordSeparator) 92 | } 93 | 94 | var defaultTransport = &http.Transport{} 95 | 96 | type recorderTransport struct { 97 | mutex sync.Mutex 98 | writer io.Writer 99 | } 100 | 101 | func (t *recorderTransport) RoundTrip(r *http.Request) (*http.Response, error) { 102 | t.mutex.Lock() 103 | defer t.mutex.Unlock() 104 | 105 | resp, err := defaultTransport.RoundTrip(r) 106 | if err != nil { 107 | panic(err) 108 | } 109 | 110 | logRequest(t.writer, r) 111 | logResponse(t.writer, resp, true) 112 | logSeparator(t.writer) 113 | 114 | return resp, err 115 | } 116 | 117 | func NewRecordingClient(w io.Writer) http.Client { 118 | client := http.Client{} 119 | client.Transport = &recorderTransport{writer: w} 120 | return client 121 | } 122 | 123 | func NewReplayerClient(r io.Reader) (http.Client, error) { 124 | client := http.Client{} 125 | transport, err := NewReplayerTransport(r) 126 | if err != nil { 127 | return client, err 128 | } 129 | client.Transport = transport 130 | return client, err 131 | } 132 | 133 | type replayerTransport struct { 134 | mutex sync.Mutex 135 | requests []*http.Request 136 | responses []*http.Response 137 | } 138 | 139 | func NewReplayerTransport(reader io.Reader) (*replayerTransport, error) { 140 | t := &replayerTransport{mutex: sync.Mutex{}} 141 | t.mutex.Lock() 142 | defer t.mutex.Unlock() 143 | conversation, err := io.ReadAll(reader) 144 | if err != nil { 145 | return nil, fmt.Errorf("vcr: failed to read vcr file: %s", err) 146 | } 147 | r := bufio.NewReader(bytes.NewReader(conversation)) 148 | 149 | i := 0 150 | for { 151 | i++ 152 | // fmt.Printf("Reading Request %d\n", i) 153 | req, err := http.ReadRequest(r) 154 | if err == io.EOF { 155 | return t, nil 156 | } else if err != nil { 157 | fmt.Printf("error on request read: %s", err) 158 | return t, nil 159 | } else { 160 | t.requests = append(t.requests, req) 161 | } 162 | 163 | res, err := http.ReadResponse(r, req) // nil? 164 | if err == io.EOF { 165 | return t, nil 166 | } else if err != nil { 167 | fmt.Printf("error on response read: %s", err) 168 | return t, nil 169 | } else { 170 | res.Request.URL.Scheme = "http" 171 | res.Request.URL.Host = req.Host 172 | t.responses = append(t.responses, res) 173 | } 174 | 175 | bodyBytes := []byte{} 176 | for { 177 | line, err := r.ReadBytes('\n') 178 | separatorReached := strings.HasSuffix(string(line), recordSeparator) 179 | if separatorReached { 180 | line = bytes.TrimSuffix(line, []byte(recordSeparator)) 181 | } 182 | bodyBytes = append(bodyBytes, line...) 183 | 184 | if err == io.EOF || separatorReached { 185 | bodyReader := bytes.NewReader(bodyBytes) 186 | res.Body = io.NopCloser(bodyReader) 187 | break 188 | } else if err == nil { 189 | } else { 190 | return t, err 191 | } 192 | } 193 | } 194 | 195 | return t, nil 196 | } 197 | 198 | func (t *replayerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 199 | t.mutex.Lock() 200 | defer t.mutex.Unlock() 201 | 202 | r, e := t.findResponse(req) 203 | if e != nil { 204 | return r, e 205 | } 206 | 207 | fmt.Fprintf(os.Stderr, "Replaying Request: %s %s://%s%s: %d\n", 208 | req.Method, req.URL.Scheme, req.URL.Host, req.URL.Path, r.StatusCode) 209 | 210 | r.Request.URL.Scheme = req.URL.Scheme 211 | return r, nil 212 | } 213 | 214 | func (t *replayerTransport) findResponse(req *http.Request) (*http.Response, error) { 215 | pattern := path.Join(req.URL.Host, req.URL.Path) 216 | for i, r := range t.requests { 217 | if r == nil { 218 | continue 219 | } 220 | 221 | hostPath := path.Join(r.URL.Host, r.URL.Path) 222 | if pattern == hostPath { 223 | t.requests[i] = nil // Mark as used 224 | return t.responses[i], nil 225 | } 226 | } 227 | return nil, errors.New("vcr: no matching request/response found") 228 | } 229 | -------------------------------------------------------------------------------- /ico/ico.go: -------------------------------------------------------------------------------- 1 | // Package ico registers image.Decode and DecodeConfig support 2 | // for the icon (container) format. 3 | package ico 4 | 5 | import ( 6 | "bytes" 7 | "encoding/binary" 8 | "errors" 9 | "image" 10 | "io" 11 | 12 | "image/png" 13 | 14 | "golang.org/x/image/bmp" 15 | ) 16 | 17 | type icondir struct { 18 | Reserved uint16 19 | Type uint16 20 | Count uint16 21 | Entries []icondirEntry 22 | } 23 | 24 | type icondirEntry struct { 25 | Width byte 26 | Height byte 27 | PaletteCount byte 28 | Reserved byte 29 | ColorPlanes uint16 30 | BitsPerPixel uint16 31 | Size uint32 32 | Offset uint32 33 | } 34 | 35 | func (dir *icondir) FindBestIcon() *icondirEntry { 36 | if len(dir.Entries) == 0 { 37 | return nil 38 | } 39 | 40 | best := dir.Entries[0] 41 | for _, e := range dir.Entries { 42 | if (e.width() > best.width()) && (e.height() > best.height()) { 43 | best = e 44 | } 45 | } 46 | return &best 47 | } 48 | 49 | // ParseIco parses the icon and returns meta information for the icons as icondir. 50 | func ParseIco(r io.Reader) (*icondir, error) { 51 | dir := icondir{} 52 | 53 | var err error 54 | err = binary.Read(r, binary.LittleEndian, &dir.Reserved) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | err = binary.Read(r, binary.LittleEndian, &dir.Type) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | err = binary.Read(r, binary.LittleEndian, &dir.Count) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | for i := uint16(0); i < dir.Count; i++ { 70 | entry := icondirEntry{} 71 | e := parseIcondirEntry(r, &entry) 72 | if e != nil { 73 | return nil, e 74 | } 75 | dir.Entries = append(dir.Entries, entry) 76 | } 77 | 78 | return &dir, err 79 | } 80 | 81 | func parseIcondirEntry(r io.Reader, e *icondirEntry) error { 82 | err := binary.Read(r, binary.LittleEndian, e) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (e *icondirEntry) ColorCount() int { 91 | if e.PaletteCount == 0 { 92 | return 256 93 | } 94 | return int(e.PaletteCount) 95 | } 96 | 97 | func (e *icondirEntry) width() int { 98 | if e.Width == 0 { 99 | return 256 100 | } 101 | return int(e.Width) 102 | } 103 | 104 | func (e *icondirEntry) height() int { 105 | if e.Height == 0 { 106 | return 256 107 | } 108 | return int(e.Height) 109 | } 110 | 111 | // DecodeConfig returns just the dimensions of the largest image 112 | // contained in the icon withou decoding the entire icon file. 113 | func DecodeConfig(r io.Reader) (image.Config, error) { 114 | dir, err := ParseIco(r) 115 | if err != nil { 116 | return image.Config{}, err 117 | } 118 | 119 | best := dir.FindBestIcon() 120 | if best == nil { 121 | return image.Config{}, errInvalid 122 | } 123 | return image.Config{Width: best.width(), Height: best.height()}, nil 124 | } 125 | 126 | // The bitmap header structure we read from an icondirEntry 127 | type bitmapHeaderRead struct { 128 | Size uint32 129 | Width uint32 130 | Height uint32 131 | Planes uint16 132 | BitCount uint16 133 | Compression uint32 134 | ImageSize uint32 135 | XPixelsPerMeter uint32 136 | YPixelsPerMeter uint32 137 | ColorsUsed uint32 138 | ColorsImportant uint32 139 | } 140 | 141 | // The bitmap header structure we need to generate for bmp.Decode() 142 | type bitmapHeaderWrite struct { 143 | sigBM [2]byte 144 | fileSize uint32 145 | resverved [2]uint16 //lint:ignore U1000 unused 146 | pixOffset uint32 147 | Size uint32 148 | Width uint32 149 | Height uint32 150 | Planes uint16 151 | BitCount uint16 152 | Compression uint32 153 | ImageSize uint32 154 | XPixelsPerMeter uint32 155 | YPixelsPerMeter uint32 156 | ColorsUsed uint32 157 | ColorsImportant uint32 158 | } 159 | 160 | var errInvalid = errors.New("ico: invalid ICO image") 161 | 162 | // Decode returns the largest image contained in the icon 163 | // which might be a bmp or png 164 | func Decode(r io.Reader) (image.Image, error) { 165 | icoBytes, err := io.ReadAll(r) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | r = bytes.NewReader(icoBytes) 171 | dir, err := ParseIco(r) 172 | if err != nil { 173 | return nil, errInvalid 174 | } 175 | 176 | best := dir.FindBestIcon() 177 | if best == nil { 178 | return nil, errInvalid 179 | } 180 | 181 | return parseImage(best, icoBytes) 182 | } 183 | 184 | func parseImage(entry *icondirEntry, icoBytes []byte) (image.Image, error) { 185 | r := bytes.NewReader(icoBytes) 186 | r.Seek(int64(entry.Offset), 0) 187 | 188 | // Try PNG first then BMP 189 | img, err := png.Decode(r) 190 | if err != nil { 191 | return parseBMP(entry, icoBytes) 192 | } 193 | return img, nil 194 | } 195 | 196 | func parseBMP(entry *icondirEntry, icoBytes []byte) (image.Image, error) { 197 | bmpBytes, err := makeFullBMPBytes(entry, icoBytes) 198 | if err != nil { 199 | return nil, err 200 | } 201 | return bmp.Decode(bmpBytes) 202 | } 203 | 204 | func makeFullBMPBytes(entry *icondirEntry, icoBytes []byte) (*bytes.Buffer, error) { 205 | r := bytes.NewReader(icoBytes) 206 | r.Seek(int64(entry.Offset), 0) 207 | 208 | var err error 209 | h := bitmapHeaderRead{} 210 | 211 | err = binary.Read(r, binary.LittleEndian, &h) 212 | if err != nil { 213 | return nil, err 214 | } 215 | 216 | if h.Size != 40 || h.Planes != 1 { 217 | return nil, errInvalid 218 | } 219 | 220 | var pixOffset uint32 221 | if h.ColorsUsed == 0 && h.BitCount <= 8 { 222 | pixOffset = 14 + 40 + 4*(1< 0.01 { 250 | fail(t, fmt.Sprintf("Not equal: %v (expected)\n"+ 251 | " != %v (actual)", expected, actual)) 252 | } 253 | } 254 | 255 | func fail(t *testing.T, failureMessage string) { 256 | t.Errorf("\t%s\n"+ 257 | "\r\t", 258 | failureMessage) 259 | } 260 | -------------------------------------------------------------------------------- /besticon/iconserver/assets/grids-responsive-0.5.0-min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v0.5.0 3 | Copyright 2014 Yahoo! Inc. All rights reserved. 4 | Licensed under the BSD License. 5 | https://github.com/yui/pure/blob/master/LICENSE.md 6 | */ 7 | @media screen and (min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-2,.pure-u-sm-1-3,.pure-u-sm-2-3,.pure-u-sm-1-4,.pure-u-sm-3-4,.pure-u-sm-1-5,.pure-u-sm-2-5,.pure-u-sm-3-5,.pure-u-sm-4-5,.pure-u-sm-5-5,.pure-u-sm-1-6,.pure-u-sm-5-6,.pure-u-sm-1-8,.pure-u-sm-3-8,.pure-u-sm-5-8,.pure-u-sm-7-8,.pure-u-sm-1-12,.pure-u-sm-5-12,.pure-u-sm-7-12,.pure-u-sm-11-12,.pure-u-sm-1-24,.pure-u-sm-2-24,.pure-u-sm-3-24,.pure-u-sm-4-24,.pure-u-sm-5-24,.pure-u-sm-6-24,.pure-u-sm-7-24,.pure-u-sm-8-24,.pure-u-sm-9-24,.pure-u-sm-10-24,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-sm-1-24{width:4.1667%;*width:4.1357%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%;*width:8.3023%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%;*width:12.469%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%;*width:16.6357%}.pure-u-sm-1-5{width:20%;*width:19.969%}.pure-u-sm-5-24{width:20.8333%;*width:20.8023%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%;*width:24.969%}.pure-u-sm-7-24{width:29.1667%;*width:29.1357%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%;*width:33.3023%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%;*width:37.469%}.pure-u-sm-2-5{width:40%;*width:39.969%}.pure-u-sm-5-12,.pure-u-sm-10-24{width:41.6667%;*width:41.6357%}.pure-u-sm-11-24{width:45.8333%;*width:45.8023%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%;*width:49.969%}.pure-u-sm-13-24{width:54.1667%;*width:54.1357%}.pure-u-sm-7-12,.pure-u-sm-14-24{width:58.3333%;*width:58.3023%}.pure-u-sm-3-5{width:60%;*width:59.969%}.pure-u-sm-5-8,.pure-u-sm-15-24{width:62.5%;*width:62.469%}.pure-u-sm-2-3,.pure-u-sm-16-24{width:66.6667%;*width:66.6357%}.pure-u-sm-17-24{width:70.8333%;*width:70.8023%}.pure-u-sm-3-4,.pure-u-sm-18-24{width:75%;*width:74.969%}.pure-u-sm-19-24{width:79.1667%;*width:79.1357%}.pure-u-sm-4-5{width:80%;*width:79.969%}.pure-u-sm-5-6,.pure-u-sm-20-24{width:83.3333%;*width:83.3023%}.pure-u-sm-7-8,.pure-u-sm-21-24{width:87.5%;*width:87.469%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%;*width:91.6357%}.pure-u-sm-23-24{width:95.8333%;*width:95.8023%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-5-5,.pure-u-sm-24-24{width:100%}}@media screen and (min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-2,.pure-u-md-1-3,.pure-u-md-2-3,.pure-u-md-1-4,.pure-u-md-3-4,.pure-u-md-1-5,.pure-u-md-2-5,.pure-u-md-3-5,.pure-u-md-4-5,.pure-u-md-5-5,.pure-u-md-1-6,.pure-u-md-5-6,.pure-u-md-1-8,.pure-u-md-3-8,.pure-u-md-5-8,.pure-u-md-7-8,.pure-u-md-1-12,.pure-u-md-5-12,.pure-u-md-7-12,.pure-u-md-11-12,.pure-u-md-1-24,.pure-u-md-2-24,.pure-u-md-3-24,.pure-u-md-4-24,.pure-u-md-5-24,.pure-u-md-6-24,.pure-u-md-7-24,.pure-u-md-8-24,.pure-u-md-9-24,.pure-u-md-10-24,.pure-u-md-11-24,.pure-u-md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-md-1-24{width:4.1667%;*width:4.1357%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%;*width:8.3023%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%;*width:12.469%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%;*width:16.6357%}.pure-u-md-1-5{width:20%;*width:19.969%}.pure-u-md-5-24{width:20.8333%;*width:20.8023%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%;*width:24.969%}.pure-u-md-7-24{width:29.1667%;*width:29.1357%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%;*width:33.3023%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%;*width:37.469%}.pure-u-md-2-5{width:40%;*width:39.969%}.pure-u-md-5-12,.pure-u-md-10-24{width:41.6667%;*width:41.6357%}.pure-u-md-11-24{width:45.8333%;*width:45.8023%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%;*width:49.969%}.pure-u-md-13-24{width:54.1667%;*width:54.1357%}.pure-u-md-7-12,.pure-u-md-14-24{width:58.3333%;*width:58.3023%}.pure-u-md-3-5{width:60%;*width:59.969%}.pure-u-md-5-8,.pure-u-md-15-24{width:62.5%;*width:62.469%}.pure-u-md-2-3,.pure-u-md-16-24{width:66.6667%;*width:66.6357%}.pure-u-md-17-24{width:70.8333%;*width:70.8023%}.pure-u-md-3-4,.pure-u-md-18-24{width:75%;*width:74.969%}.pure-u-md-19-24{width:79.1667%;*width:79.1357%}.pure-u-md-4-5{width:80%;*width:79.969%}.pure-u-md-5-6,.pure-u-md-20-24{width:83.3333%;*width:83.3023%}.pure-u-md-7-8,.pure-u-md-21-24{width:87.5%;*width:87.469%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%;*width:91.6357%}.pure-u-md-23-24{width:95.8333%;*width:95.8023%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-5-5,.pure-u-md-24-24{width:100%}}@media screen and (min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-2,.pure-u-lg-1-3,.pure-u-lg-2-3,.pure-u-lg-1-4,.pure-u-lg-3-4,.pure-u-lg-1-5,.pure-u-lg-2-5,.pure-u-lg-3-5,.pure-u-lg-4-5,.pure-u-lg-5-5,.pure-u-lg-1-6,.pure-u-lg-5-6,.pure-u-lg-1-8,.pure-u-lg-3-8,.pure-u-lg-5-8,.pure-u-lg-7-8,.pure-u-lg-1-12,.pure-u-lg-5-12,.pure-u-lg-7-12,.pure-u-lg-11-12,.pure-u-lg-1-24,.pure-u-lg-2-24,.pure-u-lg-3-24,.pure-u-lg-4-24,.pure-u-lg-5-24,.pure-u-lg-6-24,.pure-u-lg-7-24,.pure-u-lg-8-24,.pure-u-lg-9-24,.pure-u-lg-10-24,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-lg-1-24{width:4.1667%;*width:4.1357%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%;*width:8.3023%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%;*width:12.469%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%;*width:16.6357%}.pure-u-lg-1-5{width:20%;*width:19.969%}.pure-u-lg-5-24{width:20.8333%;*width:20.8023%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%;*width:24.969%}.pure-u-lg-7-24{width:29.1667%;*width:29.1357%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%;*width:33.3023%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%;*width:37.469%}.pure-u-lg-2-5{width:40%;*width:39.969%}.pure-u-lg-5-12,.pure-u-lg-10-24{width:41.6667%;*width:41.6357%}.pure-u-lg-11-24{width:45.8333%;*width:45.8023%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%;*width:49.969%}.pure-u-lg-13-24{width:54.1667%;*width:54.1357%}.pure-u-lg-7-12,.pure-u-lg-14-24{width:58.3333%;*width:58.3023%}.pure-u-lg-3-5{width:60%;*width:59.969%}.pure-u-lg-5-8,.pure-u-lg-15-24{width:62.5%;*width:62.469%}.pure-u-lg-2-3,.pure-u-lg-16-24{width:66.6667%;*width:66.6357%}.pure-u-lg-17-24{width:70.8333%;*width:70.8023%}.pure-u-lg-3-4,.pure-u-lg-18-24{width:75%;*width:74.969%}.pure-u-lg-19-24{width:79.1667%;*width:79.1357%}.pure-u-lg-4-5{width:80%;*width:79.969%}.pure-u-lg-5-6,.pure-u-lg-20-24{width:83.3333%;*width:83.3023%}.pure-u-lg-7-8,.pure-u-lg-21-24{width:87.5%;*width:87.469%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%;*width:91.6357%}.pure-u-lg-23-24{width:95.8333%;*width:95.8023%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-5-5,.pure-u-lg-24-24{width:100%}}@media screen and (min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-2,.pure-u-xl-1-3,.pure-u-xl-2-3,.pure-u-xl-1-4,.pure-u-xl-3-4,.pure-u-xl-1-5,.pure-u-xl-2-5,.pure-u-xl-3-5,.pure-u-xl-4-5,.pure-u-xl-5-5,.pure-u-xl-1-6,.pure-u-xl-5-6,.pure-u-xl-1-8,.pure-u-xl-3-8,.pure-u-xl-5-8,.pure-u-xl-7-8,.pure-u-xl-1-12,.pure-u-xl-5-12,.pure-u-xl-7-12,.pure-u-xl-11-12,.pure-u-xl-1-24,.pure-u-xl-2-24,.pure-u-xl-3-24,.pure-u-xl-4-24,.pure-u-xl-5-24,.pure-u-xl-6-24,.pure-u-xl-7-24,.pure-u-xl-8-24,.pure-u-xl-9-24,.pure-u-xl-10-24,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-19-24,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xl-1-24{width:4.1667%;*width:4.1357%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%;*width:8.3023%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%;*width:12.469%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%;*width:16.6357%}.pure-u-xl-1-5{width:20%;*width:19.969%}.pure-u-xl-5-24{width:20.8333%;*width:20.8023%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%;*width:24.969%}.pure-u-xl-7-24{width:29.1667%;*width:29.1357%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%;*width:33.3023%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%;*width:37.469%}.pure-u-xl-2-5{width:40%;*width:39.969%}.pure-u-xl-5-12,.pure-u-xl-10-24{width:41.6667%;*width:41.6357%}.pure-u-xl-11-24{width:45.8333%;*width:45.8023%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%;*width:49.969%}.pure-u-xl-13-24{width:54.1667%;*width:54.1357%}.pure-u-xl-7-12,.pure-u-xl-14-24{width:58.3333%;*width:58.3023%}.pure-u-xl-3-5{width:60%;*width:59.969%}.pure-u-xl-5-8,.pure-u-xl-15-24{width:62.5%;*width:62.469%}.pure-u-xl-2-3,.pure-u-xl-16-24{width:66.6667%;*width:66.6357%}.pure-u-xl-17-24{width:70.8333%;*width:70.8023%}.pure-u-xl-3-4,.pure-u-xl-18-24{width:75%;*width:74.969%}.pure-u-xl-19-24{width:79.1667%;*width:79.1357%}.pure-u-xl-4-5{width:80%;*width:79.969%}.pure-u-xl-5-6,.pure-u-xl-20-24{width:83.3333%;*width:83.3023%}.pure-u-xl-7-8,.pure-u-xl-21-24{width:87.5%;*width:87.469%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%;*width:91.6357%}.pure-u-xl-23-24{width:95.8333%;*width:95.8023%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-5-5,.pure-u-xl-24-24{width:100%}} -------------------------------------------------------------------------------- /lettericon/lettericon.go: -------------------------------------------------------------------------------- 1 | package lettericon 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "image" 10 | "image/color" 11 | "image/draw" 12 | "image/png" 13 | "io" 14 | "log" 15 | "math" 16 | "net/url" 17 | "path" 18 | "path/filepath" 19 | "strconv" 20 | "strings" 21 | 22 | "golang.org/x/image/font" 23 | "golang.org/x/image/math/fixed" 24 | "golang.org/x/net/publicsuffix" 25 | 26 | "github.com/golang/freetype/truetype" 27 | "github.com/mat/besticon/v3/colorfinder" 28 | "github.com/mat/besticon/v3/lettericon/fonts" 29 | ) 30 | 31 | const dpi = 72 32 | 33 | const fontSizeFactor = 0.6180340 // (by taste) 34 | const yOffsetFactor = 102.0 / 1024.0 // (by trial and error) :-) 35 | 36 | func RenderPNG(letter string, bgColor color.Color, width int, out io.Writer) error { 37 | fg := pickForegroundColor(bgColor) 38 | 39 | rgba := image.NewRGBA(image.Rect(0, 0, width, width)) 40 | draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) 41 | 42 | fontSize := fontSizeFactor * float64(width) 43 | d := &font.Drawer{ 44 | Dst: rgba, 45 | Src: &image.Uniform{fg}, 46 | Face: truetype.NewFace(fnt, &truetype.Options{ 47 | Size: fontSize, 48 | DPI: dpi, 49 | Hinting: font.HintingNone, 50 | }), 51 | } 52 | 53 | y := int(yOffsetFactor*float64(width)) + int(math.Ceil(fontSize*dpi/72)) 54 | d.Dot = fixed.Point26_6{ 55 | X: (fixed.I(width) - d.MeasureString(letter)) / 2, 56 | Y: fixed.I(y), 57 | } 58 | d.DrawString(letter) 59 | 60 | b := bufio.NewWriter(out) 61 | encoder := png.Encoder{CompressionLevel: png.BestCompression} 62 | err := encoder.Encode(b, rgba) 63 | if err != nil { 64 | return err 65 | } 66 | err = b.Flush() 67 | if err != nil { 68 | return err 69 | } 70 | return nil 71 | } 72 | 73 | func pickForegroundColor(bgColor color.Color) color.Color { 74 | cWhite := contrastRatio(pickLighterColor(color.White, bgColor)) 75 | 76 | // We prefer white text, this ratio was deemed good enough 77 | if cWhite > 1.5 { 78 | return color.White 79 | } 80 | return lightDark 81 | } 82 | 83 | func pickLighterColor(c1, c2 color.Color) (color.Color, color.Color) { 84 | _, _, v1 := RGBToHSV(c1) 85 | _, _, v2 := RGBToHSV(c2) 86 | 87 | if v1 >= v2 { 88 | return c1, c2 89 | } 90 | return c2, c1 91 | } 92 | 93 | // https://code.google.com/p/gorilla/source/browse/color/hsv.go?r=ef489f63418265a7249b1d53bdc358b09a4a2ea0 94 | func RGBToHSV(c color.Color) (h, s, v float64) { 95 | r, g, b, _ := c.RGBA() 96 | fR := float64(r) / 255 97 | fG := float64(g) / 255 98 | fB := float64(b) / 255 99 | max := math.Max(math.Max(fR, fG), fB) 100 | min := math.Min(math.Min(fR, fG), fB) 101 | d := max - min 102 | s, v = 0, max 103 | if max > 0 { 104 | s = d / max 105 | } 106 | if max == min { 107 | // Achromatic. 108 | h = 0 109 | } else { 110 | // Chromatic. 111 | switch max { 112 | case fR: 113 | h = (fG - fB) / d 114 | if fG < fB { 115 | h += 6 116 | } 117 | case fG: 118 | h = (fB-fR)/d + 2 119 | case fB: 120 | h = (fR-fG)/d + 4 121 | } 122 | h /= 6 123 | } 124 | return 125 | } 126 | 127 | func contrastRatio(c1 color.Color, c2 color.Color) float64 { 128 | // http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef 129 | 130 | l1 := relativeLuminance(c1) 131 | l2 := relativeLuminance(c2) 132 | 133 | return (l1 + 0.05) / (l2 + 0.05) 134 | } 135 | 136 | func relativeLuminance(c color.Color) float64 { 137 | r, g, b, _ := c.RGBA() 138 | r64 := foo(r) 139 | g64 := foo(g) 140 | b64 := foo(b) 141 | 142 | return 0.2126*r64 + 0.7152*g64 + 0.0722*b64 143 | } 144 | 145 | const shiftRGB = uint8(8) 146 | 147 | func foo(col uint32) float64 { 148 | c := float64(uint8(col >> shiftRGB)) 149 | c /= 255.0 150 | 151 | if c < 0.03928 { 152 | return c / 12.92 153 | } 154 | 155 | return math.Pow(((c + 0.055) / 1.055), 2.4) 156 | } 157 | 158 | var ( 159 | errMalformedColorString = errors.New("malformed hex color string") 160 | ) 161 | 162 | func ColorFromHex(hex string) (*color.RGBA, error) { 163 | if len(hex) != 6 && len(hex) != 7 { 164 | return nil, errMalformedColorString 165 | } 166 | hex = strings.TrimPrefix(hex, "#") 167 | 168 | r, err := strconv.ParseUint(hex[0:2], 16, 8) 169 | if err != nil { 170 | return nil, errMalformedColorString 171 | } 172 | g, err := strconv.ParseUint(hex[2:4], 16, 8) 173 | if err != nil { 174 | return nil, errMalformedColorString 175 | } 176 | b, err := strconv.ParseUint(hex[4:6], 16, 8) 177 | if err != nil { 178 | return nil, errMalformedColorString 179 | } 180 | 181 | col := color.RGBA{uint8(r), uint8(g), uint8(b), 0xff} 182 | return &col, nil 183 | } 184 | 185 | func IconPath(letter string, size string, colr *color.RGBA, format string) string { 186 | var parts []string 187 | 188 | // letter 189 | if letter == "" { 190 | letter = " " 191 | } else { 192 | letter = strings.ToUpper(letter) 193 | } 194 | parts = append(parts, letter) 195 | 196 | // size (maybe) 197 | if format == "png" { 198 | parts = append(parts, size) 199 | } 200 | 201 | // colr (maybe) 202 | if colr != nil { 203 | parts = append(parts, colorfinder.ColorToHex(*colr)) 204 | } 205 | 206 | return fmt.Sprintf("/lettericons/%s.%s", strings.Join(parts, "-"), format) 207 | } 208 | 209 | const defaultIconSize = 144 210 | 211 | // TODO: Sync with besticon.MaxIconSize ? 212 | const maxIconSize = 256 213 | 214 | // path is like: lettericons/M-144-EFC25D.png 215 | func ParseIconPath(fullpath string) (string, *color.RGBA, int, string) { 216 | fullpath = percentDecode(fullpath) 217 | 218 | _, filename := path.Split(fullpath) 219 | 220 | // format 221 | format := filepath.Ext(filename) 222 | if !(format == ".png" || format == ".svg") { 223 | return "", nil, -1, "" 224 | } 225 | filename = strings.TrimSuffix(filename, format) 226 | format = format[1:] // remove period 227 | 228 | // now we parse each of the params, delimited by "-" 229 | params := strings.Split(filename, "-") 230 | if len(params) == 0 { 231 | return "", nil, -1, "" 232 | } 233 | for _, s := range params { 234 | if len(s) == 0 { 235 | return "", nil, -1, "" 236 | } 237 | } 238 | 239 | var letter string 240 | var size int 241 | var col *color.RGBA 242 | 243 | // letter 244 | letter, params = firstRune(params[0]), params[1:] 245 | 246 | // size (only png) 247 | if format == "png" && len(params) > 0 { 248 | size, _ = strconv.Atoi(params[0]) 249 | params = params[1:] 250 | } 251 | if size < 1 { 252 | size = defaultIconSize 253 | } 254 | if size > maxIconSize { 255 | size = maxIconSize 256 | } 257 | 258 | // color 259 | if len(params) > 0 { 260 | col, _ = ColorFromHex(params[0]) 261 | params = params[1:] 262 | } 263 | if col == nil { 264 | col = DefaultBackgroundColor 265 | } 266 | 267 | // extra stuff at the end? error 268 | if len(params) > 0 { 269 | return "", nil, -1, "" 270 | } 271 | 272 | return letter, col, size, format 273 | } 274 | 275 | func MainLetterFromURL(URL string) string { 276 | URL = strings.TrimSpace(URL) 277 | if !strings.HasPrefix(URL, "http:") && !strings.HasPrefix(URL, "https:") { 278 | URL = "http://" + URL 279 | } 280 | 281 | url, err := url.Parse(URL) 282 | if err != nil { 283 | return "" 284 | } 285 | 286 | host := url.Host 287 | hostSuffix, _ := publicsuffix.PublicSuffix(host) 288 | if hostSuffix != "" { 289 | host = strings.TrimSuffix(host, hostSuffix) 290 | host = strings.TrimSuffix(host, ".") 291 | } 292 | 293 | hostParts := strings.Split(host, ".") 294 | domain := hostParts[len(hostParts)-1] 295 | if len(domain) > 0 { 296 | return firstRune(domain) 297 | } else if len(hostSuffix) > 0 { 298 | return string(hostSuffix[0]) 299 | } 300 | 301 | return "" 302 | } 303 | 304 | func firstRune(str string) string { 305 | for _, runeValue := range str { 306 | return fmt.Sprintf("%c", runeValue) 307 | } 308 | return "" 309 | } 310 | 311 | func percentDecode(p string) string { 312 | if !strings.HasPrefix(p, "/") { 313 | p = "/" + p 314 | } 315 | u, err := url.ParseRequestURI(p) 316 | 317 | if err != nil { 318 | return p 319 | } 320 | return u.Path 321 | } 322 | 323 | const svgTemplate = ` 324 | 325 | 326 | $LETTER 327 | 328 | ` 329 | 330 | // RenderSVG writes an SVG lettericon for this letter and color 331 | func RenderSVG(letter string, bgColor color.Color, out io.Writer) error { 332 | // xml escape letter 333 | var buf bytes.Buffer 334 | err := xml.EscapeText(&buf, []byte(letter)) 335 | if err != nil { 336 | return err 337 | } 338 | 339 | // vars 340 | vars := map[string]string{ 341 | "$BG_COLOR": ColorToHex(bgColor), 342 | "$FG_COLOR": ColorToHex(pickForegroundColor(bgColor)), 343 | "$LETTER": buf.String(), 344 | } 345 | 346 | // render SVG by replacing vars in template 347 | svg := strings.TrimSpace(svgTemplate) + "\n" 348 | for k, v := range vars { 349 | svg = strings.ReplaceAll(svg, k, v) 350 | } 351 | 352 | _, err = io.WriteString(out, svg) 353 | return err 354 | } 355 | 356 | // ColorToHex returns the #rrggbb hex string for a color 357 | func ColorToHex(c color.Color) string { 358 | r, g, b, _ := c.RGBA() 359 | return fmt.Sprintf("#%02x%02x%02x", r&0xff, g&0xff, b&0xff) 360 | } 361 | 362 | var fnt *truetype.Font 363 | 364 | var DefaultBackgroundColor *color.RGBA 365 | var lightDark *color.RGBA 366 | 367 | func init() { 368 | var err error 369 | fnt, err = truetype.Parse(fonts.NotoSansRegularBytes()) 370 | if err != nil { 371 | log.Println(err) 372 | return 373 | } 374 | 375 | DefaultBackgroundColor, _ = ColorFromHex("#909090") 376 | lightDark, _ = ColorFromHex("#505050") 377 | } 378 | -------------------------------------------------------------------------------- /besticon/iconserver/server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/mat/besticon/v3/besticon" 15 | ) 16 | 17 | func TestGetIndex(t *testing.T) { 18 | req, err := http.NewRequest("GET", "/", nil) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | w := httptest.NewRecorder() 24 | s := newTestServer() 25 | s.indexHandler(w, req) 26 | 27 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 28 | assertStringEquals(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 29 | 30 | assertStringContains(t, w.Body.String(), "The Favicon Finder") 31 | } 32 | 33 | func TestGetIcons(t *testing.T) { 34 | req, err := http.NewRequest("GET", "/icons?url=apple.com", nil) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | 39 | w := httptest.NewRecorder() 40 | s := newTestServer() 41 | s.iconsHandler(w, req) 42 | 43 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 44 | assertStringEquals(t, "max-age=2592000", w.Header().Get("Cache-Control")) 45 | assertStringEquals(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 46 | 47 | assertStringContains(t, w.Body.String(), "Icons on apple.com") 48 | 49 | assertStringContains(t, w.Body.String(), "") 51 | assertStringContains(t, w.Body.String(), "64x64") 52 | } 53 | 54 | func TestGetIcon(t *testing.T) { 55 | req, err := http.NewRequest("GET", "/icon?url=apple.com&size=120", nil) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | w := httptest.NewRecorder() 61 | s := newTestServer() 62 | s.iconHandler(w, req) 63 | 64 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 65 | assertStringEquals(t, "max-age=2592000", w.Header().Get("Cache-Control")) 66 | assertStringEquals(t, "https://www.apple.com/apple-touch-icon.png", w.Header().Get("Location")) 67 | } 68 | 69 | func TestGetIconWithDownloadMode(t *testing.T) { 70 | os.Setenv("SERVER_MODE", "download") 71 | req, err := http.NewRequest("GET", "/icon?url=apple.com&size=120", nil) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | w := httptest.NewRecorder() 77 | s := newTestServer() 78 | s.iconHandler(w, req) 79 | 80 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 81 | assertStringEquals(t, "max-age=2592000", w.Header().Get("Cache-Control")) 82 | assertStringEquals(t, "image/png", w.Header().Get("Content-Type")) 83 | assertStringEquals(t, "", w.Header().Get("Location")) 84 | 85 | // Make sure we return some data 86 | assertIntegerInInterval(t, 2000, 10000, len(w.Body.String())) 87 | 88 | os.Setenv("SERVER_MODE", "") 89 | } 90 | 91 | func TestGetIconWithFallBackURL(t *testing.T) { 92 | req, err := http.NewRequest("GET", "/icon?url=apple.com&size=400&fallback_icon_url=http%3A%2F%2Fexample.com", nil) 93 | if err != nil { 94 | log.Fatal(err) 95 | } 96 | 97 | w := httptest.NewRecorder() 98 | s := newTestServer() 99 | s.iconHandler(w, req) 100 | 101 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 102 | assertStringEquals(t, "max-age=2592000", w.Header().Get("Cache-Control")) 103 | assertStringEquals(t, "http://example.com", w.Header().Get("Location")) 104 | } 105 | 106 | func TestGetIconWith404Page(t *testing.T) { 107 | req, err := http.NewRequest("GET", "/icons?size=32&url=httpbin.org/status/404", nil) 108 | if err != nil { 109 | log.Fatal(err) 110 | } 111 | 112 | w := httptest.NewRecorder() 113 | s := newTestServer() 114 | s.iconHandler(w, req) 115 | 116 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 117 | assertStringEquals(t, "/lettericons/H-32.png", w.Header().Get("Location")) 118 | } 119 | 120 | func TestGet404IconWithFallbackColor(t *testing.T) { 121 | req, err := http.NewRequest("GET", "/icons?size=32&url=httpbin.org/status/404&fallback_icon_color=123456", nil) 122 | if err != nil { 123 | log.Fatal(err) 124 | } 125 | 126 | w := httptest.NewRecorder() 127 | s := newTestServer() 128 | s.iconHandler(w, req) 129 | 130 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 131 | assertStringEquals(t, "/lettericons/H-32-123456.png", w.Header().Get("Location")) 132 | } 133 | 134 | func TestGet404IconWithInvalidFallbackColor(t *testing.T) { 135 | req, err := http.NewRequest("GET", "/icons?size=32&url=httpbin.org/status/404&fallback_icon_color=zz", nil) 136 | if err != nil { 137 | log.Fatal(err) 138 | } 139 | 140 | w := httptest.NewRecorder() 141 | s := newTestServer() 142 | s.iconHandler(w, req) 143 | 144 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 145 | assertStringEquals(t, "/lettericons/H-32.png", w.Header().Get("Location")) 146 | } 147 | 148 | func TestGetIconWithSVG(t *testing.T) { 149 | req, err := http.NewRequest("GET", "/icons?size=32&url=httpbin.org/status/404&formats=svg", nil) 150 | if err != nil { 151 | log.Fatal(err) 152 | } 153 | 154 | w := httptest.NewRecorder() 155 | s := newTestServer() 156 | s.iconHandler(w, req) 157 | 158 | assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code)) 159 | assertStringEquals(t, "/lettericons/H.svg", w.Header().Get("Location")) 160 | } 161 | 162 | func TestGetAllIcons(t *testing.T) { 163 | req, err := http.NewRequest("GET", "/allicons.json?url=apple.com", nil) 164 | if err != nil { 165 | log.Fatal(err) 166 | } 167 | 168 | w := httptest.NewRecorder() 169 | s := newTestServer() 170 | s.alliconsHandler(w, req) 171 | 172 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 173 | assertStringEquals(t, "application/json", w.Header().Get("Content-Type")) 174 | assertStringEquals(t, "max-age=2592000", w.Header().Get("Cache-Control")) 175 | 176 | assertStringContains(t, w.Body.String(), `"url":"https://www.apple.com/favicon.ico"`) 177 | assertStringContains(t, w.Body.String(), `"width":64`) 178 | assertStringContains(t, w.Body.String(), `"height":64`) 179 | 180 | // Make sure we don't return inlined image data 181 | assertDoesNotExceed(t, len(w.Body.String()), 2000) 182 | } 183 | 184 | func TestGetPopular(t *testing.T) { 185 | req, err := http.NewRequest("GET", "/popular", nil) 186 | if err != nil { 187 | t.Fatal(err) 188 | } 189 | 190 | w := httptest.NewRecorder() 191 | s := newTestServer() 192 | s.popularHandler(w, req) 193 | 194 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 195 | assertStringEquals(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) 196 | 197 | assertStringContains(t, w.Body.String(), `Icon Examples`) 198 | assertStringContains(t, w.Body.String(), `github.com`) 199 | } 200 | 201 | func TestGetLetterIconPNG(t *testing.T) { 202 | req, err := http.NewRequest("GET", "/lettericons/M-144-EFC25D.png", nil) 203 | if err != nil { 204 | t.Fatal(err) 205 | } 206 | 207 | w := httptest.NewRecorder() 208 | s := newTestServer() 209 | s.lettericonHandler(w, req) 210 | 211 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 212 | assertStringEquals(t, "image/png", w.Header().Get("Content-Type")) 213 | assertStringEquals(t, "max-age=31536000", w.Header().Get("Cache-Control")) 214 | assertIntegerInInterval(t, 1500, 1800, w.Body.Len()) 215 | } 216 | 217 | func TestGetLetterIconSVG(t *testing.T) { 218 | req, err := http.NewRequest("GET", "/lettericons/M-EFC25D.svg", nil) 219 | if err != nil { 220 | t.Fatal(err) 221 | } 222 | 223 | w := httptest.NewRecorder() 224 | s := newTestServer() 225 | s.lettericonHandler(w, req) 226 | 227 | assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code)) 228 | assertStringEquals(t, "image/svg+xml", w.Header().Get("Content-Type")) 229 | assertStringEquals(t, "max-age=31536000", w.Header().Get("Cache-Control")) 230 | assertStringContains(t, w.Body.String(), ` upper { 277 | fail(t, fmt.Sprintf("Expected %d to be in interval [%d,%d]", actual, lower, upper)) 278 | } 279 | } 280 | 281 | func assertDoesNotExceed(t *testing.T, actual int, maximum int) { 282 | if actual >= maximum { 283 | fail(t, fmt.Sprintf("Expected '%d' to be < '%d'", actual, maximum)) 284 | } 285 | } 286 | 287 | func fail(t *testing.T, failureMessage string) { 288 | t.Errorf("\t%s\n"+ 289 | "\r\t", 290 | failureMessage) 291 | } 292 | 293 | func newTestServer() *server { 294 | return &server{ 295 | maxIconSize: 500, 296 | cacheDuration: 720 * time.Hour, 297 | besticon: besticon.New(besticon.WithLogger(besticon.NewDefaultLogger(io.Discard))), 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /besticon/besticon.go: -------------------------------------------------------------------------------- 1 | // Package besticon includes functions 2 | // finding icons for a given web site. 3 | package besticon 4 | 5 | import ( 6 | "bytes" 7 | "crypto/sha1" 8 | "errors" 9 | "fmt" 10 | "image" 11 | "image/color" 12 | "io" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "strings" 17 | 18 | "github.com/golang/groupcache" 19 | 20 | // Load supported image formats. 21 | _ "image/gif" 22 | _ "image/jpeg" 23 | _ "image/png" 24 | 25 | _ "github.com/mat/besticon/v3/ico" 26 | 27 | "github.com/mat/besticon/v3/colorfinder" 28 | 29 | "golang.org/x/net/html/charset" 30 | ) 31 | 32 | // Besticon is the main interface to the besticon package. 33 | type Besticon struct { 34 | httpClient *http.Client 35 | iconCache *groupcache.Group 36 | logger Logger 37 | 38 | defaultFormats []string 39 | discardImageBytes bool 40 | maxResponseBodySize int64 41 | } 42 | 43 | // New returns a new Besticon instance. 44 | func New(opts ...Option) *Besticon { 45 | b := &Besticon{} 46 | 47 | for _, opt := range opts { 48 | opt.applyOption(b) 49 | } 50 | 51 | if len(b.defaultFormats) == 0 { 52 | b.defaultFormats = []string{"gif", "ico", "jpg", "png"} 53 | } 54 | 55 | if b.maxResponseBodySize == 0 { 56 | b.maxResponseBodySize = 10485760 // 10MB 57 | } 58 | 59 | if b.httpClient == nil { 60 | b.httpClient = NewDefaultHTTPClient() 61 | } 62 | 63 | if b.logger == nil { 64 | b.logger = NewDefaultLogger(os.Stdout) 65 | } 66 | 67 | return b 68 | } 69 | 70 | // Icon holds icon information. 71 | type Icon struct { 72 | URL string `json:"url"` 73 | Width int `json:"width"` 74 | Height int `json:"height"` 75 | Format string `json:"format"` 76 | Bytes int `json:"bytes"` 77 | Error error `json:"error"` 78 | Sha1sum string `json:"sha1sum"` 79 | ImageData []byte `json:",omitempty"` 80 | } 81 | 82 | type IconFinder struct { 83 | b *Besticon 84 | 85 | FormatsAllowed []string 86 | HostOnlyDomains []string 87 | icons []Icon 88 | } 89 | 90 | func (b *Besticon) NewIconFinder() *IconFinder { 91 | return &IconFinder{ 92 | b: b, 93 | } 94 | } 95 | 96 | func (f *IconFinder) FetchIcons(url string) ([]Icon, error) { 97 | url = strings.TrimSpace(url) 98 | if !strings.HasPrefix(url, "http:") && !strings.HasPrefix(url, "https:") { 99 | url = "http://" + url 100 | } 101 | 102 | url = f.stripIfNecessary(url) 103 | 104 | var err error 105 | 106 | if f.b.CacheEnabled() { 107 | f.icons, err = f.b.resultFromCache(url) 108 | } else { 109 | f.icons, err = f.b.fetchIcons(url) 110 | } 111 | 112 | return f.Icons(), err 113 | } 114 | 115 | // stripIfNecessary removes everything from URL but the Scheme and Host 116 | // part if URL.Host is found in HostOnlyDomains. 117 | // This can be used for very popular domains like youtube.com where throttling is 118 | // an issue. 119 | func (f *IconFinder) stripIfNecessary(URL string) string { 120 | u, e := url.Parse(URL) 121 | if e != nil { 122 | return URL 123 | } 124 | 125 | for _, h := range f.HostOnlyDomains { 126 | if h == u.Host || h == "*" { 127 | domainOnlyURL := url.URL{Scheme: u.Scheme, Host: u.Host} 128 | return domainOnlyURL.String() 129 | } 130 | } 131 | 132 | return URL 133 | } 134 | 135 | func (f *IconFinder) IconInSizeRange(r SizeRange) *Icon { 136 | icons := f.Icons() 137 | 138 | // 1. SVG always wins 139 | for _, ico := range icons { 140 | if ico.Format == "svg" { 141 | return &ico 142 | } 143 | } 144 | 145 | // 2. Try to return smallest in range perfect..max 146 | sortIcons(icons, false) 147 | for _, ico := range icons { 148 | if (ico.Width >= r.Perfect && ico.Height >= r.Perfect) && (ico.Width <= r.Max && ico.Height <= r.Max) { 149 | return &ico 150 | } 151 | } 152 | 153 | // 3. Try to return biggest in range perfect..min 154 | sortIcons(icons, true) 155 | for _, ico := range icons { 156 | if (ico.Width >= r.Min && ico.Height >= r.Min) && (ico.Width <= r.Perfect && ico.Height <= r.Perfect) { 157 | return &ico 158 | } 159 | } 160 | 161 | return nil 162 | } 163 | 164 | func (f *IconFinder) MainColorForIcons() *color.RGBA { 165 | return MainColorForIcons(f.icons) 166 | } 167 | 168 | func (f *IconFinder) Icons() []Icon { 169 | return f.b.discardUnwantedFormats(f.icons, f.FormatsAllowed) 170 | } 171 | 172 | func (ico *Icon) Image() (*image.Image, error) { 173 | img, _, err := image.Decode(bytes.NewReader(ico.ImageData)) 174 | return &img, err 175 | } 176 | 177 | func (b *Besticon) discardUnwantedFormats(icons []Icon, wantedFormats []string) []Icon { 178 | formats := b.defaultFormats 179 | if len(wantedFormats) > 0 { 180 | formats = wantedFormats 181 | } 182 | 183 | return filterIcons(icons, func(ico Icon) bool { 184 | return includesString(formats, ico.Format) 185 | }) 186 | } 187 | 188 | type iconPredicate func(Icon) bool 189 | 190 | func filterIcons(icons []Icon, pred iconPredicate) []Icon { 191 | var result []Icon 192 | for _, ico := range icons { 193 | if pred(ico) { 194 | result = append(result, ico) 195 | } 196 | } 197 | return result 198 | } 199 | 200 | func includesString(arr []string, str string) bool { 201 | for _, e := range arr { 202 | if e == str { 203 | return true 204 | } 205 | } 206 | return false 207 | } 208 | 209 | func (b *Besticon) fetchIcons(siteURL string) ([]Icon, error) { 210 | var links []string 211 | 212 | html, urlAfterRedirect, e := b.fetchHTML(siteURL) 213 | if e == nil { 214 | // Search HTML for icons 215 | links, e = findIconLinks(urlAfterRedirect, html) 216 | if e != nil { 217 | return nil, e 218 | } 219 | } else { 220 | // Unable to fetch the response or got a bad HTTP status code. Try default 221 | // icon paths. https://github.com/mat/besticon/discussions/47 222 | links, e = defaultIconURLs(siteURL) 223 | if e != nil { 224 | return nil, e 225 | } 226 | } 227 | 228 | icons := b.fetchAllIcons(links) 229 | icons = rejectBrokenIcons(icons) 230 | sortIcons(icons, true) 231 | 232 | return icons, nil 233 | } 234 | 235 | func (b *Besticon) fetchHTML(url string) ([]byte, *url.URL, error) { 236 | r, e := b.Get(url) 237 | if e != nil { 238 | return nil, nil, e 239 | } 240 | 241 | if !(r.StatusCode >= 200 && r.StatusCode < 300) { 242 | return nil, nil, errors.New("besticon: not found") 243 | } 244 | 245 | body, e := b.GetBodyBytes(r) 246 | if e != nil { 247 | return nil, nil, e 248 | } 249 | if len(body) == 0 { 250 | return nil, nil, errors.New("besticon: empty response") 251 | } 252 | 253 | reader := bytes.NewReader(body) 254 | contentType := r.Header.Get("Content-Type") 255 | utf8reader, e := charset.NewReader(reader, contentType) 256 | if e != nil { 257 | return nil, nil, e 258 | } 259 | utf8bytes, e := io.ReadAll(utf8reader) 260 | if e != nil { 261 | return nil, nil, e 262 | } 263 | 264 | return utf8bytes, r.Request.URL, nil 265 | } 266 | 267 | func MainColorForIcons(icons []Icon) *color.RGBA { 268 | if len(icons) == 0 { 269 | return nil 270 | } 271 | 272 | var icon *Icon 273 | // Prefer gif, jpg, png 274 | for _, ico := range icons { 275 | if ico.Format == "gif" || ico.Format == "jpg" || ico.Format == "png" { 276 | icon = &ico 277 | break 278 | } 279 | } 280 | // Try .ico else 281 | if icon == nil { 282 | for _, ico := range icons { 283 | if ico.Format == "ico" { 284 | icon = &ico 285 | break 286 | } 287 | } 288 | } 289 | 290 | if icon == nil { 291 | return nil 292 | } 293 | 294 | img, err := icon.Image() 295 | if err != nil { 296 | return nil 297 | } 298 | 299 | cf := colorfinder.ColorFinder{} 300 | mainColor, err := cf.FindMainColor(*img) 301 | if err != nil { 302 | return nil 303 | } 304 | 305 | return &mainColor 306 | } 307 | 308 | // Construct default icon URLs. A fallback if we can't fetch the HTML. 309 | func defaultIconURLs(siteURL string) ([]string, error) { 310 | baseURL, e := url.Parse(siteURL) 311 | if e != nil { 312 | return nil, e 313 | } 314 | 315 | var links []string 316 | for _, path := range iconPaths { 317 | absoluteURL, e := absoluteURL(baseURL, path) 318 | if e != nil { 319 | return nil, e 320 | } 321 | links = append(links, absoluteURL) 322 | } 323 | 324 | return links, nil 325 | } 326 | 327 | func (b *Besticon) fetchAllIcons(urls []string) []Icon { 328 | ch := make(chan Icon) 329 | 330 | for _, u := range urls { 331 | go func(u string) { ch <- b.fetchIconDetails(u) }(u) 332 | } 333 | 334 | var icons []Icon 335 | for range urls { 336 | icon := <-ch 337 | icons = append(icons, icon) 338 | } 339 | return icons 340 | } 341 | 342 | func (b *Besticon) fetchIconDetails(url string) Icon { 343 | i := Icon{URL: url} 344 | 345 | response, e := b.Get(url) 346 | if e != nil { 347 | i.Error = e 348 | return i 349 | } 350 | 351 | body, e := b.GetBodyBytes(response) 352 | if e != nil { 353 | i.Error = e 354 | return i 355 | } 356 | 357 | if isSVG(body) { 358 | // Special handling for svg, which golang can't decode with 359 | // image.DecodeConfig. Fill in an absurdly large width/height so SVG always 360 | // wins size contests. 361 | i.Format = "svg" 362 | i.Width = 9999 363 | i.Height = 9999 364 | } else { 365 | cfg, format, e := image.DecodeConfig(bytes.NewReader(body)) 366 | if e != nil { 367 | i.Error = fmt.Errorf("besticon: unknown image format: %s", e) 368 | return i 369 | } 370 | 371 | // jpeg => jpg 372 | if format == "jpeg" { 373 | format = "jpg" 374 | } 375 | 376 | i.Width = cfg.Width 377 | i.Height = cfg.Height 378 | i.Format = format 379 | } 380 | 381 | i.Bytes = len(body) 382 | i.Sha1sum = sha1Sum(body) 383 | if !b.discardImageBytes { 384 | i.ImageData = body 385 | } 386 | 387 | return i 388 | } 389 | 390 | // SVG detector. We can't use image.RegisterFormat, since RegisterFormat is 391 | // limited to a simple magic number check. It's easy to confuse the first few 392 | // bytes of HTML with SVG. 393 | func isSVG(body []byte) bool { 394 | // is it long enough? 395 | if len(body) < 10 { 396 | return false 397 | } 398 | 399 | // does it start with something reasonable? 400 | switch { 401 | case bytes.Equal(body[0:2], []byte(" 300 { 410 | return false 411 | } 412 | 413 | return true 414 | } 415 | 416 | func absoluteURL(baseURL *url.URL, path string) (string, error) { 417 | u, e := url.Parse(path) 418 | if e != nil { 419 | return "", e 420 | } 421 | 422 | u.Scheme = baseURL.Scheme 423 | if u.Scheme == "" { 424 | u.Scheme = "http" 425 | } 426 | 427 | if u.Host == "" { 428 | u.Host = baseURL.Host 429 | } 430 | return baseURL.ResolveReference(u).String(), nil 431 | } 432 | 433 | func urlFromBase(baseURL *url.URL, path string) string { 434 | u := *baseURL 435 | u.Path = path 436 | if u.Scheme == "" { 437 | u.Scheme = "http" 438 | } 439 | 440 | return u.String() 441 | } 442 | 443 | func rejectBrokenIcons(icons []Icon) []Icon { 444 | var result []Icon 445 | for _, img := range icons { 446 | if img.Error == nil && (img.Width > 1 && img.Height > 1) { 447 | result = append(result, img) 448 | } 449 | } 450 | return result 451 | } 452 | 453 | func sha1Sum(b []byte) string { 454 | hash := sha1.New() 455 | hash.Write(b) 456 | bs := hash.Sum(nil) 457 | return fmt.Sprintf("%x", bs) 458 | } 459 | 460 | var BuildDate string // set via ldflags on Make 461 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= 2 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 3 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 4 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 13 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 14 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 15 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 16 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 17 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 20 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 21 | github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= 22 | github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 28 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 30 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 34 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 35 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 36 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 37 | github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= 38 | github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= 39 | github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= 40 | github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 41 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 42 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 43 | github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= 44 | github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 45 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 46 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 47 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 48 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 49 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 50 | go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 51 | go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 52 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 53 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 54 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 55 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 56 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 57 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 58 | golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= 59 | golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= 60 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 61 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 62 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 63 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 64 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 65 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 66 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 67 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 68 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 69 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 70 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 71 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 72 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 73 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 74 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 75 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 76 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 77 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 78 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 79 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 80 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 81 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 82 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 83 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 84 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 85 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 92 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 93 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 94 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 95 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 96 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 97 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 98 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 99 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 100 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 101 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 102 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 103 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 104 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 105 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 106 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 107 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 108 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 109 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 110 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 111 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 112 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 113 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 114 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 115 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 116 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 117 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 118 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 119 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 120 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 121 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 122 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 123 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 124 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 125 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 126 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 127 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 128 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 129 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | -------------------------------------------------------------------------------- /besticon/besticon_test.go: -------------------------------------------------------------------------------- 1 | package besticon 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "net/url" 8 | "os" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/mat/besticon/v3/vcr" 14 | ) 15 | 16 | // 17 | // Big list of tests for IconFinder.FetchIcons. Responses are cached on disk 18 | // using our VCR file format. No network connectivity is required unless you are 19 | // adding more tests. 20 | // 21 | 22 | type testFetch struct { 23 | url string 24 | icons []testFetchIcon 25 | } 26 | 27 | type testFetchIcon struct { 28 | url string 29 | width int 30 | format string 31 | } 32 | 33 | func TestFetchIcons(t *testing.T) { 34 | tests := []testFetch{ 35 | // alibaba - base tag without scheme 36 | {"http://alibaba.com", []testFetchIcon{ 37 | {"http://is.alicdn.com/simg/single/icon/favicon.ico", 16, "ico"}, 38 | {"http://www.alibaba.com/favicon.ico", 16, "ico"}, 39 | }}, 40 | 41 | // aol - has one pixel gifs 42 | {"http://aol.com", []testFetchIcon{ 43 | {"http://www.aol.com/favicon.ico", 32, "ico"}, 44 | {"http://www.aol.com/favicon.ico?v=2", 32, "ico"}, 45 | }}, 46 | 47 | // archive.org - has jpg 48 | {"https://archive.org", []testFetchIcon{ 49 | {"https://archive.org/apple-touch-icon-precomposed.png", 180, "png"}, 50 | {"https://archive.org/apple-touch-icon.png", 180, "png"}, 51 | {"https://archive.org/images/glogo.jpg", 40, "jpg"}, 52 | {"https://archive.org/favicon.ico", 32, "ico"}, 53 | }}, 54 | 55 | // ard - should sort by size 56 | {"http://ard.de", []testFetchIcon{ 57 | {"http://www.ard.de/ARD-144.png", 144, "png"}, 58 | {"http://www.ard.de/apple-touch-icon-precomposed.png", 144, "png"}, 59 | {"http://www.ard.de/apple-touch-icon.png", 144, "png"}, 60 | {"http://www.ard.de/favicon.ico", 144, "ico"}, 61 | }}, 62 | 63 | // aws.amazon.com - this one has a base url 64 | {"http://aws.amazon.com", []testFetchIcon{ 65 | {"http://a0.awsstatic.com/main/images/site/touch-icon-ipad-144-precomposed.png", 144, "png"}, 66 | {"http://a0.awsstatic.com/main/images/site/touch-icon-iphone-114-precomposed.png", 114, "png"}, 67 | {"http://a0.awsstatic.com/main/images/site/favicon.ico", 16, "ico"}, 68 | {"http://aws.amazon.com/favicon.ico", 16, "ico"}, 69 | }}, 70 | 71 | // car2go - relative urls 72 | {"http://car2go.com", []testFetchIcon{ 73 | {"https://www.car2go.com/media/assets/patterns/static/img/favicon.ico", 16, "ico"}, 74 | }}, 75 | 76 | // daringfireball 77 | {"http://daringfireball.net", []testFetchIcon{ 78 | {"http://daringfireball.net/graphics/apple-touch-icon.png", 314, "png"}, 79 | {"http://daringfireball.net/favicon.ico", 32, "ico"}, 80 | {"http://daringfireball.net/graphics/favicon.ico?v=005", 32, "ico"}, 81 | }}, 82 | 83 | // dnevnik - capitalized icon tag 84 | {"http://www.dnevnik.bg", []testFetchIcon{ 85 | {"http://www.dnevnik.bg/images/layout/apple-touch-icon.png", 180, "png"}, 86 | {"http://www.dnevnik.bg/apple-touch-icon.png", 129, "png"}, 87 | {"http://www.dnevnik.bg/favicon.ico", 32, "ico"}, 88 | {"http://www.dnevnik.bg/images/layout/favicon.ico", 16, "ico"}, 89 | }}, 90 | 91 | // eat24 - has base tag 92 | {"http://eat24.com", []testFetchIcon{ 93 | // later - for svg 94 | // {"http://eat24hours.com/static/v4/images/favicon.svg", 9999, "svg" }, 95 | {"http://eat24hours.com/favicon.ico", 16, "ico"}, 96 | }}, 97 | 98 | // example.com - no icons 99 | {"http://example.com", []testFetchIcon{}}, 100 | 101 | // github 102 | {"http://github.com", []testFetchIcon{ 103 | // later - for svg 104 | // {"https://assets-cdn.github.com/pinned-octocat.svg", 9999, "svg" }, 105 | {"https://github.com/apple-touch-icon-144.png", 144, "png"}, 106 | {"https://github.com/apple-touch-icon.png", 120, "png"}, 107 | {"https://github.com/apple-touch-icon-114.png", 114, "png"}, 108 | {"https://github.com/apple-touch-icon-precomposed.png", 57, "png"}, 109 | {"https://github.com/favicon.ico", 32, "ico"}, 110 | }}, 111 | 112 | // kicktipp.de 113 | {"http://kicktipp.de", []testFetchIcon{ 114 | {"https://www.kicktipp.de/assets/apple-touch-icon.0879fba1.png", 180, "png"}, 115 | {"https://www.kicktipp.de/assets/favicon.5368f953.ico", 48, "ico"}, 116 | {"https://www.kicktipp.de/favicon.ico", 48, "ico"}, 117 | {"https://www.kicktipp.de/assets/favicon-32x32.cfcd6069.png", 32, "png"}, 118 | {"https://www.kicktipp.de/assets/favicon-16x16.932c575d.png", 16, "png"}, 119 | }}, 120 | 121 | // netflix - has cookie redirects 122 | {"http://netflix.com", []testFetchIcon{ 123 | {"https://assets.nflxext.com/us/ffe/siteui/common/icons/nficon2016.png", 64, "png"}, 124 | {"https://assets.nflxext.com/us/ffe/siteui/common/icons/nficon2016.ico", 64, "ico"}, 125 | {"https://www.netflix.com/favicon.ico", 64, "ico"}, 126 | }}, 127 | 128 | // storage.googleapis - has bad http response 129 | {"https://storage.googleapis.com", []testFetchIcon{ 130 | {"https://storage.googleapis.com/favicon.ico", 32, "png"}, 131 | }}, 132 | 133 | // xing.com:443 - https with port 134 | {"https://xing.com:443", []testFetchIcon{ 135 | {"https://www.xing.com/assets/frontend_minified/img/shared/xing_icon_apple.png", 129, "png"}, 136 | {"https://www.xing.com/assets/frontend_minified/img/shared/xing_r1.ico", 16, "ico"}, 137 | {"https://www.xing.com/favicon.ico", 16, "ico"}, 138 | }}, 139 | 140 | // https://printables.com - "), 307 | mustReadFile("testdata/favicon.ico"), 308 | } 309 | for _, data := range invalid { 310 | assertEquals(t, isSVG(data), false) 311 | } 312 | 313 | valid := [][]byte{ 314 | []byte(""), 315 | []byte(""), 316 | []byte(""), 317 | mustReadFile("testdata/svg.svg"), 318 | } 319 | for _, data := range valid { 320 | assertEquals(t, isSVG(data), true) 321 | } 322 | } 323 | 324 | // 325 | // helpers 326 | // 327 | 328 | const testdataDir = "testdata/" 329 | 330 | func fetchIconsWithVCR(s string) ([]Icon, *IconFinder, error) { 331 | URL, _ := url.Parse(s) 332 | host := strings.ReplaceAll(URL.Host, ":", "_") 333 | path := fmt.Sprintf("%s%s.vcr", testdataDir, host) 334 | 335 | // build client 336 | client, f, err := vcr.Client(path) 337 | if err != nil { 338 | return nil, nil, err 339 | } 340 | defer f.Close() 341 | 342 | client.Jar = mustInitCookieJar() 343 | 344 | b := New(WithHTTPClient(client), WithDiscardImageBytes(true)) 345 | 346 | // fetch 347 | finder := b.NewIconFinder() 348 | finder.HostOnlyDomains = []string{"youtube.com"} 349 | icons, err := finder.FetchIcons(s) 350 | return icons, finder, err 351 | } 352 | 353 | func getImageWidthForFile(filename string) int { 354 | f, err := os.Open(filename) 355 | check(err) 356 | defer f.Close() 357 | 358 | icfg, _, err := image.DecodeConfig(f) 359 | check(err) 360 | return icfg.Width 361 | } 362 | 363 | func mustReadFile(filename string) []byte { 364 | bytes, e := os.ReadFile(filename) 365 | check(e) 366 | return bytes 367 | } 368 | 369 | func check(e error) { 370 | if e != nil { 371 | panic(e) 372 | } 373 | } 374 | 375 | func assertEquals(t *testing.T, expected, actual interface{}) { 376 | if !reflect.DeepEqual(expected, actual) { 377 | fail(t, fmt.Sprintf("Not equal: %#v (expected)\n"+ 378 | " != %#v (actual)", expected, actual)) 379 | } 380 | } 381 | 382 | func fail(t *testing.T, failureMessage string) { 383 | t.Errorf("\t%s\n"+ 384 | "\r\t", 385 | failureMessage) 386 | } 387 | -------------------------------------------------------------------------------- /Readme.markdown: -------------------------------------------------------------------------------- 1 | # favicon-service (besticon) 2 | 3 | This is a favicon service: 4 | 5 | - Supports `favicon.ico` and `apple-touch-icon.png` 6 | - Simple URL API 7 | - Fallback icon generation 8 | - Docker image & single binary download for [easy hosting](#hosting) 9 | 10 | Try out the demo at or find out how to [deploy your own version](#hosting) right now. 11 | 12 | [![Build Status](https://github.com/mat/besticon/actions/workflows/go.yml/badge.svg)](https://github.com/mat/besticon/actions/workflows/go.yml) 13 | [![Go Report Card](https://goreportcard.com/badge/github.com/mat/besticon)](https://goreportcard.com/report/github.com/mat/besticon) 14 | [![Donate at PayPal](https://img.shields.io/badge/paypal-donate-orange.svg?style=flat)](https://paypal.me/matthiasluedtke 'Donate once-off to this project using Paypal') 15 | 16 | ## What's this? 17 | 18 | Websites used to have a `favicon.ico`, or not. With the introduction of the `apple-touch-icon.png` finding “the icon” for a website became more complicated. This service finds and — if necessary — generates icons for web sites. 19 | 20 | ## API 21 | 22 | ### GET /icon 23 | 24 | This endpoint always returns an icon image for the given site — it redirects to an official icon if possible or creates and returns a fallback image if needed. 25 | 26 | | Parameter | Example | Description | Default | 27 | | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | 28 | | url | http://yelp.com | | required | 29 | | size | 32..50..100 | Desired size range (min..perfect..max) If no image of size perfect..max nor perfect..min can be found a fallback icon will be generated. | required | 30 | | formats | png,ico | Comma-separated list of accepted image formats: png, ico, gif, jpg | `gif,ico,jpg,png,svg` | 31 | | fallback_icon_url | _HTTP image URL_ | If provided, a redirect to this image will be returned in case no suitable icon could be found. This overrides the default fallback image behaviour. | | 32 | | fallback_icon_color | ff0000 | If provided, letter icons will be colored with the hex value provided, rather than be grey, when no color can be found for any icon. | | 33 | 34 | #### Examples 35 | 36 | | Input URL | Icon | 37 | | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | 38 | | | ![Icon for yelp.com](https://besticon-demo.herokuapp.com/icon?url=yelp.com&size=32..50..120) | 39 | | | ![Icon for yelp.com](https://besticon-demo.herokuapp.com/icon?url=yelp.com&size=64..64..120) | 40 | | | size missing | 41 | | | ![Icon for non-existent page](https://besticon-demo.herokuapp.com/icon?url=httpbin.org/status/404&size=32..64..120) | 42 | | | ![Icon for non-existent page](https://besticon-demo.herokuapp.com/icon?url=httpbin.org/status/404&size=32..64..120&fallback_icon_color=ff0000) | 43 | | | ![Icon with cyrillic letter ф](https://besticon-demo.herokuapp.com/icon?url=фминобрнауки.рф&size=32..64..120) | 44 | 45 | ### GET /allicons.json 46 | 47 | This endpoint returns all icons for a given site. 48 | 49 | | Parameter | Example | Description | Default | 50 | | --------- | --------------- | ------------------------------------------------------------------ | ----------------- | 51 | | url | http://yelp.com | | required | 52 | | formats | png,ico | Comma-separated list of accepted image formats: png, ico, gif, jpg | `png,ico,gif,jpg` | 53 | 54 | #### Examples 55 | 56 | - 57 | - 58 | 59 | ## Bugs & limitations 60 | 61 | I tried hard to make this useful but please note there are some known limitations: 62 | 63 | - Poor i18n support for letter icons ([#13](https://github.com/mat/besticon/issues/13)) 64 | 65 | Feel free to file other bugs - and offer your help - at . 66 | 67 | ## Hosting 68 | 69 | Simple options to host this service are, for example: 70 | 71 | - Render: 72 | - Heroku: 73 | - Google Cloud Run: 74 | 75 | ## Docker 76 | 77 | A docker image is available at , generated from the [Dockerfile](https://github.com/mat/besticon/blob/master/Dockerfile) in this repo. I try to keep it updated for every release. 78 | 79 | Note that this docker image is not used to run and therefore not well tested. 80 | 81 | ## Monitoring 82 | 83 | [Prometheus](https://prometheus.io) metrics are exposed under [/metrics](https://besticon-demo.herokuapp.com/metrics). A Grafana dashboard config based on these metrics can be found in [grafana-dashboard.json](https://github.com/mat/besticon/blob/master/grafana-dashboard.json). 84 | 85 | ## Server Executable 86 | 87 | ### Download binaries 88 | 89 | Binaries for some operating systems can be downloaded from 90 | 91 | ### Build your own 92 | 93 | If you have Go installed on your system you can use `go get` to fetch the source code and build the server: 94 | 95 | $ go get -u github.com/mat/besticon/v3/... 96 | 97 | If you want to build executables for a different target operating system you can add the `GOOS` and `GOARCH` environment variables: 98 | 99 | $ GOOS=linux GOARCH=amd64 go get -u github.com/mat/besticon/v3/... 100 | 101 | ### Running 102 | 103 | To start the server on default port 8080 just do 104 | 105 | $ iconserver 106 | 107 | To use a different port use 108 | 109 | $ PORT=80 iconserver 110 | 111 | To listen on a different address (say localhost) use 112 | 113 | $ ADDRESS=127.0.0.1 iconserver 114 | 115 | To enable CORS headers you need to set `CORS_ENABLED=true`. Optionally, you can set [additional environment variables](https://github.com/mat/besticon#configuration) which will be passed as options to the [rs/cors middleware](https://github.com/rs/cors#parameters). 116 | 117 | $ CORS_ENABLED=true iconserver 118 | 119 | Now when you open you should see something like 120 | ![Screenshot of The Favicon Finder](https://github.com/mat/besticon/raw/master/the-icon-finder.png) 121 | 122 | ## Configuration 123 | 124 | There is not a lot to configure, but these environment variables exist 125 | 126 | | Variable | Description | Default Value | 127 | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | 128 | | `ADDRESS` | HTTP server listen address | 0.0.0.0 | 129 | | `CACHE_SIZE_MB` | Size for the [groupcache](http://github.com/golang/groupcache), set to 0 to disable | 32 | 130 | | `CORS_ENABLED` | Enables the [rs/cors](https://github.com/rs/cors) middleware | false | 131 | | `CORS_ALLOWED_HEADERS` | Comma-separated, passed to middleware | | 132 | | `CORS_ALLOWED_METHODS` | Comma-separated, passed to middleware | | 133 | | `CORS_ALLOWED_ORIGINS` | Comma-separated, passed to middleware | | 134 | | `CORS_ALLOW_CREDENTIALS` | Boolean, passed to middleware | | 135 | | `CORS_DEBUG` | Boolean, passed to middleware | | 136 | | `DISABLE_BROWSE_PAGES` | Boolean, if true, the server will not serve any of the HTML pages | false | 137 | | `HOST_ONLY_DOMAINS` | | \* | 138 | | `HTTP_CLIENT_TIMEOUT` | Timeout used for HTTP requests. Supports units like ms, s, m. | 5s | 139 | | `HTTP_MAX_AGE_DURATION` | Cache duration for all dynamically generated HTTP responses. Supports units like ms, s, m. | 720h _(30 days)_ | 140 | | `HTTP_USER_AGENT` | User-Agent used for HTTP requests | _iPhone user agent string_ | 141 | | `METRICS_PATH` | Path at which the Prometheus metrics are served. Set to `disable` to disable Prometheus metrics | `/metrics` | 142 | | `POPULAR_SITES` | Comma-separated list of domains used on /popular page | some random web sites | 143 | | `PORT` | HTTP server port | 8080 | 144 | | `SERVER_MODE` | Set to `download` to proxy downloads through besticon or `redirect` to let browser to download instead. (example at [#40](https://github.com/mat/besticon/pull/40#issuecomment-528325450)) | `redirect` | 145 | 146 | ## Contributors 147 | 148 | - Erkie - https://github.com/erkie 149 | - mmkal - https://github.com/mmkal 150 | - kspearrin - https://github.com/kspearrin 151 | - karl-ravn - https://github.com/karl-ravn 152 | - korbenclario - https://github.com/korbenclario 153 | 154 | ## License 155 | 156 | MIT License (MIT) 157 | 158 | Copyright (c) 2015-2023 Matthias Lüdtke, Hamburg - 159 | 160 | Permission is hereby granted, free of charge, to any person obtaining a copy 161 | of this software and associated documentation files (the "Software"), to deal 162 | in the Software without restriction, including without limitation the rights 163 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 164 | copies of the Software, and to permit persons to whom the Software is 165 | furnished to do so, subject to the following conditions: 166 | 167 | The above copyright notice and this permission notice shall be included in all 168 | copies or substantial portions of the Software. 169 | 170 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 171 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 172 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 173 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 174 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 175 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 176 | SOFTWARE. 177 | 178 | ## Donate 179 | 180 | If you find this useful and want to donate... you would make my day :-) 181 | 182 | [![Donate at PayPal](https://img.shields.io/badge/paypal-donate-orange.svg?style=flat)](https://paypal.me/matthiasluedtke 'Donate once-off to this project using Paypal') 183 | -------------------------------------------------------------------------------- /besticon/iconserver/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "runtime" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/mat/besticon/v3/besticon" 19 | "github.com/mat/besticon/v3/besticon/iconserver/assets" 20 | "github.com/mat/besticon/v3/lettericon" 21 | 22 | "github.com/prometheus/client_golang/prometheus/promhttp" 23 | "github.com/rs/cors" 24 | ) 25 | 26 | type server struct { 27 | maxIconSize int 28 | cacheDuration time.Duration 29 | hostOnlyDomains []string 30 | 31 | besticon *besticon.Besticon 32 | } 33 | 34 | func (s *server) indexHandler(w http.ResponseWriter, r *http.Request) { 35 | if r.URL.Path == "" || r.URL.Path == "/" { 36 | renderHTMLTemplate(w, 200, indexHTML, nil) 37 | } else { 38 | renderHTMLTemplate(w, 404, notFoundHTML, nil) 39 | } 40 | } 41 | 42 | func (s *server) iconsHandler(w http.ResponseWriter, r *http.Request) { 43 | url := r.FormValue(urlParam) 44 | if len(url) == 0 { 45 | http.Redirect(w, r, "/", 302) 46 | return 47 | } 48 | 49 | finder := s.newIconFinder() 50 | 51 | formats := r.FormValue("formats") 52 | if formats != "" { 53 | finder.FormatsAllowed = strings.Split(r.FormValue("formats"), ",") 54 | } 55 | 56 | icons, e := finder.FetchIcons(url) 57 | switch { 58 | case e != nil: 59 | renderHTMLTemplate(w, 404, iconsHTML, pageInfo{URL: url, Error: e}) 60 | case len(icons) == 0: 61 | errNoIcons := errors.New("this poor site has no icons at all :-(") 62 | renderHTMLTemplate(w, 404, iconsHTML, pageInfo{URL: url, Error: errNoIcons}) 63 | default: 64 | addCacheControl(w, s.cacheDuration) 65 | renderHTMLTemplate(w, 200, iconsHTML, pageInfo{Icons: icons, URL: url}) 66 | } 67 | } 68 | 69 | func (s *server) iconHandler(w http.ResponseWriter, r *http.Request) { 70 | url := r.FormValue("url") 71 | if len(url) == 0 { 72 | writeAPIError(w, 400, errors.New("need url parameter")) 73 | return 74 | } 75 | 76 | sizeRange, err := besticon.ParseSizeRange(r.FormValue("size"), s.maxIconSize) 77 | if err != nil { 78 | writeAPIError(w, 400, errors.New("bad size parameter")) 79 | return 80 | } 81 | 82 | finder := s.newIconFinder() 83 | formats := r.FormValue("formats") 84 | if formats != "" { 85 | finder.FormatsAllowed = strings.Split(r.FormValue("formats"), ",") 86 | } 87 | 88 | finder.FetchIcons(url) 89 | 90 | icon := finder.IconInSizeRange(*sizeRange) 91 | if icon != nil { 92 | s.returnIcon(w, r, icon.URL) 93 | return 94 | } 95 | 96 | fallbackIconURL := r.FormValue("fallback_icon_url") 97 | if fallbackIconURL != "" { 98 | s.returnIcon(w, r, fallbackIconURL) 99 | return 100 | } 101 | 102 | iconColor := finder.MainColorForIcons() 103 | letter := lettericon.MainLetterFromURL(url) 104 | 105 | fallbackColorHex := r.FormValue("fallback_icon_color") 106 | if iconColor == nil && fallbackColorHex != "" { 107 | color, err := lettericon.ColorFromHex(fallbackColorHex) 108 | if err == nil { 109 | iconColor = color 110 | } 111 | } 112 | 113 | // We support both PNG and SVG fallback. Only return SVG if requested. 114 | format := "png" 115 | if includesString(finder.FormatsAllowed, "svg") { 116 | format = "svg" 117 | } 118 | redirectPath := lettericon.IconPath(letter, fmt.Sprintf("%d", sizeRange.Perfect), iconColor, format) 119 | s.redirectWithCacheControl(w, r, redirectPath) 120 | } 121 | 122 | func (s *server) popularHandler(w http.ResponseWriter, r *http.Request) { 123 | iconSize, err := strconv.Atoi(r.FormValue("iconsize")) 124 | if iconSize > s.maxIconSize || iconSize < 0 || err != nil { 125 | iconSize = 120 126 | } 127 | 128 | pageInfo := struct { 129 | URLs []string 130 | IconSize int 131 | DisplaySize int 132 | }{ 133 | strings.Split(os.Getenv("POPULAR_SITES"), ","), 134 | iconSize, 135 | iconSize / 2, 136 | } 137 | renderHTMLTemplate(w, 200, popularHTML, pageInfo) 138 | } 139 | 140 | const ( 141 | urlParam = "url" 142 | ) 143 | 144 | func (s *server) alliconsHandler(w http.ResponseWriter, r *http.Request) { 145 | url := r.FormValue(urlParam) 146 | if len(url) == 0 { 147 | errMissingURL := errors.New("need url query parameter") 148 | writeAPIError(w, 400, errMissingURL) 149 | return 150 | } 151 | 152 | finder := s.newIconFinder() 153 | formats := r.FormValue("formats") 154 | if formats != "" { 155 | finder.FormatsAllowed = strings.Split(r.FormValue("formats"), ",") 156 | } 157 | 158 | icons, e := finder.FetchIcons(url) 159 | if e != nil { 160 | writeAPIError(w, 404, e) 161 | return 162 | } 163 | 164 | addCacheControl(w, s.cacheDuration) 165 | writeAPIIcons(w, url, icons) 166 | } 167 | 168 | func (s *server) lettericonHandler(w http.ResponseWriter, r *http.Request) { 169 | charParam, col, size, format := lettericon.ParseIconPath(r.URL.Path) 170 | if charParam == "" || col == nil || size <= 0 || format == "" { 171 | writeAPIError(w, 400, errors.New("wrong format for lettericons/ path, must look like lettericons/M-144-EFC25D.png or M-EFC25D.svg")) 172 | return 173 | } 174 | 175 | addCacheControl(w, oneYear) 176 | 177 | if format == "svg" { 178 | w.Header().Add(contentType, imageSVG) 179 | lettericon.RenderSVG(charParam, col, w) 180 | } else { 181 | w.Header().Add(contentType, imagePNG) 182 | lettericon.RenderPNG(charParam, col, size, w) 183 | } 184 | } 185 | 186 | func writeAPIError(w http.ResponseWriter, httpStatus int, e error) { 187 | data := struct { 188 | Error string `json:"error"` 189 | }{ 190 | e.Error(), 191 | } 192 | renderJSONResponse(w, httpStatus, data) 193 | } 194 | 195 | func writeAPIIcons(w http.ResponseWriter, url string, icons []besticon.Icon) { 196 | // Don't return whole image data 197 | newIcons := []besticon.Icon{} 198 | for _, ico := range icons { 199 | newIcon := ico 200 | newIcon.ImageData = nil 201 | newIcons = append(newIcons, newIcon) 202 | } 203 | 204 | data := &struct { 205 | URL string `json:"url"` 206 | Icons []besticon.Icon `json:"icons"` 207 | }{ 208 | url, 209 | newIcons, 210 | } 211 | renderJSONResponse(w, 200, data) 212 | } 213 | 214 | const ( 215 | contentType = "Content-Type" 216 | applicationJSON = "application/json" 217 | imagePNG = "image/png" 218 | imageSVG = "image/svg+xml" 219 | ) 220 | 221 | func renderJSONResponse(w http.ResponseWriter, httpStatus int, data interface{}) { 222 | w.Header().Add(contentType, applicationJSON) 223 | w.WriteHeader(httpStatus) 224 | enc := json.NewEncoder(w) 225 | enc.Encode(data) 226 | } 227 | 228 | type pageInfo struct { 229 | URL string 230 | Icons []besticon.Icon 231 | Error error 232 | } 233 | 234 | func (pi pageInfo) Host() string { 235 | u := pi.URL 236 | url, _ := url.Parse(u) 237 | if url != nil && url.Host != "" { 238 | return url.Host 239 | } 240 | return pi.URL 241 | } 242 | 243 | func (pi pageInfo) Best() string { 244 | if len(pi.Icons) > 0 { 245 | best := pi.Icons[0] 246 | return best.URL 247 | } 248 | return "" 249 | } 250 | 251 | func renderHTMLTemplate(w http.ResponseWriter, httpStatus int, templ *template.Template, data interface{}) { 252 | w.Header().Add(contentType, "text/html; charset=utf-8") 253 | w.WriteHeader(httpStatus) 254 | 255 | err := templ.Execute(w, data) 256 | if err != nil { 257 | err = fmt.Errorf("server: could not generate output: %s", err) 258 | logger.Print(err) 259 | w.Write([]byte(err.Error())) 260 | } 261 | } 262 | 263 | func startServer(port string, address string) { 264 | var opts []besticon.Option 265 | 266 | cacheSize := os.Getenv("CACHE_SIZE_MB") 267 | if cacheSize == "" { 268 | opts = append(opts, besticon.WithCache(32)) 269 | } else { 270 | n, _ := strconv.Atoi(cacheSize) 271 | opts = append(opts, besticon.WithCache(int64(n))) 272 | } 273 | 274 | cacheDuration, err := time.ParseDuration(getenvOrFallback("HTTP_MAX_AGE_DURATION", "720h")) 275 | if err != nil { 276 | panic(err) 277 | } 278 | 279 | maxIconSize, err := strconv.Atoi(getenvOrFallback("MAX_ICON_SIZE", "500")) 280 | if err != nil { 281 | panic(err) 282 | } 283 | 284 | httpClient := besticon.NewDefaultHTTPClient() 285 | httpClient.Transport = besticon.NewDefaultHTTPTransport(getenvOrFallback("HTTP_USER_AGENT", "Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A5297c Safari/602.1")) 286 | 287 | opts = append(opts, besticon.WithHTTPClient(httpClient)) 288 | 289 | s := &server{ 290 | maxIconSize: maxIconSize, 291 | cacheDuration: cacheDuration, 292 | hostOnlyDomains: strings.Split(os.Getenv("HOST_ONLY_DOMAINS"), ","), 293 | 294 | besticon: besticon.New(opts...), 295 | } 296 | 297 | registerHandler("/icon", s.iconHandler) 298 | registerHandler("/allicons.json", s.alliconsHandler) 299 | registerHandler("/lettericons/", s.lettericonHandler) 300 | registerHandler("/up", s.upHandler) 301 | 302 | disableBrowsePages := getTrueFromEnv("DISABLE_BROWSE_PAGES") 303 | 304 | if !disableBrowsePages { 305 | registerHandler("/", s.indexHandler) 306 | registerHandler("/icons", s.iconsHandler) 307 | registerHandler("/popular", s.popularHandler) 308 | 309 | serveAsset("/pure-0.5.0-min.css", "pure-0.5.0-min.css", oneYear) 310 | serveAsset("/grids-responsive-0.5.0-min.css", "grids-responsive-0.5.0-min.css", oneYear) 311 | serveAsset("/main-min.css", "main-min.css", oneYear) 312 | 313 | serveAsset("/icon.svg", "icon.svg", oneYear) 314 | serveAsset("/favicon.ico", "favicon.ico", oneYear) 315 | serveAsset("/apple-touch-icon.png", "apple-touch-icon.png", oneYear) 316 | } 317 | 318 | metricsPath := getenvOrFallback("METRICS_PATH", "/metrics") 319 | 320 | if metricsPath != "disable" { 321 | if !strings.HasPrefix(metricsPath, "/") { 322 | logger.Fatalf("METRICS_PATH must start with a slash") 323 | } 324 | 325 | http.Handle(metricsPath, promhttp.Handler()) 326 | } 327 | 328 | addr := address + ":" + port 329 | logger.Print("Starting server on ", addr, "...") 330 | err = http.ListenAndServe(addr, httpHandler()) 331 | if err != nil { 332 | logger.Fatalf("cannot start server: %s\n", err) 333 | } 334 | } 335 | 336 | func httpHandler() http.Handler { 337 | corsEnabled := getTrueFromEnv("CORS_ENABLED") 338 | if corsEnabled { 339 | logger.Print("Enabling CORS middleware") 340 | return corsHandler(newLoggingMux()) 341 | } else { 342 | return newLoggingMux() 343 | } 344 | } 345 | 346 | func corsHandler(mux http.HandlerFunc) http.Handler { 347 | corsOpts := cors.Options{ 348 | AllowedOrigins: stringSliceFromEnv("CORS_ALLOWED_ORIGINS"), 349 | AllowedMethods: stringSliceFromEnv("CORS_ALLOWED_METHODS"), 350 | AllowedHeaders: stringSliceFromEnv("CORS_ALLOWED_HEADERS"), 351 | AllowCredentials: getTrueFromEnv("CORS_ALLOW_CREDENTIALS"), 352 | Debug: getTrueFromEnv("CORS_DEBUG"), 353 | } 354 | return cors.New(corsOpts).Handler(mux) 355 | } 356 | 357 | const ( 358 | cacheControl = "Cache-Control" 359 | oneYear = 365 * 24 * time.Hour 360 | ) 361 | 362 | func (s *server) returnIcon(w http.ResponseWriter, r *http.Request, iconURL string) { 363 | if os.Getenv("SERVER_MODE") == "download" { 364 | s.downloadAndReturn(w, r, iconURL) 365 | } else { 366 | s.redirectWithCacheControl(w, r, iconURL) 367 | } 368 | } 369 | 370 | func (s *server) downloadAndReturn(w http.ResponseWriter, r *http.Request, iconURL string) { 371 | response, err := s.besticon.Get(iconURL) 372 | if err != nil { 373 | s.redirectWithCacheControl(w, r, iconURL) 374 | return 375 | } 376 | 377 | b, err := s.besticon.GetBodyBytes(response) 378 | if err != nil { 379 | s.redirectWithCacheControl(w, r, iconURL) 380 | return 381 | } 382 | 383 | addCacheControl(w, s.cacheDuration) 384 | w.Write(b) 385 | } 386 | 387 | func (s *server) redirectWithCacheControl(w http.ResponseWriter, r *http.Request, redirectURL string) { 388 | addCacheControl(w, s.cacheDuration) 389 | http.Redirect(w, r, redirectURL, 302) 390 | } 391 | 392 | func addCacheControl(w http.ResponseWriter, maxAge time.Duration) { 393 | w.Header().Add(cacheControl, fmt.Sprintf("max-age=%d", int(maxAge.Seconds()))) 394 | } 395 | 396 | func serveAsset(path string, assetPath string, maxAge time.Duration) { 397 | registerHandler(path, func(w http.ResponseWriter, r *http.Request) { 398 | f, err := assets.Assets.Open(assetPath) 399 | if err != nil { 400 | panic(err) 401 | } 402 | defer f.Close() 403 | 404 | stat, err := f.Stat() 405 | if err != nil { 406 | panic(err) 407 | } 408 | 409 | data, err := io.ReadAll(f) 410 | if err != nil { 411 | panic(err) 412 | } 413 | 414 | addCacheControl(w, maxAge) 415 | 416 | http.ServeContent(w, r, stat.Name(), stat.ModTime(), bytes.NewReader(data)) 417 | }) 418 | } 419 | 420 | func registerHandler(path string, f http.HandlerFunc) { 421 | http.Handle(path, newPrometheusHandler(path, f)) 422 | } 423 | 424 | // /up is a simple health check endpoint (used by kamal deploy) 425 | func (s *server) upHandler(w http.ResponseWriter, r *http.Request) { 426 | w.WriteHeader(http.StatusOK) 427 | w.Write([]byte("OK")) 428 | } 429 | 430 | func main() { 431 | fmt.Printf("iconserver %s (%s) (%s) - https://github.com/mat/besticon\n", besticon.VersionString, besticon.BuildDate, runtime.Version()) 432 | port := os.Getenv("PORT") 433 | if port == "" { 434 | port = "8080" 435 | } 436 | address := os.Getenv("ADDRESS") 437 | if address == "" { 438 | address = "0.0.0.0" 439 | } 440 | startServer(port, address) 441 | } 442 | 443 | func init() { 444 | indexHTML = templateFromAsset("index.html", "index.html") 445 | iconsHTML = templateFromAsset("icons.html", "icons.html") 446 | popularHTML = templateFromAsset("popular.html", "popular.html") 447 | notFoundHTML = templateFromAsset("not_found.html", "not_found.html") 448 | } 449 | 450 | func templateFromAsset(assetPath, templateName string) *template.Template { 451 | data, err := assets.Assets.ReadFile(assetPath) 452 | if err != nil { 453 | panic(err) 454 | } 455 | return template.Must(template.New(templateName).Funcs(funcMap).Parse(string(data))) 456 | } 457 | 458 | var indexHTML *template.Template 459 | var iconsHTML *template.Template 460 | var popularHTML *template.Template 461 | var notFoundHTML *template.Template 462 | 463 | var funcMap = template.FuncMap{ 464 | "ImgWidth": imgWidth, 465 | } 466 | 467 | func imgWidth(i *besticon.Icon) int { 468 | return i.Width / 2.0 469 | } 470 | 471 | func (s *server) newIconFinder() *besticon.IconFinder { 472 | finder := s.besticon.NewIconFinder() 473 | if len(s.hostOnlyDomains) > 0 { 474 | finder.HostOnlyDomains = s.hostOnlyDomains 475 | } 476 | 477 | return finder 478 | } 479 | 480 | func getTrueFromEnv(s string) bool { 481 | return getenvOrFallback(s, "") == "true" 482 | } 483 | 484 | func stringSliceFromEnv(key string) []string { 485 | value := os.Getenv(key) 486 | if value == "" { 487 | return nil 488 | } 489 | return strings.Split(value, ",") 490 | } 491 | 492 | func getenvOrFallback(key string, fallbackValue string) string { 493 | value := os.Getenv(key) 494 | if len(strings.TrimSpace(value)) != 0 { 495 | return value 496 | } 497 | return fallbackValue 498 | } 499 | 500 | func includesString(arr []string, str string) bool { 501 | for _, e := range arr { 502 | if e == str { 503 | return true 504 | } 505 | } 506 | return false 507 | } 508 | --------------------------------------------------------------------------------