CI / build (push) Successful in 28s
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send. It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which had been sending them to IMVU. Co-Authored-By: Claude Opus 5.5 <[email protected]>
318 lines
9.4 KiB
Go
318 lines
9.4 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
|
|
|
|
roomMu sync.Mutex
|
|
room room
|
|
}
|
|
|
|
// 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() {
|
|
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)<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)
|
|
}
|