433 lines
12 KiB
Go
433 lines
12 KiB
Go
// 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
|
|
}
|