// 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 and the community products it wears (the
// hub client).
Observer Observer
}
// Observer hears about the client's IMVU account and its community outfit.
type Observer interface {
Session(imvuID int64)
Outfit(imvuID int64, items []hub.Item)
}
// 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() {
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(pk.Manifest.ID)
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
}
// 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})
}
}
p.Observer.Outfit(id, items)
}