merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
433
internal/updatecdn/catalog.go
Normal file
433
internal/updatecdn/catalog.go
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
// Package updatecdn implements the release catalog shared by the update HTTP
|
||||
// service and telesrv's help.getAppUpdate resolver client.
|
||||
package updatecdn
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ManifestSchemaVersion = 1
|
||||
|
||||
var (
|
||||
sha256RE = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||
versionRE = regexp.MustCompile(`\d+(?:\.\d+)*`)
|
||||
)
|
||||
|
||||
// Manifest is the operator-managed update catalog. Desktop entries feed the
|
||||
// native TDesktop /current4 protocol; Apps entries feed help.getAppUpdate.
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Desktop map[string]map[string]DesktopRelease `json:"desktop,omitempty"`
|
||||
Apps map[string]map[string]ApplicationRelease `json:"apps,omitempty"`
|
||||
}
|
||||
|
||||
// DesktopRelease points at a package produced by TDesktop's Packer target.
|
||||
// The package has its own client-verified RSA signature; SHA256 additionally
|
||||
// prevents publishing a truncated or accidentally replaced file.
|
||||
type DesktopRelease struct {
|
||||
Build uint64 `json:"build"`
|
||||
Version string `json:"version,omitempty"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationRelease is returned through help.appUpdate for mobile and other
|
||||
// clients that use the MTProto update mechanism.
|
||||
type ApplicationRelease struct {
|
||||
ID int `json:"id"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url,omitempty"`
|
||||
URLBySource map[string]string `json:"url_by_source,omitempty"`
|
||||
CanNotSkip bool `json:"can_not_skip,omitempty"`
|
||||
Notes map[string]string `json:"notes"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveRequest describes a help.getAppUpdate client.
|
||||
type ResolveRequest struct {
|
||||
Platform string
|
||||
Channel string
|
||||
Version string
|
||||
Source string
|
||||
LangCode string
|
||||
}
|
||||
|
||||
// ResolvedUpdate is the transport-neutral help.appUpdate payload.
|
||||
type ResolvedUpdate struct {
|
||||
ID int `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url,omitempty"`
|
||||
CanNotSkip bool `json:"can_not_skip,omitempty"`
|
||||
}
|
||||
|
||||
type fileRecord struct {
|
||||
path string
|
||||
name string
|
||||
sha256 string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// Catalog is an immutable, validated manifest snapshot.
|
||||
type Catalog struct {
|
||||
manifest Manifest
|
||||
files map[string]fileRecord
|
||||
}
|
||||
|
||||
// LoadCatalog parses and fully validates a manifest and all enabled desktop
|
||||
// packages. Unknown JSON fields fail closed so operator typos cannot silently
|
||||
// publish an incomplete update.
|
||||
func LoadCatalog(manifestPath, filesDir string) (*Catalog, error) {
|
||||
f, err := os.Open(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open manifest: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if info, err := f.Stat(); err != nil {
|
||||
return nil, fmt.Errorf("stat manifest: %w", err)
|
||||
} else if info.Size() > 4<<20 {
|
||||
return nil, fmt.Errorf("manifest exceeds 4 MiB")
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
decoder := json.NewDecoder(io.LimitReader(f, 4<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest.SchemaVersion != ManifestSchemaVersion {
|
||||
return nil, fmt.Errorf("schema_version = %d, want %d", manifest.SchemaVersion, ManifestSchemaVersion)
|
||||
}
|
||||
|
||||
catalog := &Catalog{manifest: manifest, files: make(map[string]fileRecord)}
|
||||
if err := catalog.validateDesktop(filesDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := catalog.validateApps(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err == io.EOF {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("decode manifest trailer: %w", err)
|
||||
}
|
||||
return fmt.Errorf("manifest contains more than one JSON value")
|
||||
}
|
||||
|
||||
func (c *Catalog) validateDesktop(filesDir string) error {
|
||||
for platform, channels := range c.manifest.Desktop {
|
||||
if !validDesktopPlatform(platform) {
|
||||
return fmt.Errorf("desktop platform %q is unsupported", platform)
|
||||
}
|
||||
for channel, release := range channels {
|
||||
if !validChannel(channel) {
|
||||
return fmt.Errorf("desktop.%s channel %q is unsupported", platform, channel)
|
||||
}
|
||||
if release.Disabled {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("desktop.%s.%s", platform, channel)
|
||||
if release.Build == 0 {
|
||||
return fmt.Errorf("%s.build must be positive", prefix)
|
||||
}
|
||||
if release.File == "" || filepath.Base(release.File) != release.File || strings.ContainsAny(release.File, `/\\`) {
|
||||
return fmt.Errorf("%s.file must be a single file name", prefix)
|
||||
}
|
||||
if !sha256RE.MatchString(release.SHA256) {
|
||||
return fmt.Errorf("%s.sha256 must contain 64 hexadecimal characters", prefix)
|
||||
}
|
||||
if existing, ok := c.files[release.File]; ok {
|
||||
if !strings.EqualFold(existing.sha256, release.SHA256) {
|
||||
return fmt.Errorf("%s.file %q is reused with another SHA256", prefix, release.File)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(filesDir, release.File)
|
||||
record, err := verifyDesktopFile(path, release)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", prefix, err)
|
||||
}
|
||||
c.files[release.File] = record
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyDesktopFile(path string, release DesktopRelease) (fileRecord, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fileRecord{}, fmt.Errorf("open package %q: %w", release.File, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return fileRecord{}, fmt.Errorf("stat package %q: %w", release.File, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fileRecord{}, fmt.Errorf("package %q is not a regular file", release.File)
|
||||
}
|
||||
if release.Size > 0 && release.Size != info.Size() {
|
||||
return fileRecord{}, fmt.Errorf("package %q size = %d, want %d", release.File, info.Size(), release.Size)
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return fileRecord{}, fmt.Errorf("hash package %q: %w", release.File, err)
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, release.SHA256) {
|
||||
return fileRecord{}, fmt.Errorf("package %q SHA256 = %s, want %s", release.File, actual, strings.ToLower(release.SHA256))
|
||||
}
|
||||
return fileRecord{
|
||||
path: path,
|
||||
name: release.File,
|
||||
sha256: actual,
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) validateApps() error {
|
||||
for platform, channels := range c.manifest.Apps {
|
||||
if !validAppPlatform(platform) {
|
||||
return fmt.Errorf("apps platform %q is unsupported", platform)
|
||||
}
|
||||
for channel, release := range channels {
|
||||
if !validChannel(channel) {
|
||||
return fmt.Errorf("apps.%s channel %q is unsupported", platform, channel)
|
||||
}
|
||||
if release.Disabled {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("apps.%s.%s", platform, channel)
|
||||
if release.ID <= 0 {
|
||||
return fmt.Errorf("%s.id must be positive", prefix)
|
||||
}
|
||||
if _, ok := parseVersion(release.Version); !ok {
|
||||
return fmt.Errorf("%s.version must contain a numeric version", prefix)
|
||||
}
|
||||
if len(release.Notes) == 0 {
|
||||
return fmt.Errorf("%s.notes must contain at least one localization", prefix)
|
||||
}
|
||||
for lang, note := range release.Notes {
|
||||
if strings.TrimSpace(lang) == "" || strings.TrimSpace(note) == "" {
|
||||
return fmt.Errorf("%s.notes contains an empty language or text", prefix)
|
||||
}
|
||||
}
|
||||
if release.URL != "" {
|
||||
if err := validateDownloadURL(release.URL); err != nil {
|
||||
return fmt.Errorf("%s.url: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
for source, rawURL := range release.URLBySource {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return fmt.Errorf("%s.url_by_source contains an empty source", prefix)
|
||||
}
|
||||
if err := validateDownloadURL(rawURL); err != nil {
|
||||
return fmt.Errorf("%s.url_by_source[%q]: %w", prefix, source, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDownloadURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse URL: %w", err)
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
|
||||
return fmt.Errorf("must be an HTTP(S) URL without credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDesktopPlatform(platform string) bool {
|
||||
switch platform {
|
||||
case "win", "win64", "winarm", "mac", "armac", "linux":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAppPlatform(platform string) bool {
|
||||
switch platform {
|
||||
case "android", "ios", "macos", "tdesktop":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validChannel(channel string) bool {
|
||||
return channel == "stable" || channel == "beta" || channel == "alpha"
|
||||
}
|
||||
|
||||
// DesktopMap returns the exact JSON object consumed by TDesktop's current4
|
||||
// parser. Links are deliberately relative to autoupdate_url_prefix.
|
||||
func (c *Catalog) DesktopMap() map[string]map[string]map[string]any {
|
||||
result := make(map[string]map[string]map[string]any)
|
||||
for platform, channels := range c.manifest.Desktop {
|
||||
published := make(map[string]map[string]any)
|
||||
for channel, release := range channels {
|
||||
if release.Disabled {
|
||||
continue
|
||||
}
|
||||
published[channel] = map[string]any{
|
||||
"released": release.Build,
|
||||
"link": "/files/" + url.PathEscape(release.File),
|
||||
}
|
||||
}
|
||||
if len(published) != 0 {
|
||||
result[platform] = published
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Resolve returns a release only when it is newer than the supplied client
|
||||
// version. Empty channel means stable.
|
||||
func (c *Catalog) Resolve(req ResolveRequest) (*ResolvedUpdate, error) {
|
||||
platform := strings.ToLower(strings.TrimSpace(req.Platform))
|
||||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
channels, ok := c.manifest.Apps[platform]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
release, ok := channels[channel]
|
||||
if !ok || release.Disabled {
|
||||
return nil, nil
|
||||
}
|
||||
if compareVersions(req.Version, release.Version) >= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
text := localizedText(release.Notes, req.LangCode)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("release %s/%s has no usable localized text", platform, channel)
|
||||
}
|
||||
updateURL := release.URL
|
||||
if specific := release.URLBySource[req.Source]; specific != "" {
|
||||
updateURL = specific
|
||||
}
|
||||
return &ResolvedUpdate{
|
||||
ID: release.ID,
|
||||
Version: release.Version,
|
||||
Text: text,
|
||||
URL: updateURL,
|
||||
CanNotSkip: release.CanNotSkip,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func localizedText(values map[string]string, langCode string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalizedValues := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
normalizedValues[strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "_", "-"))] = value
|
||||
}
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(langCode), "_", "-"))
|
||||
if text := normalizedValues[normalized]; text != "" {
|
||||
return text
|
||||
}
|
||||
if base, _, ok := strings.Cut(normalized, "-"); ok {
|
||||
if text := normalizedValues[base]; text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
if text := normalizedValues["en"]; text != "" {
|
||||
return text
|
||||
}
|
||||
keys := make([]string, 0, len(normalizedValues))
|
||||
for key := range normalizedValues {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return normalizedValues[keys[0]]
|
||||
}
|
||||
|
||||
func compareVersions(left, right string) int {
|
||||
a, aOK := parseVersion(left)
|
||||
b, bOK := parseVersion(right)
|
||||
if !aOK && !bOK {
|
||||
return strings.Compare(strings.TrimSpace(left), strings.TrimSpace(right))
|
||||
}
|
||||
if !aOK {
|
||||
return -1
|
||||
}
|
||||
if !bOK {
|
||||
return 1
|
||||
}
|
||||
max := len(a)
|
||||
if len(b) > max {
|
||||
max = len(b)
|
||||
}
|
||||
for i := 0; i < max; i++ {
|
||||
var av, bv uint64
|
||||
if i < len(a) {
|
||||
av = a[i]
|
||||
}
|
||||
if i < len(b) {
|
||||
bv = b[i]
|
||||
}
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
if av > bv {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseVersion(value string) ([]uint64, bool) {
|
||||
match := versionRE.FindString(value)
|
||||
if match == "" {
|
||||
return nil, false
|
||||
}
|
||||
rawParts := strings.Split(match, ".")
|
||||
parts := make([]uint64, 0, len(rawParts))
|
||||
for _, raw := range rawParts {
|
||||
part, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts, true
|
||||
}
|
||||
|
||||
func (c *Catalog) file(name string) (fileRecord, bool) {
|
||||
record, ok := c.files[name]
|
||||
return record, ok
|
||||
}
|
||||
100
internal/updatecdn/catalog_test.go
Normal file
100
internal/updatecdn/catalog_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package updatecdn
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogDesktopMapAndResolve(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filesDir := filepath.Join(dir, "files")
|
||||
if err := os.Mkdir(filesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageData := []byte("signed-tdesktop-update-package")
|
||||
packageName := "tx64upd7007000"
|
||||
if err := os.WriteFile(filepath.Join(filesDir, packageName), packageData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := sha256.Sum256(packageData)
|
||||
manifest := Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Desktop: map[string]map[string]DesktopRelease{
|
||||
"win64": {"stable": {
|
||||
Build: 7007000, Version: "7.0.7", File: packageName,
|
||||
SHA256: hex.EncodeToString(hash[:]), Size: int64(len(packageData)),
|
||||
}},
|
||||
},
|
||||
Apps: map[string]map[string]ApplicationRelease{
|
||||
"android": {"stable": {
|
||||
ID: 77, Version: "12.9.1", URL: "https://updates.example/app.apk",
|
||||
URLBySource: map[string]string{"com.example.store": "https://store.example/app"},
|
||||
Notes: map[string]string{"en": "New version", "ru": "Новая версия"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
manifestPath := writeTestManifest(t, dir, manifest)
|
||||
catalog, err := LoadCatalog(manifestPath, filesDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry := catalog.DesktopMap()["win64"]["stable"]
|
||||
if entry["released"] != uint64(7007000) || entry["link"] != "/files/tx64upd7007000" {
|
||||
t.Fatalf("desktop entry = %#v", entry)
|
||||
}
|
||||
resolved, err := catalog.Resolve(ResolveRequest{
|
||||
Platform: "android", Version: "12.9.0 (500)", Source: "com.example.store", LangCode: "ru-RU",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved == nil || resolved.ID != 77 || resolved.Text != "Новая версия" || resolved.URL != "https://store.example/app" {
|
||||
t.Fatalf("resolved update = %#v", resolved)
|
||||
}
|
||||
current, err := catalog.Resolve(ResolveRequest{Platform: "android", Version: "12.9.1", LangCode: "en"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current != nil {
|
||||
t.Fatalf("current client got update %#v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCatalogRejectsPackageHashMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filesDir := filepath.Join(dir, "files")
|
||||
if err := os.Mkdir(filesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(filesDir, "tx64upd7007000"), []byte("package"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := writeTestManifest(t, dir, Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Desktop: map[string]map[string]DesktopRelease{
|
||||
"win64": {"stable": {Build: 7007000, File: "tx64upd7007000", SHA256: strings.Repeat("0", 64)}},
|
||||
},
|
||||
})
|
||||
if _, err := LoadCatalog(manifestPath, filesDir); err == nil {
|
||||
t.Fatal("hash mismatch accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestManifest(t *testing.T, dir string, manifest Manifest) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "manifest.json")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
77
internal/updatecdn/client.go
Normal file
77
internal/updatecdn/client.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package updatecdn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, req ResolveRequest) (*ResolvedUpdate, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, timeout time.Duration) (*Client, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse update service URL: %w", err)
|
||||
}
|
||||
if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("update service URL must be an HTTP(S) base URL without credentials, query, or fragment")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
return &Client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Resolve(ctx context.Context, request ResolveRequest) (*ResolvedUpdate, error) {
|
||||
u, err := url.Parse(c.baseURL + "/v1/resolve")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("platform", request.Platform)
|
||||
query.Set("channel", request.Channel)
|
||||
query.Set("version", request.Version)
|
||||
query.Set("source", request.Source)
|
||||
query.Set("lang_code", request.LangCode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build update resolve request: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve application update: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
return nil, fmt.Errorf("resolve application update: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var resolved ResolvedUpdate
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, 256<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&resolved); err != nil {
|
||||
return nil, fmt.Errorf("decode application update: %w", err)
|
||||
}
|
||||
if resolved.ID <= 0 || strings.TrimSpace(resolved.Version) == "" || strings.TrimSpace(resolved.Text) == "" {
|
||||
return nil, fmt.Errorf("resolve application update: incomplete response")
|
||||
}
|
||||
return &resolved, nil
|
||||
}
|
||||
187
internal/updatecdn/server.go
Normal file
187
internal/updatecdn/server.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package updatecdn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxResolveQueryLength = 256
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHandler(store *Store) (*Handler, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("update catalog store is required")
|
||||
}
|
||||
h := &Handler{store: store, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("/healthz", h.health)
|
||||
h.mux.HandleFunc("/readyz", h.ready)
|
||||
h.mux.HandleFunc("/v1/resolve", h.resolve)
|
||||
h.mux.HandleFunc("/files/", h.file)
|
||||
for _, endpoint := range []string{"/current", "/current1", "/current2", "/current3", "/current4"} {
|
||||
h.mux.HandleFunc(endpoint, h.current)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowReadMethod(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write([]byte("{\"status\":\"ok\"}\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowReadMethod(w, r) {
|
||||
return
|
||||
}
|
||||
if _, err := h.store.Snapshot(); err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "catalog unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write([]byte("{\"status\":\"ready\"}\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) current(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowReadMethod(w, r) {
|
||||
return
|
||||
}
|
||||
catalog, err := h.store.Snapshot()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "catalog unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(catalog.DesktopMap()); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowReadMethod(w, r) {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
values := []string{query.Get("platform"), query.Get("channel"), query.Get("version"), query.Get("source"), query.Get("lang_code")}
|
||||
for _, value := range values {
|
||||
if len(value) > maxResolveQueryLength {
|
||||
writeJSONError(w, http.StatusBadRequest, "query value too long")
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(values[0]) == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "platform is required")
|
||||
return
|
||||
}
|
||||
catalog, err := h.store.Snapshot()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "catalog unavailable")
|
||||
return
|
||||
}
|
||||
resolved, err := catalog.Resolve(ResolveRequest{
|
||||
Platform: values[0],
|
||||
Channel: values[1],
|
||||
Version: values[2],
|
||||
Source: values[3],
|
||||
LangCode: values[4],
|
||||
})
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "resolve failed")
|
||||
return
|
||||
}
|
||||
if resolved == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resolved)
|
||||
}
|
||||
|
||||
func (h *Handler) file(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowReadMethod(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
if name == "" || path.Base(name) != name || strings.ContainsAny(name, `/\\`) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
catalog, err := h.store.Snapshot()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "catalog unavailable")
|
||||
return
|
||||
}
|
||||
record, ok := catalog.file(name)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
etag := `"sha256-` + record.sha256 + `"`
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(record.path)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "package unavailable")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != record.size || !info.ModTime().Equal(record.modTime) {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "package changed; reload the manifest")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", record.name))
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
http.ServeContent(w, r, record.name, record.modTime, f)
|
||||
}
|
||||
|
||||
func allowReadMethod(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
84
internal/updatecdn/server_test.go
Normal file
84
internal/updatecdn/server_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package updatecdn
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandlerServesCurrentResolveAndRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filesDir := filepath.Join(dir, "files")
|
||||
if err := os.Mkdir(filesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageData := []byte("0123456789abcdef")
|
||||
packageName := "tx64upd7007000"
|
||||
if err := os.WriteFile(filepath.Join(filesDir, packageName), packageData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := sha256.Sum256(packageData)
|
||||
manifestPath := writeTestManifest(t, dir, Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Desktop: map[string]map[string]DesktopRelease{
|
||||
"win64": {"stable": {Build: 7007000, File: packageName, SHA256: hex.EncodeToString(hash[:])}},
|
||||
},
|
||||
Apps: map[string]map[string]ApplicationRelease{
|
||||
"ios": {"stable": {ID: 8, Version: "12.9.1", URL: "https://apps.apple.com/app/id1", Notes: map[string]string{"en": "Update available"}}},
|
||||
},
|
||||
})
|
||||
store, err := NewStore(manifestPath, filesDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandler(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/current4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(currentBody), `"released":7007000`) {
|
||||
t.Fatalf("current4 = %d %s", resp.StatusCode, currentBody)
|
||||
}
|
||||
|
||||
client, err := NewClient(server.URL, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := client.Resolve(t.Context(), ResolveRequest{Platform: "ios", Version: "12.9.0", LangCode: "en"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved == nil || resolved.ID != 8 || resolved.Version != "12.9.1" {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/files/"+packageName, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
rangeResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rangeBody, _ := io.ReadAll(rangeResp.Body)
|
||||
rangeResp.Body.Close()
|
||||
if rangeResp.StatusCode != http.StatusPartialContent || string(rangeBody) != "2345" {
|
||||
t.Fatalf("range = %d %q", rangeResp.StatusCode, rangeBody)
|
||||
}
|
||||
}
|
||||
60
internal/updatecdn/store.go
Normal file
60
internal/updatecdn/store.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package updatecdn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store atomically reloads the catalog when the manifest changes. A broken new
|
||||
// manifest is never mixed with the previous snapshot.
|
||||
type Store struct {
|
||||
manifestPath string
|
||||
filesDir string
|
||||
|
||||
mu sync.RWMutex
|
||||
catalog *Catalog
|
||||
modTime time.Time
|
||||
fileSize int64
|
||||
}
|
||||
|
||||
func NewStore(manifestPath, filesDir string) (*Store, error) {
|
||||
store := &Store{manifestPath: manifestPath, filesDir: filesDir}
|
||||
if _, err := store.Snapshot(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() (*Catalog, error) {
|
||||
info, err := os.Stat(s.manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat manifest: %w", err)
|
||||
}
|
||||
s.mu.RLock()
|
||||
if s.catalog != nil && info.ModTime().Equal(s.modTime) && info.Size() == s.fileSize {
|
||||
catalog := s.catalog
|
||||
s.mu.RUnlock()
|
||||
return catalog, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
info, err = os.Stat(s.manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat manifest: %w", err)
|
||||
}
|
||||
if s.catalog != nil && info.ModTime().Equal(s.modTime) && info.Size() == s.fileSize {
|
||||
return s.catalog, nil
|
||||
}
|
||||
catalog, err := LoadCatalog(s.manifestPath, s.filesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.catalog = catalog
|
||||
s.modTime = info.ModTime()
|
||||
s.fileSize = info.Size()
|
||||
return catalog, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue