arblant
firefox-vpn-client / core / internal / service / flags.go
flags.go143 lines · 4.4 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
package service

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
	"github.com/armanlife/firefox-vpn-client/core/internal/logx"
	"github.com/armanlife/firefox-vpn-client/core/internal/securefile"
)

// Country flags. The web folder carries the flags of Mozilla's locations
// (the ones the program shows); a flag it lacks (a location Mozilla added
// later) is downloaded once from flag-icons, the same set, and kept in the
// data folder. The dashboard and the app both ask GET /flags/xx.svg.

// flagSources are where a missing flag comes from, tried in order: the
// latest flag-icons through jsDelivr's CDN, then GitHub directly.
var flagSources = []string{
	"https://cdn.jsdelivr.net/gh/lipis/flag-icons@main/flags/4x3/%s.svg",
	"https://raw.githubusercontent.com/lipis/flag-icons/main/flags/4x3/%s.svg",
}

var flagCode = regexp.MustCompile(`^[a-z]{2}$`)

const (
	// maxFlagSize bounds a downloaded flag (flag-icons' largest is under
	// 100 KiB).
	maxFlagSize = 100 << 10
	// flagRetryAfter spaces the attempts for a flag that could not be
	// downloaded, so a list full of them does not ask again on every look.
	flagRetryAfter = 10 * time.Minute
	flagTimeout    = 15 * time.Second
	flagsDirName   = "flags"
)

// handleFlag serves a country's flag: built in, saved, or downloaded now.
func (s *dashboardServer) handleFlag(w http.ResponseWriter, r *http.Request) {
	code, ok := strings.CutSuffix(strings.ToLower(r.PathValue("file")), ".svg")
	if !ok || !flagCode.MatchString(code) {
		http.NotFound(w, r)
		return
	}
	data, err := s.d.flag(code)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "image/svg+xml")
	_, _ = w.Write(data)
}

// flag returns the SVG of a country's flag by its two-letter code.
func (d *Daemon) flag(code string) ([]byte, error) {
	if data, err := fs.ReadFile(webFiles, "web/flags/"+code+".svg"); err == nil {
		return data, nil
	}
	saved := filepath.Join(d.paths.Dir, flagsDirName, code+".svg")
	if data, err := os.ReadFile(saved); err == nil {
		return data, nil
	}

	d.flags.mu.Lock()
	defer d.flags.mu.Unlock()
	// Another request may have fetched it while this one waited.
	if data, err := os.ReadFile(saved); err == nil {
		return data, nil
	}
	if failed, ok := d.flags.failed[code]; ok && time.Since(failed) < flagRetryAfter {
		return nil, errors.New("flag download failed recently")
	}
	data, err := d.downloadFlag(code)
	if err != nil {
		if d.flags.failed == nil {
			d.flags.failed = make(map[string]time.Time)
		}
		d.flags.failed[code] = time.Now()
		logx.Debug("downloading a flag failed code=%s err=%s", code, logx.Err(err))
		return nil, err
	}
	if err := securefile.EnsurePrivateDir(filepath.Dir(saved)); err == nil {
		if err := securefile.WritePrivate(saved, data); err != nil {
			logx.Debug("saving a flag failed code=%s err=%s", code, logx.Err(err))
		}
	}
	logx.Info("downloaded the flag of %s", strings.ToUpper(code))
	return data, nil
}

// downloadFlag fetches a flag through Mozilla's proxy when it is awake (the
// flag hosts may be filtered), else the way the program's own requests go.
func (d *Daemon) downloadFlag(code string) ([]byte, error) {
	var clients []*http.Client
	if engine := d.currentEngine(); engine != nil {
		if controller := engine.currentController(); controller != nil {
			clients = append(clients, controller.HTTPClient(flagTimeout))
		}
	}
	clients = append(clients, &http.Client{Transport: controlplane.Transport(), Timeout: flagTimeout})
	var errs []error
	for _, client := range clients {
		for _, source := range flagSources {
			data, err := fetchFlag(client, fmt.Sprintf(source, code))
			if err == nil {
				return data, nil
			}
			errs = append(errs, err)
		}
	}
	return nil, errors.Join(errs...)
}

func fetchFlag(client *http.Client, url string) ([]byte, error) {
	resp, err := client.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
	}
	data, err := io.ReadAll(io.LimitReader(resp.Body, maxFlagSize+1))
	if err != nil {
		return nil, err
	}
	if len(data) > maxFlagSize {
		return nil, fmt.Errorf("%s is larger than %d KiB", url, maxFlagSize>>10)
	}
	head := data[:min(len(data), 512)]
	if !bytes.Contains(head, []byte("<svg")) {
		return nil, fmt.Errorf("%s is not an SVG image", url)
	}
	return data, nil
}