Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling). Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
1042 lines
39 KiB
Go
1042 lines
39 KiB
Go
// Command giftfetch snapshots the official Telegram star-gift catalog, the
|
|
// complete current upgrade-attribute pools, and all document resources
|
|
// referenced by either response. It is a read-only fetcher: it never imports
|
|
// data into telesrv and never copies the authorization session.
|
|
//
|
|
// Usage:
|
|
//
|
|
// SESSION=/path/to/session giftfetch -out <directory>
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/bin"
|
|
"github.com/iamxvbaba/td/telegram"
|
|
"github.com/iamxvbaba/td/telegram/downloader"
|
|
"github.com/iamxvbaba/td/tg"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"telesrv/internal/app/stargifts"
|
|
)
|
|
|
|
const (
|
|
apiID = 17349
|
|
apiHash = "344583e45741c457fe1862106095a5eb"
|
|
defaultMaxDocBytes = int64(16 << 20)
|
|
defaultWorkers = 8
|
|
maxCatalogGifts = 5000
|
|
maxUpgradeAttrs = 10000
|
|
maxDocuments = 50000
|
|
maxWorkers = 128
|
|
floodWaitMargin = 5 * time.Second
|
|
)
|
|
|
|
type catalogManifest struct {
|
|
Schema int `json:"schema"`
|
|
Hash int `json:"hash"`
|
|
RawCatalog fileArtifact `json:"raw_catalog"`
|
|
GiftCount int `json:"gift_count"`
|
|
ChatCount int `json:"chat_count"`
|
|
UserCount int `json:"user_count"`
|
|
UpgradeableGiftCount int `json:"upgradeable_gift_count"`
|
|
UpgradeAttributeSetCount int `json:"upgrade_attribute_set_count"`
|
|
UpgradeAttributeCount int `json:"upgrade_attribute_count"`
|
|
UpgradeModelCount int `json:"upgrade_model_count"`
|
|
UpgradePatternCount int `json:"upgrade_pattern_count"`
|
|
UpgradeBackdropCount int `json:"upgrade_backdrop_count"`
|
|
MissingThumbCount int `json:"missing_thumb_count"`
|
|
Gifts []giftManifest `json:"gifts"`
|
|
UpgradeAttributeSets []upgradeAttributeSetManifest `json:"upgrade_attribute_sets"`
|
|
Documents []documentManifest `json:"documents"`
|
|
TotalBytes int64 `json:"total_document_bytes"`
|
|
BoundaryNote string `json:"boundary_note"`
|
|
}
|
|
|
|
type giftManifest struct {
|
|
Index int `json:"index"`
|
|
Kind string `json:"kind"`
|
|
ID int64 `json:"id"`
|
|
GiftID int64 `json:"gift_id,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Slug string `json:"slug,omitempty"`
|
|
Number int `json:"number,omitempty"`
|
|
Stars int64 `json:"stars,omitempty"`
|
|
ConvertStars int64 `json:"convert_stars,omitempty"`
|
|
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
|
ResellMinStars int64 `json:"resell_min_stars,omitempty"`
|
|
Limited bool `json:"limited,omitempty"`
|
|
SoldOut bool `json:"sold_out,omitempty"`
|
|
Birthday bool `json:"birthday,omitempty"`
|
|
RequirePremium bool `json:"require_premium,omitempty"`
|
|
LimitedPerUser bool `json:"limited_per_user,omitempty"`
|
|
PeerColorAvailable bool `json:"peer_color_available,omitempty"`
|
|
Auction bool `json:"auction,omitempty"`
|
|
AvailabilityRemains int `json:"availability_remains,omitempty"`
|
|
AvailabilityTotal int `json:"availability_total,omitempty"`
|
|
AvailabilityResale int64 `json:"availability_resale,omitempty"`
|
|
AvailabilityIssued int `json:"availability_issued,omitempty"`
|
|
PerUserTotal int `json:"per_user_total,omitempty"`
|
|
PerUserRemains int `json:"per_user_remains,omitempty"`
|
|
FirstSaleDate int `json:"first_sale_date,omitempty"`
|
|
LastSaleDate int `json:"last_sale_date,omitempty"`
|
|
LockedUntilDate int `json:"locked_until_date,omitempty"`
|
|
AuctionSlug string `json:"auction_slug,omitempty"`
|
|
GiftsPerRound int `json:"gifts_per_round,omitempty"`
|
|
AuctionStartDate int `json:"auction_start_date,omitempty"`
|
|
UpgradeVariants int `json:"upgrade_variants,omitempty"`
|
|
DocumentIDs []int64 `json:"document_ids,omitempty"`
|
|
Background *backgroundManifest `json:"background,omitempty"`
|
|
}
|
|
|
|
type backgroundManifest struct {
|
|
CenterColor int `json:"center_color"`
|
|
EdgeColor int `json:"edge_color"`
|
|
TextColor int `json:"text_color"`
|
|
}
|
|
|
|
type upgradeAttributeSetManifest struct {
|
|
GiftID int64 `json:"gift_id"`
|
|
RawAttributes fileArtifact `json:"raw_attributes"`
|
|
AttributeCount int `json:"attribute_count"`
|
|
Models []upgradeModelManifest `json:"models"`
|
|
Patterns []upgradePatternManifest `json:"patterns"`
|
|
Backdrops []upgradeBackdropManifest `json:"backdrops"`
|
|
DocumentIDs []int64 `json:"document_ids"`
|
|
}
|
|
|
|
type upgradeModelManifest struct {
|
|
Name string `json:"name"`
|
|
DocumentID int64 `json:"document_id"`
|
|
Crafted bool `json:"crafted"`
|
|
Rarity rarityManifest `json:"rarity"`
|
|
}
|
|
|
|
type upgradePatternManifest struct {
|
|
Name string `json:"name"`
|
|
DocumentID int64 `json:"document_id"`
|
|
Rarity rarityManifest `json:"rarity"`
|
|
}
|
|
|
|
type upgradeBackdropManifest struct {
|
|
Name string `json:"name"`
|
|
BackdropID int `json:"backdrop_id"`
|
|
CenterColor int `json:"center_color"`
|
|
EdgeColor int `json:"edge_color"`
|
|
PatternColor int `json:"pattern_color"`
|
|
TextColor int `json:"text_color"`
|
|
Rarity rarityManifest `json:"rarity"`
|
|
}
|
|
|
|
type rarityManifest struct {
|
|
Kind string `json:"kind"`
|
|
ConstructorID string `json:"constructor_id"`
|
|
Permille *int `json:"permille,omitempty"`
|
|
}
|
|
|
|
type documentManifest struct {
|
|
ID int64 `json:"id"`
|
|
Date int `json:"date"`
|
|
DCID int `json:"dc_id"`
|
|
MimeType string `json:"mime_type"`
|
|
ExpectedSize int64 `json:"expected_size"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
StickerAlt string `json:"sticker_alt,omitempty"`
|
|
Purposes []string `json:"purposes"`
|
|
File fileArtifact `json:"file"`
|
|
AnimationValidated bool `json:"animation_validated,omitempty"`
|
|
ValidationError string `json:"validation_error,omitempty"`
|
|
Thumbs []fileArtifact `json:"thumbs,omitempty"`
|
|
MissingThumbs []missingThumb `json:"missing_thumbs,omitempty"`
|
|
}
|
|
|
|
type missingThumb struct {
|
|
Kind string `json:"kind"`
|
|
Type string `json:"type"`
|
|
ExpectedSize int64 `json:"expected_size"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type fileArtifact struct {
|
|
Kind string `json:"kind,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type documentSource struct {
|
|
document *tg.Document
|
|
purposes map[string]struct{}
|
|
}
|
|
|
|
type addDocumentFunc func(tg.DocumentClass, string) (*tg.Document, error)
|
|
|
|
func main() {
|
|
outDir := flag.String("out", "", "output directory")
|
|
maxDocBytes := flag.Int64("max-document-bytes", defaultMaxDocBytes, "maximum bytes accepted for one document or thumbnail")
|
|
skipThumbs := flag.Bool("skip-thumbs", false, "download main documents only")
|
|
workers := flag.Int("workers", defaultWorkers, "concurrent document downloads")
|
|
reuseMetadata := flag.Bool("reuse-metadata", false, "reuse and strictly decode catalog.tl plus upgrade-attributes/*.tl instead of refetching metadata")
|
|
allowedMissingThumbsRaw := flag.String("allow-missing-thumb", "", "comma-separated document_id:photo|video:type entries that may be recorded as explicitly missing after a failed download")
|
|
flag.Parse()
|
|
if strings.TrimSpace(*outDir) == "" {
|
|
fmt.Fprintln(os.Stderr, "usage: SESSION=/path/to/session giftfetch -out <directory>")
|
|
os.Exit(2)
|
|
}
|
|
session := strings.TrimSpace(os.Getenv("SESSION"))
|
|
if session == "" {
|
|
fmt.Fprintln(os.Stderr, "ERROR: SESSION is required")
|
|
os.Exit(2)
|
|
}
|
|
if *maxDocBytes <= 0 || *maxDocBytes > 256<<20 {
|
|
fmt.Fprintln(os.Stderr, "ERROR: max-document-bytes must be in (0, 256 MiB]")
|
|
os.Exit(2)
|
|
}
|
|
if *workers <= 0 || *workers > maxWorkers {
|
|
fmt.Fprintf(os.Stderr, "ERROR: workers must be in [1, %d]\n", maxWorkers)
|
|
os.Exit(2)
|
|
}
|
|
allowedMissingThumbs, err := parseAllowedMissingThumbs(*allowedMissingThumbsRaw)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "ERROR:", err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
|
|
defer cancel()
|
|
client := telegram.NewClient(apiID, apiHash, telegram.Options{
|
|
SessionStorage: &telegram.FileSessionStorage{Path: session},
|
|
})
|
|
if err := client.Run(ctx, func(ctx context.Context) error {
|
|
status, err := client.Auth().Status(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("auth status: %w", err)
|
|
}
|
|
if !status.Authorized {
|
|
return errors.New("SESSION is not authorized")
|
|
}
|
|
fmt.Println("[session authorized]")
|
|
return fetchCatalog(ctx, client.API(), *outDir, *maxDocBytes, *skipThumbs, *workers, *reuseMetadata, allowedMissingThumbs)
|
|
}); err != nil {
|
|
fmt.Fprintln(os.Stderr, "ERROR:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func fetchCatalog(ctx context.Context, api *tg.Client, outDir string, maxDocBytes int64, skipThumbs bool, workers int, reuseMetadata bool, allowedMissingThumbs map[string]struct{}) error {
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
var catalog *tg.PaymentsStarGifts
|
|
var rawArtifact fileArtifact
|
|
if reuseMetadata {
|
|
catalog = &tg.PaymentsStarGifts{}
|
|
var err error
|
|
rawArtifact, err = readTLArtifact(outDir, "catalog.tl", catalog)
|
|
if err != nil {
|
|
return fmt.Errorf("reuse catalog metadata: %w", err)
|
|
}
|
|
fmt.Printf("[metadata reused] path=%s\n", rawArtifact.Path)
|
|
} else {
|
|
result, err := api.PaymentsGetStarGifts(ctx, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("payments.getStarGifts: %w", err)
|
|
}
|
|
var ok bool
|
|
catalog, ok = result.(*tg.PaymentsStarGifts)
|
|
if !ok {
|
|
return fmt.Errorf("payments.getStarGifts(hash=0) returned %T", result)
|
|
}
|
|
var raw bin.Buffer
|
|
if err := catalog.Encode(&raw); err != nil {
|
|
return fmt.Errorf("encode raw catalog: %w", err)
|
|
}
|
|
rawArtifact, err = writeArtifact(outDir, "catalog.tl", "tl", "", raw.Buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(catalog.Gifts) > maxCatalogGifts {
|
|
return fmt.Errorf("gift catalog has %d entries, limit is %d", len(catalog.Gifts), maxCatalogGifts)
|
|
}
|
|
manifest := catalogManifest{
|
|
Schema: 2,
|
|
Hash: catalog.Hash,
|
|
RawCatalog: rawArtifact,
|
|
GiftCount: len(catalog.Gifts),
|
|
ChatCount: len(catalog.Chats),
|
|
UserCount: len(catalog.Users),
|
|
BoundaryNote: "payments.getStarGifts(hash=0) plus payments.getStarGiftUpgradeAttributes for every currently upgradeable base gift: complete current official attribute definitions and referenced documents, not deleted historical definitions or precomputed model-pattern-backdrop combinations",
|
|
}
|
|
|
|
documents := make(map[int64]*documentSource)
|
|
addDocument := func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
|
|
doc, ok := class.(*tg.Document)
|
|
if !ok || doc.ID == 0 {
|
|
return nil, fmt.Errorf("%s references invalid document %T", purpose, class)
|
|
}
|
|
if !hasRenderableStickerAttribute(doc) {
|
|
return nil, fmt.Errorf("%s document %d has neither sticker nor custom-emoji attribute", purpose, doc.ID)
|
|
}
|
|
existing := documents[doc.ID]
|
|
if existing == nil {
|
|
existing = &documentSource{document: doc, purposes: make(map[string]struct{})}
|
|
documents[doc.ID] = existing
|
|
} else if existing.document.Size != doc.Size || existing.document.MimeType != doc.MimeType {
|
|
return nil, fmt.Errorf("document %d has conflicting metadata", doc.ID)
|
|
}
|
|
existing.purposes[purpose] = struct{}{}
|
|
return doc, nil
|
|
}
|
|
|
|
for index, class := range catalog.Gifts {
|
|
gm, err := collectGift(index, class, addDocument)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
manifest.Gifts = append(manifest.Gifts, gm)
|
|
}
|
|
|
|
upgradeableGiftIDs := collectUpgradeableGiftIDs(catalog.Gifts)
|
|
manifest.UpgradeableGiftCount = len(upgradeableGiftIDs)
|
|
for _, giftID := range upgradeableGiftIDs {
|
|
attributeSet, err := fetchUpgradeAttributeSet(ctx, api, outDir, giftID, addDocument, reuseMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
manifest.UpgradeAttributeSets = append(manifest.UpgradeAttributeSets, attributeSet)
|
|
manifest.UpgradeAttributeSetCount++
|
|
manifest.UpgradeAttributeCount += attributeSet.AttributeCount
|
|
manifest.UpgradeModelCount += len(attributeSet.Models)
|
|
manifest.UpgradePatternCount += len(attributeSet.Patterns)
|
|
manifest.UpgradeBackdropCount += len(attributeSet.Backdrops)
|
|
fmt.Printf("[upgrade attributes] gift_id=%d models=%d patterns=%d backdrops=%d raw=%s\n", giftID, len(attributeSet.Models), len(attributeSet.Patterns), len(attributeSet.Backdrops), attributeSet.RawAttributes.Path)
|
|
}
|
|
if manifest.UpgradeAttributeSetCount != manifest.UpgradeableGiftCount {
|
|
return fmt.Errorf("upgrade attribute set count mismatch: got %d want %d", manifest.UpgradeAttributeSetCount, manifest.UpgradeableGiftCount)
|
|
}
|
|
if len(documents) > maxDocuments {
|
|
return fmt.Errorf("catalog and upgrade attributes reference %d documents, limit is %d", len(documents), maxDocuments)
|
|
}
|
|
|
|
ids := make([]int64, 0, len(documents))
|
|
for id := range documents {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
fmt.Printf("[documents discovered] count=%d workers=%d\n", len(ids), workers)
|
|
documentResults := make([]documentManifest, len(ids))
|
|
group, downloadCtx := errgroup.WithContext(ctx)
|
|
group.SetLimit(workers)
|
|
var completed atomic.Int64
|
|
for index, id := range ids {
|
|
index, id := index, id
|
|
group.Go(func() error {
|
|
dl := downloader.NewDownloader().WithRetryHandler(func(event downloader.RetryEvent) {
|
|
fmt.Printf("[download retry] document=%d operation=%s attempt=%d error=%v\n", id, event.Operation, event.Attempt, event.Err)
|
|
if strings.Contains(event.Err.Error(), "FLOOD_WAIT") {
|
|
timer := time.NewTimer(floodWaitMargin)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-downloadCtx.Done():
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
})
|
|
dm, err := fetchDocument(downloadCtx, api, dl, outDir, documents[id], maxDocBytes, skipThumbs, allowedMissingThumbs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentResults[index] = dm
|
|
done := completed.Add(1)
|
|
if done == int64(len(ids)) || done%100 == 0 {
|
|
fmt.Printf("[documents downloaded] completed=%d total=%d\n", done, len(ids))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if err := group.Wait(); err != nil {
|
|
return err
|
|
}
|
|
for _, dm := range documentResults {
|
|
manifest.TotalBytes += dm.File.Size
|
|
for _, thumb := range dm.Thumbs {
|
|
manifest.TotalBytes += thumb.Size
|
|
}
|
|
manifest.MissingThumbCount += len(dm.MissingThumbs)
|
|
manifest.Documents = append(manifest.Documents, dm)
|
|
}
|
|
|
|
encoded, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeFileAtomic(filepath.Join(outDir, "manifest.json"), append(encoded, '\n')); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[complete] gifts=%d attribute_sets=%d attributes=%d documents=%d missing_thumbs=%d bytes=%d manifest=%s\n", len(manifest.Gifts), len(manifest.UpgradeAttributeSets), manifest.UpgradeAttributeCount, len(manifest.Documents), manifest.MissingThumbCount, manifest.TotalBytes, filepath.Join(outDir, "manifest.json"))
|
|
return nil
|
|
}
|
|
|
|
func collectUpgradeableGiftIDs(classes []tg.StarGiftClass) []int64 {
|
|
ids := make([]int64, 0, len(classes))
|
|
for _, class := range classes {
|
|
gift, ok := class.(*tg.StarGift)
|
|
if !ok || gift.ID == 0 || gift.UpgradeStars <= 0 && gift.UpgradeVariants <= 0 {
|
|
continue
|
|
}
|
|
ids = append(ids, gift.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func fetchUpgradeAttributeSet(ctx context.Context, api *tg.Client, root string, giftID int64, addDocument addDocumentFunc, reuseMetadata bool) (upgradeAttributeSetManifest, error) {
|
|
var result *tg.PaymentsStarGiftUpgradeAttributes
|
|
var rawArtifact fileArtifact
|
|
if reuseMetadata {
|
|
result = &tg.PaymentsStarGiftUpgradeAttributes{}
|
|
var err error
|
|
rawArtifact, err = readTLArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), result)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("reuse upgrade attributes for gift %d: %w", giftID, err)
|
|
}
|
|
} else {
|
|
var err error
|
|
result, err = api.PaymentsGetStarGiftUpgradeAttributes(ctx, giftID)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d): %w", giftID, err)
|
|
}
|
|
var raw bin.Buffer
|
|
if err := result.Encode(&raw); err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("encode upgrade attributes for gift %d: %w", giftID, err)
|
|
}
|
|
rawArtifact, err = writeArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), "tl", "", raw.Buf)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, err
|
|
}
|
|
}
|
|
if len(result.Attributes) == 0 {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d) returned no attributes", giftID)
|
|
}
|
|
if len(result.Attributes) > maxUpgradeAttrs {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has %d upgrade attributes, limit is %d", giftID, len(result.Attributes), maxUpgradeAttrs)
|
|
}
|
|
return collectUpgradeAttributes(giftID, result, rawArtifact, addDocument)
|
|
}
|
|
|
|
func collectUpgradeAttributes(giftID int64, result *tg.PaymentsStarGiftUpgradeAttributes, rawArtifact fileArtifact, addDocument addDocumentFunc) (upgradeAttributeSetManifest, error) {
|
|
if result == nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has nil upgrade-attribute result", giftID)
|
|
}
|
|
set := upgradeAttributeSetManifest{
|
|
GiftID: giftID,
|
|
RawAttributes: rawArtifact,
|
|
AttributeCount: len(result.Attributes),
|
|
}
|
|
documentIDs := make(map[int64]struct{})
|
|
for index, attribute := range result.Attributes {
|
|
switch value := attribute.(type) {
|
|
case *tg.StarGiftAttributeModel:
|
|
rarity, err := collectRarity(value.Rarity)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d model %q rarity: %w", giftID, value.Name, err)
|
|
}
|
|
doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-model:%s", giftID, value.Name))
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, err
|
|
}
|
|
set.Models = append(set.Models, upgradeModelManifest{Name: value.Name, DocumentID: doc.ID, Crafted: value.GetCrafted(), Rarity: rarity})
|
|
documentIDs[doc.ID] = struct{}{}
|
|
case *tg.StarGiftAttributePattern:
|
|
rarity, err := collectRarity(value.Rarity)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d pattern %q rarity: %w", giftID, value.Name, err)
|
|
}
|
|
doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-pattern:%s", giftID, value.Name))
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, err
|
|
}
|
|
set.Patterns = append(set.Patterns, upgradePatternManifest{Name: value.Name, DocumentID: doc.ID, Rarity: rarity})
|
|
documentIDs[doc.ID] = struct{}{}
|
|
case *tg.StarGiftAttributeBackdrop:
|
|
rarity, err := collectRarity(value.Rarity)
|
|
if err != nil {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d backdrop %q rarity: %w", giftID, value.Name, err)
|
|
}
|
|
set.Backdrops = append(set.Backdrops, upgradeBackdropManifest{
|
|
Name: value.Name, BackdropID: value.BackdropID, CenterColor: value.CenterColor,
|
|
EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor,
|
|
Rarity: rarity,
|
|
})
|
|
default:
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d upgrade attribute at index %d has unsupported constructor %T", giftID, index, attribute)
|
|
}
|
|
}
|
|
if len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d incomplete upgrade attributes: models=%d patterns=%d backdrops=%d", giftID, len(set.Models), len(set.Patterns), len(set.Backdrops))
|
|
}
|
|
if len(set.Models)+len(set.Patterns)+len(set.Backdrops) != set.AttributeCount {
|
|
return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d parsed %d of %d upgrade attributes", giftID, len(set.Models)+len(set.Patterns)+len(set.Backdrops), set.AttributeCount)
|
|
}
|
|
for id := range documentIDs {
|
|
set.DocumentIDs = append(set.DocumentIDs, id)
|
|
}
|
|
sort.Slice(set.DocumentIDs, func(i, j int) bool { return set.DocumentIDs[i] < set.DocumentIDs[j] })
|
|
return set, nil
|
|
}
|
|
|
|
func collectRarity(class tg.StarGiftAttributeRarityClass) (rarityManifest, error) {
|
|
switch value := class.(type) {
|
|
case *tg.StarGiftAttributeRarity:
|
|
permille := value.Permille
|
|
return rarityManifest{Kind: "permille", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID()), Permille: &permille}, nil
|
|
case *tg.StarGiftAttributeRarityUncommon:
|
|
return rarityManifest{Kind: "uncommon", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil
|
|
case *tg.StarGiftAttributeRarityRare:
|
|
return rarityManifest{Kind: "rare", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil
|
|
case *tg.StarGiftAttributeRarityEpic:
|
|
return rarityManifest{Kind: "epic", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil
|
|
case *tg.StarGiftAttributeRarityLegendary:
|
|
return rarityManifest{Kind: "legendary", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil
|
|
default:
|
|
return rarityManifest{}, fmt.Errorf("unsupported constructor %T", class)
|
|
}
|
|
}
|
|
|
|
func collectGift(index int, class tg.StarGiftClass, addDocument addDocumentFunc) (giftManifest, error) {
|
|
switch gift := class.(type) {
|
|
case *tg.StarGift:
|
|
purpose := fmt.Sprintf("gift:%d:sticker", gift.ID)
|
|
doc, err := addDocument(gift.Sticker, purpose)
|
|
if err != nil {
|
|
return giftManifest{}, err
|
|
}
|
|
gm := giftManifest{
|
|
Index: index,
|
|
Kind: "regular",
|
|
ID: gift.ID,
|
|
Stars: gift.Stars,
|
|
ConvertStars: gift.ConvertStars,
|
|
UpgradeStars: gift.UpgradeStars,
|
|
ResellMinStars: gift.ResellMinStars,
|
|
Limited: gift.Limited,
|
|
SoldOut: gift.SoldOut,
|
|
Birthday: gift.Birthday,
|
|
RequirePremium: gift.RequirePremium,
|
|
LimitedPerUser: gift.LimitedPerUser,
|
|
PeerColorAvailable: gift.PeerColorAvailable,
|
|
Auction: gift.Auction,
|
|
AvailabilityRemains: gift.AvailabilityRemains,
|
|
AvailabilityTotal: gift.AvailabilityTotal,
|
|
AvailabilityResale: gift.AvailabilityResale,
|
|
PerUserTotal: gift.PerUserTotal,
|
|
PerUserRemains: gift.PerUserRemains,
|
|
FirstSaleDate: gift.FirstSaleDate,
|
|
LastSaleDate: gift.LastSaleDate,
|
|
LockedUntilDate: gift.LockedUntilDate,
|
|
AuctionSlug: gift.AuctionSlug,
|
|
GiftsPerRound: gift.GiftsPerRound,
|
|
AuctionStartDate: gift.AuctionStartDate,
|
|
UpgradeVariants: gift.UpgradeVariants,
|
|
Title: gift.Title,
|
|
DocumentIDs: []int64{doc.ID},
|
|
}
|
|
if background, ok := gift.GetBackground(); ok {
|
|
gm.Background = &backgroundManifest{CenterColor: background.CenterColor, EdgeColor: background.EdgeColor, TextColor: background.TextColor}
|
|
}
|
|
return gm, nil
|
|
case *tg.StarGiftUnique:
|
|
gm := giftManifest{
|
|
Index: index,
|
|
Kind: "unique",
|
|
ID: gift.ID,
|
|
GiftID: gift.GiftID,
|
|
Title: gift.Title,
|
|
Slug: gift.Slug,
|
|
Number: gift.Num,
|
|
RequirePremium: gift.RequirePremium,
|
|
AvailabilityIssued: gift.AvailabilityIssued,
|
|
AvailabilityTotal: gift.AvailabilityTotal,
|
|
}
|
|
for _, attribute := range gift.Attributes {
|
|
var class tg.DocumentClass
|
|
var purpose string
|
|
switch value := attribute.(type) {
|
|
case *tg.StarGiftAttributeModel:
|
|
class = value.Document
|
|
purpose = fmt.Sprintf("unique:%d:model:%s", gift.ID, value.Name)
|
|
case *tg.StarGiftAttributePattern:
|
|
class = value.Document
|
|
purpose = fmt.Sprintf("unique:%d:pattern:%s", gift.ID, value.Name)
|
|
default:
|
|
continue
|
|
}
|
|
doc, err := addDocument(class, purpose)
|
|
if err != nil {
|
|
return giftManifest{}, err
|
|
}
|
|
gm.DocumentIDs = append(gm.DocumentIDs, doc.ID)
|
|
}
|
|
return gm, nil
|
|
default:
|
|
return giftManifest{}, fmt.Errorf("unsupported gift constructor %T at index %d", class, index)
|
|
}
|
|
}
|
|
|
|
func fetchDocument(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, source *documentSource, maxBytes int64, skipThumbs bool, allowedMissingThumbs map[string]struct{}) (documentManifest, error) {
|
|
doc := source.document
|
|
if doc.Size <= 0 || doc.Size > maxBytes {
|
|
return documentManifest{}, fmt.Errorf("document %d size %d is outside (0, %d]", doc.ID, doc.Size, maxBytes)
|
|
}
|
|
fileName, stickerAlt := documentNames(doc)
|
|
ext := documentExtension(fileName, doc.MimeType)
|
|
rel := filepath.Join("documents", fmt.Sprintf("%d%s", doc.ID, ext))
|
|
data, reused, err := existingArtifact(root, rel, doc.Size, maxBytes)
|
|
if err != nil {
|
|
return documentManifest{}, err
|
|
}
|
|
if !reused {
|
|
fmt.Printf("[network fetch] document=%d resource=document expected_size=%d part_size=%d\n", doc.ID, doc.Size, downloadPartSize(doc.Size))
|
|
data, err = download(ctx, api, dl, doc, "", doc.Size, maxBytes)
|
|
if err != nil {
|
|
return documentManifest{}, fmt.Errorf("download document %d: %w", doc.ID, err)
|
|
}
|
|
}
|
|
if int64(len(data)) != doc.Size {
|
|
return documentManifest{}, fmt.Errorf("document %d size mismatch: got %d want %d", doc.ID, len(data), doc.Size)
|
|
}
|
|
artifact, err := writeArtifact(root, rel, "document", "", data)
|
|
if err != nil {
|
|
return documentManifest{}, err
|
|
}
|
|
purposes := make([]string, 0, len(source.purposes))
|
|
for purpose := range source.purposes {
|
|
purposes = append(purposes, purpose)
|
|
}
|
|
sort.Strings(purposes)
|
|
dm := documentManifest{
|
|
ID: doc.ID,
|
|
Date: doc.Date,
|
|
DCID: doc.DCID,
|
|
MimeType: doc.MimeType,
|
|
ExpectedSize: doc.Size,
|
|
FileName: fileName,
|
|
StickerAlt: stickerAlt,
|
|
Purposes: purposes,
|
|
File: artifact,
|
|
}
|
|
if ext == ".tgs" || strings.EqualFold(doc.MimeType, "application/x-tgsticker") {
|
|
validator := &stargifts.Service{}
|
|
if _, err := validator.PrepareAnimation(fmt.Sprintf("%d.tgs", doc.ID), data); err != nil {
|
|
dm.ValidationError = err.Error()
|
|
} else {
|
|
dm.AnimationValidated = true
|
|
}
|
|
}
|
|
if skipThumbs {
|
|
return dm, nil
|
|
}
|
|
thumbs, missingThumbs, err := fetchThumbs(ctx, api, dl, root, doc, maxBytes, allowedMissingThumbs)
|
|
if err != nil {
|
|
return documentManifest{}, err
|
|
}
|
|
dm.Thumbs = thumbs
|
|
dm.MissingThumbs = missingThumbs
|
|
return dm, nil
|
|
}
|
|
|
|
func fetchThumbs(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, doc *tg.Document, maxBytes int64, allowedMissingThumbs map[string]struct{}) ([]fileArtifact, []missingThumb, error) {
|
|
seen := make(map[string]struct{})
|
|
artifacts := make([]fileArtifact, 0, len(doc.Thumbs)+len(doc.VideoThumbs))
|
|
missing := make([]missingThumb, 0)
|
|
for _, class := range doc.Thumbs {
|
|
thumbType := class.GetType()
|
|
if thumbType == "" {
|
|
continue
|
|
}
|
|
key := "photo:" + thumbType
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
var data []byte
|
|
var err error
|
|
var expectedSize int64
|
|
downloadAttempted := false
|
|
switch value := class.(type) {
|
|
case *tg.PhotoCachedSize:
|
|
data = append([]byte(nil), value.Bytes...)
|
|
case *tg.PhotoStrippedSize:
|
|
data = append([]byte(nil), value.Bytes...)
|
|
case *tg.PhotoPathSize:
|
|
data = append([]byte(nil), value.Bytes...)
|
|
case *tg.PhotoSize:
|
|
expectedSize = int64(value.Size)
|
|
if expectedSize <= 0 || expectedSize > maxBytes {
|
|
return nil, nil, fmt.Errorf("document %d photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes)
|
|
}
|
|
rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType)))
|
|
var reused bool
|
|
data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes)
|
|
if err == nil && !reused {
|
|
downloadAttempted = true
|
|
fmt.Printf("[network fetch] document=%d resource=photo-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize))
|
|
data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes)
|
|
}
|
|
case *tg.PhotoSizeProgressive:
|
|
if len(value.Sizes) == 0 {
|
|
return nil, nil, fmt.Errorf("document %d progressive photo thumb %q has no sizes", doc.ID, thumbType)
|
|
}
|
|
expectedSize = int64(value.Sizes[len(value.Sizes)-1])
|
|
if expectedSize <= 0 || expectedSize > maxBytes {
|
|
return nil, nil, fmt.Errorf("document %d progressive photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes)
|
|
}
|
|
rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType)))
|
|
var reused bool
|
|
data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes)
|
|
if err == nil && !reused {
|
|
downloadAttempted = true
|
|
fmt.Printf("[network fetch] document=%d resource=photo-thumb-progressive type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize))
|
|
data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes)
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "photo", thumbType) {
|
|
missing = append(missing, missingThumb{Kind: "photo", Type: thumbType, ExpectedSize: expectedSize, Error: err.Error()})
|
|
fmt.Printf("[missing thumb] document=%d kind=photo type=%s expected_size=%d error=%v\n", doc.ID, thumbType, expectedSize, err)
|
|
continue
|
|
}
|
|
return nil, nil, fmt.Errorf("download document %d photo thumb %q: %w", doc.ID, thumbType, err)
|
|
}
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
if expectedSize > 0 && int64(len(data)) != expectedSize {
|
|
return nil, nil, fmt.Errorf("document %d photo thumb %q size mismatch: got %d want %d", doc.ID, thumbType, len(data), expectedSize)
|
|
}
|
|
rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType)))
|
|
artifact, err := writeArtifact(root, rel, "photo", thumbType, data)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
artifacts = append(artifacts, artifact)
|
|
}
|
|
for _, class := range doc.VideoThumbs {
|
|
value, ok := class.(*tg.VideoSize)
|
|
if !ok || value.Type == "" {
|
|
continue
|
|
}
|
|
key := "video:" + value.Type
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
rel := filepath.Join("thumbs", fmt.Sprintf("%d-video-%s.bin", doc.ID, safePart(value.Type)))
|
|
data, reused, err := existingArtifact(root, rel, int64(value.Size), maxBytes)
|
|
downloadAttempted := false
|
|
if err == nil && !reused {
|
|
downloadAttempted = true
|
|
fmt.Printf("[network fetch] document=%d resource=video-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, value.Type, value.Size, downloadPartSize(int64(value.Size)))
|
|
data, err = download(ctx, api, dl, doc, value.Type, int64(value.Size), maxBytes)
|
|
}
|
|
if err != nil {
|
|
if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "video", value.Type) {
|
|
missing = append(missing, missingThumb{Kind: "video", Type: value.Type, ExpectedSize: int64(value.Size), Error: err.Error()})
|
|
fmt.Printf("[missing thumb] document=%d kind=video type=%s expected_size=%d error=%v\n", doc.ID, value.Type, value.Size, err)
|
|
continue
|
|
}
|
|
return nil, nil, fmt.Errorf("download document %d video thumb %q: %w", doc.ID, value.Type, err)
|
|
}
|
|
artifact, err := writeArtifact(root, rel, "video", value.Type, data)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
artifacts = append(artifacts, artifact)
|
|
}
|
|
sort.Slice(artifacts, func(i, j int) bool {
|
|
if artifacts[i].Kind != artifacts[j].Kind {
|
|
return artifacts[i].Kind < artifacts[j].Kind
|
|
}
|
|
return artifacts[i].Type < artifacts[j].Type
|
|
})
|
|
sort.Slice(missing, func(i, j int) bool {
|
|
if missing[i].Kind != missing[j].Kind {
|
|
return missing[i].Kind < missing[j].Kind
|
|
}
|
|
return missing[i].Type < missing[j].Type
|
|
})
|
|
return artifacts, missing, nil
|
|
}
|
|
|
|
func download(ctx context.Context, api *tg.Client, dl *downloader.Downloader, doc *tg.Document, thumbType string, expectedSize, maxBytes int64) ([]byte, error) {
|
|
location := &tg.InputDocumentFileLocation{
|
|
ID: doc.ID,
|
|
AccessHash: doc.AccessHash,
|
|
FileReference: doc.FileReference,
|
|
ThumbSize: thumbType,
|
|
}
|
|
partSize := downloadPartSize(expectedSize)
|
|
if expectedSize > 0 && expectedSize < int64(partSize) {
|
|
// TDesktop and DrKLO issue ordinary non-precise upload.getFile requests
|
|
// for regular file chunks. A known-size resource that fits in one valid
|
|
// chunk needs neither gotd's precise mode nor an EOF probe.
|
|
result, err := api.UploadGetFile(ctx, &tg.UploadGetFileRequest{
|
|
Location: location,
|
|
Offset: 0,
|
|
Limit: partSize,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file, ok := result.(*tg.UploadFile)
|
|
if !ok {
|
|
return nil, fmt.Errorf("single-chunk upload.getFile returned %T", result)
|
|
}
|
|
if int64(len(file.Bytes)) > maxBytes {
|
|
return nil, fmt.Errorf("download exceeds %d bytes", maxBytes)
|
|
}
|
|
return append([]byte(nil), file.Bytes...), nil
|
|
}
|
|
buffer := &boundedBuffer{max: maxBytes}
|
|
if _, err := dl.WithPartSize(partSize).Download(api, location).Stream(ctx, buffer); err != nil {
|
|
return nil, err
|
|
}
|
|
return append([]byte(nil), buffer.Bytes()...), nil
|
|
}
|
|
|
|
func downloadPartSize(expectedSize int64) int {
|
|
const (
|
|
unit = int64(4 << 10)
|
|
max = int64(512 << 10)
|
|
)
|
|
if expectedSize <= 0 || expectedSize >= max {
|
|
return int(max)
|
|
}
|
|
// Choose a valid 4 KiB-aligned limit strictly larger than the file whenever
|
|
// possible, so downloader.Stream recognizes the first short chunk as final
|
|
// without an extra EOF probe.
|
|
partSize := ((expectedSize + 1 + unit - 1) / unit) * unit
|
|
if partSize > max {
|
|
partSize = max
|
|
}
|
|
return int(partSize)
|
|
}
|
|
|
|
func existingArtifact(root, relative string, expectedSize, maxBytes int64) ([]byte, bool, error) {
|
|
data, err := os.ReadFile(filepath.Join(root, relative))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("read existing artifact %q: %w", relative, err)
|
|
}
|
|
size := int64(len(data))
|
|
if size <= 0 || size > maxBytes || expectedSize >= 0 && size != expectedSize {
|
|
return nil, false, nil
|
|
}
|
|
return data, true, nil
|
|
}
|
|
|
|
type boundedBuffer struct {
|
|
bytes.Buffer
|
|
max int64
|
|
}
|
|
|
|
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
|
remaining := b.max - int64(b.Len())
|
|
if remaining <= 0 {
|
|
return 0, fmt.Errorf("download exceeds %d bytes", b.max)
|
|
}
|
|
if int64(len(p)) > remaining {
|
|
written, _ := b.Buffer.Write(p[:remaining])
|
|
return written, fmt.Errorf("download exceeds %d bytes", b.max)
|
|
}
|
|
return b.Buffer.Write(p)
|
|
}
|
|
|
|
func hasRenderableStickerAttribute(doc *tg.Document) bool {
|
|
for _, attribute := range doc.Attributes {
|
|
switch attribute.(type) {
|
|
case *tg.DocumentAttributeSticker, *tg.DocumentAttributeCustomEmoji:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func documentNames(doc *tg.Document) (fileName, alt string) {
|
|
for _, attribute := range doc.Attributes {
|
|
switch value := attribute.(type) {
|
|
case *tg.DocumentAttributeFilename:
|
|
fileName = filepath.Base(value.FileName)
|
|
case *tg.DocumentAttributeSticker:
|
|
alt = value.Alt
|
|
case *tg.DocumentAttributeCustomEmoji:
|
|
alt = value.Alt
|
|
}
|
|
}
|
|
return fileName, alt
|
|
}
|
|
|
|
func documentExtension(fileName, mimeType string) string {
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
switch ext {
|
|
case ".tgs", ".webm", ".mp4", ".webp", ".png", ".jpg", ".jpeg":
|
|
return ext
|
|
}
|
|
switch strings.ToLower(mimeType) {
|
|
case "application/x-tgsticker", "application/gzip":
|
|
return ".tgs"
|
|
case "video/webm":
|
|
return ".webm"
|
|
case "video/mp4":
|
|
return ".mp4"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
default:
|
|
return ".bin"
|
|
}
|
|
}
|
|
|
|
func safePart(value string) string {
|
|
var builder strings.Builder
|
|
for _, r := range value {
|
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
|
builder.WriteRune(r)
|
|
}
|
|
}
|
|
if builder.Len() == 0 {
|
|
return "unknown"
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func parseAllowedMissingThumbs(raw string) (map[string]struct{}, error) {
|
|
allowed := make(map[string]struct{})
|
|
for _, entry := range strings.Split(raw, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(entry, ":")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid allow-missing-thumb %q: want document_id:photo|video:type", entry)
|
|
}
|
|
documentID, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil || documentID <= 0 {
|
|
return nil, fmt.Errorf("invalid allow-missing-thumb document ID %q", parts[0])
|
|
}
|
|
kind := parts[1]
|
|
if kind != "photo" && kind != "video" {
|
|
return nil, fmt.Errorf("invalid allow-missing-thumb kind %q", kind)
|
|
}
|
|
thumbType := parts[2]
|
|
if thumbType == "" || safePart(thumbType) != thumbType {
|
|
return nil, fmt.Errorf("invalid allow-missing-thumb type %q", thumbType)
|
|
}
|
|
allowed[missingThumbKey(documentID, kind, thumbType)] = struct{}{}
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
func missingThumbAllowed(allowed map[string]struct{}, documentID int64, kind, thumbType string) bool {
|
|
_, ok := allowed[missingThumbKey(documentID, kind, thumbType)]
|
|
return ok
|
|
}
|
|
|
|
func missingThumbKey(documentID int64, kind, thumbType string) string {
|
|
return fmt.Sprintf("%d:%s:%s", documentID, kind, thumbType)
|
|
}
|
|
|
|
func writeArtifact(root, relative, kind, artifactType string, data []byte) (fileArtifact, error) {
|
|
path := filepath.Join(root, relative)
|
|
if err := writeFileAtomic(path, data); err != nil {
|
|
return fileArtifact{}, err
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return fileArtifact{
|
|
Kind: kind,
|
|
Type: artifactType,
|
|
Path: filepath.ToSlash(relative),
|
|
Size: int64(len(data)),
|
|
SHA256: hex.EncodeToString(sum[:]),
|
|
}, nil
|
|
}
|
|
|
|
func readTLArtifact(root, relative string, target interface{ Decode(*bin.Buffer) error }) (fileArtifact, error) {
|
|
data, err := os.ReadFile(filepath.Join(root, relative))
|
|
if err != nil {
|
|
return fileArtifact{}, err
|
|
}
|
|
if len(data) == 0 {
|
|
return fileArtifact{}, fmt.Errorf("TL artifact %q is empty", relative)
|
|
}
|
|
buffer := &bin.Buffer{Buf: data}
|
|
if err := target.Decode(buffer); err != nil {
|
|
return fileArtifact{}, fmt.Errorf("decode TL artifact %q: %w", relative, err)
|
|
}
|
|
if len(buffer.Buf) != 0 {
|
|
return fileArtifact{}, fmt.Errorf("TL artifact %q has %d trailing bytes", relative, len(buffer.Buf))
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return fileArtifact{
|
|
Kind: "tl",
|
|
Path: filepath.ToSlash(relative),
|
|
Size: int64(len(data)),
|
|
SHA256: hex.EncodeToString(sum[:]),
|
|
}, nil
|
|
}
|
|
|
|
func writeFileAtomic(path string, data []byte) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".giftfetch-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if err := tmp.Chmod(0o644); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return fmt.Errorf("replace %q: %w (remove existing: %v)", path, err, removeErr)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|