Files
t5de-proxy/internal/products/products.go
T
dhkatzandClaude Opus 5.5 0be2cd8f4e
CI / build (push) Successful in 44s
T5 furniture and poses in IMVU rooms (decorations)
Community products are taken out of rooms their owners save and broadcast, kept on the hub per room,
and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor
notice resends the room with the new ones. Placements are stored with the hub's product ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:03:21 -07:00

424 lines
14 KiB
Go

// 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"
"context"
"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"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// 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)
// Shop, if set, searches the hub's products for the shop's T5 filter; Fetch gets one into the hub's products
// now; AddToLibrary adds one to the person's library (buying it in the shop).
Shop func(ctx context.Context, query string) ([]hub.Product, error)
Fetch func(ctx context.Context, id string) error
AddToLibrary func(ctx context.Context, id string) error
// Library, if set, is the person's hub library as last read (for the inventory's Recently Added).
Library func() []hub.LibraryEntry
// Decorations and SaveDecorations, if set, get and store an IMVU room's decorations on the hub (decor.go).
Decorations func(ctx context.Context, instance string) ([]DecorItem, error)
SaveDecorations func(ctx context.Context, instance string, items []DecorItem) error
decor decorations
// Hosted, if set, serves T5DE-hosted rooms' queues (the hosted package); HostedOwner is the IMVU account owning
// the hosted room the client is in (0 if none), whose products the room's are.
Hosted HostedRooms
HostedOwner func() int64
roomMu sync.Mutex
room room
user string // the signed-in IMVU account (from the login answer), under mu
shopSeen map[string]hub.Product // hub products as the shop last listed them, 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)
}
}
// HostedOwnerID is the user ID the client gives hosted rooms' owner: the first part of their instance IDs
// (hosted.Base).
const HostedOwnerID = 2100000000
// HostedRooms serves T5DE-hosted rooms' IMQ queues: Route answers subscriptions to them, Send takes the client's
// messages to them (reporting whether the queue was one), Closed forgets a connection.
type HostedRooms interface {
Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription
Send(queue, mount string, message []byte) bool
Closed(c *proxy.IMQConn)
}
// Route serves hosted rooms' queues (proxy.IMQRouter).
func (p *Products) Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription {
if p.Hosted == nil {
return s
}
return p.Hosted.Route(c, unsubscribe, s)
}
// 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)<ProductName>.*?</ProductName>`)
// 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("<ProductName>" + esc.String() + "</ProductName>")
if productNamePattern.Match(index) {
return productNamePattern.ReplaceAllLiteral(index, repl)
}
return index
}
func xmlEscape(b *bytes.Buffer, s string) {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
b.WriteString(r.Replace(s))
}
// productdata matches IMVU's product file addresses: /productdata/<id>/<revision>[/<file>].
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)
}
// LocalID is the client's local ID for a product (a hub product ID or a local pack's), if it's here.
func (p *Products) LocalID(id string) (int64, bool) {
if s := p.byProductID(id); s != nil {
return s.local, true
}
return 0, false
}
// ProductID is the product a local ID was given to.
func (p *Products) ProductID(local int64) (string, bool) { return p.state.productID(local) }
// User is the IMVU account the client signed in as (0 before it has).
func (p *Products) User() int64 {
p.mu.Lock()
defer p.mu.Unlock()
id, _ := strconv.ParseInt(p.user, 10, 64)
return id
}