// Package products serves community products to the client from local packs (t5de-api's pack format), and keeps // them away from IMVU. // // The client knows products by number, so each product gets a local ID from FirstLocalID up. For those IDs the // proxy answers the client itself: product info (test.inventoryProductBatch), permission and file addresses // (test.authorizeProducts2), the shop's product lookup, and the product's CFL. A product deriving from an IMVU // product (its index.xml imports the parent's) gets the parent's answers from IMVU, asked for the parent. Outfits // keep community products for the client but not for IMVU: they're taken out of outfit saves (base_look.php) and // outfit chat commands (*use ...), remembered per account, and added back when the client loads the outfit. package products import ( "bytes" "crypto/md5" "encoding/hex" "errors" "fmt" "io/fs" "net/http" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "git.t5de.dev/t5de/t5de-api/cfl" "git.t5de.dev/t5de/t5de-api/pack" "git.t5de.dev/t5de/t5de-proxy/internal/hub" ) // Products is the proxy's community products: an Interceptor for HTTP and an IMQFilter for chat. type Products struct { dirs []string state *state keys keys logf func(format string, args ...any) mu sync.Mutex scanned time.Time byLocal map[int64]*served byID map[string]*served problems map[string]string // pack directory -> last error logged // Observer, if set, hears which IMVU account the client signs in as, the community products it wears, and the // chats it's in (the hub client). Observer Observer // Want, if set, is asked for products someone in a room wears that aren't here (the fetcher gets hub products). Want func(id string) // HubDir is the pack root of products from the hub; of those, only ones Owned says are in the person's library // are in the inventory (the rest are there because someone in a room wears them). Local packs always are. HubDir string Owned func(id string) bool // OnHide, if set, hears that the person hid a community product in the inventory (removing it from their // library, for hub products). OnHide func(id string) roomMu sync.Mutex room room user string // the signed-in IMVU account (from the login answer), under mu } // Ready is told a product arrived (the fetcher wrote it): it's served from now on, and given to the client for // anyone in a room who wears it. func (p *Products) Ready(id string) { p.refresh(true) p.roomMu.Lock() p.room.init() var chats []string for chat, avatars := range p.room.last { for _, a := range avatars { for _, it := range a.Items { if it.ID == id { chats = append(chats, chat) } } } } p.roomMu.Unlock() for _, chat := range chats { p.roomMu.Lock() avatars := p.room.last[chat] p.roomMu.Unlock() p.Others(chat, avatars) } } // Observer hears about the client's IMVU account, its community outfit, and the chats it's in. type Observer interface { Session(imvuID int64) Outfit(imvuID int64, items []hub.Item) Join(chat, room string) Leave(chat string) } // served is a pack ready to serve. type served struct { dir string fingerprint string pack *pack.Pack local int64 cfl []byte checksum string // md5 of cfl, as IMVU's cflChecksum } // New serves the packs in dirs (each pack a subdirectory), remembering its state in dataDir. func New(dirs []string, dataDir string, logf func(format string, args ...any)) (*Products, error) { st, err := loadState(filepath.Join(dataDir, "products.json")) if err != nil { return nil, err } if logf == nil { logf = func(string, ...any) {} } p := &Products{dirs: dirs, state: st, logf: logf, byLocal: map[int64]*served{}, byID: map[string]*served{}, problems: map[string]string{}} p.refresh(true) return p, nil } // IsCommunity reports whether the client's product ID is a community product's local ID. func IsCommunity(id int64) bool { return id >= FirstLocalID } // lookup is the pack served under a local ID, after picking up changed packs. func (p *Products) lookup(local int64) *served { p.refresh(false) p.mu.Lock() defer p.mu.Unlock() return p.byLocal[local] } func (p *Products) byProductID(id string) *served { p.refresh(false) p.mu.Lock() defer p.mu.Unlock() return p.byID[id] } // refresh reloads packs that changed on disk, at most once a second unless forced. Creators editing a pack see the // new files the next time the client loads the product. func (p *Products) refresh(force bool) { p.mu.Lock() defer p.mu.Unlock() if !force && time.Since(p.scanned) < time.Second { return } p.scanned = time.Now() seen := map[string]bool{} for _, root := range p.dirs { entries, err := os.ReadDir(root) if err != nil { continue } for _, e := range entries { dir := filepath.Join(root, e.Name()) if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { // hidden: the fetcher's work in progress continue } if _, err := os.Stat(filepath.Join(dir, "t5de-product.json")); err != nil { continue } fp := fingerprint(dir) if old := p.findDirLocked(dir); old != nil && old.fingerprint == fp { seen[old.pack.Manifest.ID] = true continue } s, err := p.loadLocked(dir, fp) if err != nil { if p.problems[dir] != err.Error() { p.logf("products: %s: %v", dir, err) p.problems[dir] = err.Error() } if old := p.findDirLocked(dir); old != nil { seen[old.pack.Manifest.ID] = true // keep serving the last good one } continue } delete(p.problems, dir) if other, ok := p.byID[s.pack.Manifest.ID]; ok && other.dir != dir && seen[s.pack.Manifest.ID] { p.logf("products: %s: product %q is also in %s; ignoring this one", dir, s.pack.Manifest.ID, other.dir) continue } p.byID[s.pack.Manifest.ID] = s p.byLocal[s.local] = s seen[s.pack.Manifest.ID] = true p.logf("products: serving %q revision %d (%s) as %d", s.pack.Manifest.ID, s.pack.Manifest.Revision, s.pack.Manifest.Name, s.local) } } for id, s := range p.byID { if !seen[id] { delete(p.byID, id) delete(p.byLocal, s.local) p.logf("products: %q is gone", id) } } } func (p *Products) findDirLocked(dir string) *served { for _, s := range p.byID { if s.dir == dir { return s } } return nil } func (p *Products) loadLocked(dir, fp string) (*served, error) { pk, err := pack.Load(dir) if err != nil { return nil, err } local, err := p.state.localID(versionKey(pk)) if err != nil { return nil, err } entries := make([]cfl.Entry, 0, len(pk.Files)) for _, f := range pk.Files { data := f.Data if f.Name == "index.xml" { data = withProductName(data, pk.Manifest.Name) } entries = append(entries, cfl.Entry{Name: f.Name, Data: data}) } var buf bytes.Buffer if err := cfl.Write(&buf, entries); err != nil { return nil, err } sum := md5.Sum(buf.Bytes()) return &served{dir: dir, fingerprint: fp, pack: pk, local: local, cfl: buf.Bytes(), checksum: hex.EncodeToString(sum[:])}, nil } // versionKey names a version of a product for its local ID: the revision and the info the client shows. The client // keeps product info for good (its product info cache), so a product whose files or info changed gets a new local // ID, and with it fresh info. func versionKey(pk *pack.Pack) string { m := pk.Manifest sum := md5.Sum([]byte(strings.Join([]string{m.Name, m.Creator, m.Rating}, "|"))) return fmt.Sprintf("%s@%d.%s", m.ID, m.Revision, hex.EncodeToString(sum[:4])) } // fingerprint changes when anything in the pack directory (or its files/ directory) changes. func fingerprint(dir string) string { var b strings.Builder filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if info, err := d.Info(); err == nil { fmt.Fprintf(&b, "%s|%d|%d\n", path, info.Size(), info.ModTime().UnixNano()) } return nil }) return b.String() } var productNamePattern = regexp.MustCompile(`(?s).*?`) // withProductName puts the pack's name in index.xml, so the client shows it. func withProductName(index []byte, name string) []byte { var esc bytes.Buffer xmlEscape(&esc, name) repl := []byte("" + esc.String() + "") if productNamePattern.Match(index) { return productNamePattern.ReplaceAllLiteral(index, repl) } return index } func xmlEscape(b *bytes.Buffer, s string) { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") b.WriteString(r.Replace(s)) } // productdata matches IMVU's product file addresses: /productdata//[/]. var productdataPath = regexp.MustCompile(`^/productdata/([0-9]+)/([0-9]+)(?:/(.+))?$`) // cflURL is where the client fetches a community product's CFL: IMVU's address form, answered by the proxy. func cflURL(local int64, revision int) string { return fmt.Sprintf("http://userimages-akm.imvu.com/productdata/%d/%d", local, revision) } // serveFile answers a request for a community product's files. func (p *Products) serveFile(path string) (*http.Response, bool) { m := productdataPath.FindStringSubmatch(path) if m == nil { return nil, false } local, _ := strconv.ParseInt(m[1], 10, 64) if !IsCommunity(local) { return nil, false } s := p.lookup(local) rev, _ := strconv.Atoi(m[2]) if s == nil || rev != s.pack.Manifest.Revision { return notFound(), true } if m[3] == "" { resp := localResponse(200, "application/octet-stream", s.cfl) resp.Header.Set("Content-MD5", s.checksum) return resp, true } for _, f := range s.pack.Files { if f.Name == m[3] { return localResponse(200, "application/octet-stream", f.Data), true } } return notFound(), true } func notFound() *http.Response { return localResponse(404, "text/plain", []byte("t5de: no such community product file")) } // wornLocal is the user's community products (local IDs, still served) that go with the IMVU outfit look. func (p *Products) wornLocal(user string, look []int64) []int64 { var ids []int64 for _, id := range p.state.worn(user, look) { if s := p.byProductID(id); s != nil { ids = append(ids, s.local) } } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids } // recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs) // taken out. func (p *Products) recordWorn(user string, imvu, locals []int64) { p.reportOutfit(user, locals) var ids []string for _, local := range locals { if id, ok := p.state.productID(local); ok { ids = append(ids, id) } } if err := p.state.setWorn(user, imvu, ids); err != nil { p.logf("products: saving what %s wears: %v", user, err) } } var errNoKey = errors.New("products: can't re-sign the request (no session key yet)") // reportOutfit tells the observer the community products (local IDs) the user wears. func (p *Products) reportOutfit(user string, locals []int64) { if p.Observer == nil { return } id, err := strconv.ParseInt(user, 10, 64) if err != nil { return } items := []hub.Item{} for _, local := range locals { if s := p.lookup(local); s != nil { items = append(items, hub.Item{ID: s.pack.Manifest.ID, Name: s.pack.Manifest.Name, Adult: s.pack.Manifest.Rating == "adult"}) } } p.Observer.Outfit(id, items) }