CI / build (push) Successful in 25s
The client never subscribes to its user queue; the proxy now follows every IMQ connection and the signed-in account (from the login answer), and delivers the refresh event on /user/<id>. Checked live: removing a product from the library on the hub reloads the client's inventory. Co-Authored-By: Claude Opus 5.5 <[email protected]>
364 lines
11 KiB
Go
364 lines
11 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"
|
|
"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)<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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
|
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)
|
|
}
|