Manage T5DE rooms from the client; the AP+ badge on avatar cards
CI / build (push) Successful in 28s
CI / build (push) Successful in 28s
Chat Rooms > Manage (the t5de-room-manage patch) lists the person's T5DE rooms (room_management_info.php?t5de=1) and saves them from IMVU's room dialog (room_management.php: create with t5de=1, edit and delete naming a hosted room) as hub calls: the room, its moderators by name, and a picture from the person's IMVU gallery, uploaded to the hub. The hub's refusals show in the dialog (hub.Error keeps their message). Avatar cards get show_ap_plus again, which IMVU stopped sending, from the person's public IMVU profile (is_ap_plus). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6aac769246
commit
8daf91ede3
7 files changed
+608
-2
No files matched your search
@@ -0,0 +1,112 @@
|
||||
// Package cards fixes what IMVU's servers no longer send the client's avatar cards: the AP+ badge. The card shows it
|
||||
// when avatarcard.php's answer has show_ap_plus, which IMVU stopped sending; whether someone has AP+ is public on
|
||||
// their IMVU profile (api.imvu.com/user/user-<id>, is_ap_plus), as the friends list and Find People still show.
|
||||
package cards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// keep is how long an account's AP+ is remembered.
|
||||
const keep = 10 * time.Minute
|
||||
|
||||
// Cards adds show_ap_plus to avatar cards of people with AP+.
|
||||
type Cards struct {
|
||||
// Client fetches IMVU's public profiles (nil: http.DefaultClient).
|
||||
Client *http.Client
|
||||
// Base is IMVU's API ("" for https://api.imvu.com).
|
||||
Base string
|
||||
|
||||
mu sync.Mutex
|
||||
known map[int64]apPlus
|
||||
}
|
||||
|
||||
type apPlus struct {
|
||||
has bool
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *Cards) Intercept(x *proxy.Exchange) error {
|
||||
if !strings.EqualFold(x.URL.Hostname(), "client-dynamic.imvu.com") || x.URL.Path != "/api/avatarcard.php" {
|
||||
return nil
|
||||
}
|
||||
cid, err := strconv.ParseInt(x.URL.Query().Get("cid"), 10, 64)
|
||||
if err != nil || cid <= 0 {
|
||||
return nil
|
||||
}
|
||||
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||
var card map[string]json.RawMessage
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &card) != nil || set(card["show_ap_plus"]) {
|
||||
return body, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if !c.has(ctx, cid) {
|
||||
return body, nil
|
||||
}
|
||||
card["show_ap_plus"] = json.RawMessage("1")
|
||||
return json.Marshal(card)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// set reports whether a JSON value counts as set in the card's JavaScript (truthy).
|
||||
func set(v json.RawMessage) bool {
|
||||
s := strings.TrimSpace(string(v))
|
||||
return s != "" && s != "null" && s != "false" && s != "0" && s != `""` && s != `"0"`
|
||||
}
|
||||
|
||||
// has reports whether an account has AP+ (false if IMVU can't say).
|
||||
func (c *Cards) has(ctx context.Context, id int64) bool {
|
||||
c.mu.Lock()
|
||||
k, ok := c.known[id]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Since(k.at) < keep {
|
||||
return k.has
|
||||
}
|
||||
base := c.Base
|
||||
if base == "" {
|
||||
base = "https://api.imvu.com"
|
||||
}
|
||||
u := fmt.Sprintf("%s/user/user-%d", base, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
client := c.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return k.has
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var profile struct {
|
||||
Denormalized map[string]struct {
|
||||
Data struct {
|
||||
IsAPPlus bool `json:"is_ap_plus"`
|
||||
} `json:"data"`
|
||||
} `json:"denormalized"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&profile) != nil {
|
||||
return k.has
|
||||
}
|
||||
has := profile.Denormalized[fmt.Sprintf("https://api.imvu.com/user/user-%d", id)].Data.IsAPPlus
|
||||
c.mu.Lock()
|
||||
if c.known == nil {
|
||||
c.known = map[int64]apPlus{}
|
||||
}
|
||||
c.known[id] = apPlus{has: has, at: time.Now()}
|
||||
c.mu.Unlock()
|
||||
return has
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cards
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
func TestAPPlusBadge(t *testing.T) {
|
||||
asked := 0
|
||||
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
asked++
|
||||
id := strings.TrimPrefix(r.URL.Path, "/user/user-")
|
||||
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-%s": {"data": {"is_ap_plus": %v}}}}`, id, id == "5")
|
||||
}))
|
||||
defer imvu.Close()
|
||||
c := &Cards{Base: imvu.URL}
|
||||
card := func(cid, body string) string {
|
||||
t.Helper()
|
||||
u, _ := url.Parse("https://client-dynamic.imvu.com/api/avatarcard.php?cid=" + cid + "&viewer_cid=1")
|
||||
x := &proxy.Exchange{URL: u}
|
||||
if err := c.Intercept(x); err != nil || x.ModifyResponse == nil {
|
||||
t.Fatalf("not intercepted: %v", err)
|
||||
}
|
||||
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
if out := card("5", `{"avatarname": "Ann", "show_ap": 1}`); !strings.Contains(out, `"show_ap_plus":1`) || !strings.Contains(out, `"avatarname":"Ann"`) {
|
||||
t.Fatalf("AP+ card %s", out)
|
||||
}
|
||||
if out := card("6", `{"avatarname": "Bo"}`); strings.Contains(out, "show_ap_plus") {
|
||||
t.Fatalf("no AP+ %s", out)
|
||||
}
|
||||
card("5", `{}`)
|
||||
if asked != 2 {
|
||||
t.Fatalf("IMVU asked %d times (kept for a while)", asked)
|
||||
}
|
||||
// Already set by IMVU: left alone, IMVU not asked.
|
||||
if out := card("7", `{"show_ap_plus": true}`); out != `{"show_ap_plus": true}` || asked != 2 {
|
||||
t.Fatalf("set already %s (%d)", out, asked)
|
||||
}
|
||||
}
|
||||
@@ -476,8 +476,20 @@ func (r *Rooms) Intercept(x *proxy.Exchange) error {
|
||||
r.addToRoomList(x)
|
||||
return nil
|
||||
case "/api/room_management_info.php":
|
||||
if x.URL.Query().Get("t5de") == "1" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
x.Response = r.manageInfo(ctx)
|
||||
return nil
|
||||
}
|
||||
r.markManagedRooms(x)
|
||||
return nil
|
||||
case "/api/room_management.php":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if r.manage(ctx, x) {
|
||||
return nil
|
||||
}
|
||||
case "/api/rooms/room_info.php":
|
||||
if n, ok := RoomNumber(x.URL.Query().Get("room_id")); ok {
|
||||
x.Response = r.roomInfo(n)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package hosted
|
||||
|
||||
// Managing the person's own hosted rooms from the client's Chat Rooms > Manage tab, which shows them in a list of
|
||||
// their own (the t5de-rooms patch): room_management_info.php?t5de=1 lists them as IMVU lists the person's rooms, and
|
||||
// IMVU's room dialog saves them (room_management.php with t5de=1, or naming a hosted room), as calls to the hub.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// maxRoomsPerPerson is the hub's cap on a person's open rooms (t5de-hub's maxRoomsPerPerson).
|
||||
const maxRoomsPerPerson = 10
|
||||
|
||||
// uploader is a hub that takes files (hub.Client).
|
||||
type uploader interface {
|
||||
Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error
|
||||
}
|
||||
|
||||
// manageInfo answers room_management_info.php?t5de=1: the person's hosted rooms owned by the account in the client,
|
||||
// and how many more they may make.
|
||||
func (r *Rooms) manageInfo(ctx context.Context) *http.Response {
|
||||
var out struct {
|
||||
Rooms []Room `json:"rooms"`
|
||||
}
|
||||
if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms?mine=1", nil, &out); err != nil {
|
||||
r.logf("hosted: the person's rooms: %v", err)
|
||||
return manageJSON(map[string]any{"error": "T5DE's hub isn't available."})
|
||||
}
|
||||
me := r.user()
|
||||
rooms := []any{}
|
||||
for _, rm := range out.Rooms {
|
||||
if rm.OwnerImvuID == me {
|
||||
rooms = append(rooms, manageRow(rm))
|
||||
}
|
||||
}
|
||||
return manageJSON(map[string]any{"result": map[string]any{"rooms": rooms, "limit": maxRoomsPerPerson - len(out.Rooms) + len(rooms)}})
|
||||
}
|
||||
|
||||
// manageRow is a hosted room as room_management_info.php lists the person's rooms (and the room dialog edits them).
|
||||
func manageRow(rm Room) map[string]any {
|
||||
adult, open := 0, 2 // 2: public, as IMVU's "Public"
|
||||
if rm.Rating == "adult" {
|
||||
adult = 1
|
||||
}
|
||||
if rm.Privacy == "private" {
|
||||
open = 0
|
||||
}
|
||||
var mods []string
|
||||
for _, id := range rm.Moderators {
|
||||
mods = append(mods, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return map[string]any{
|
||||
"name": plain(rm.Name), "description": plain(rm.Description), "customers_id": strconv.FormatInt(rm.OwnerImvuID, 10),
|
||||
"customers_room_id": strconv.FormatInt(Base+rm.N, 10), "room_instance_id": instanceID(rm.OwnerImvuID, rm.N),
|
||||
"image_url": rm.Image, "num_participants": rm.Occupancy, "max_users": rm.MaxUsers, "room_pid": rm.RoomPid,
|
||||
"is_ap": adult, "is_vip": 0, "is_age_verified_only": 0, "is_non_guest_only": 0, "is_friends_only": 0,
|
||||
"is_favorite": boolNumber(rm.Favorite), "language": "", "open": open, "lieutenants": strings.Join(mods, ", "),
|
||||
"t5de_t5": 1, "t5de_hosted": 1,
|
||||
}
|
||||
}
|
||||
|
||||
// manage answers room_management.php for hosted rooms: action create (with t5de=1), edit or delete (naming one).
|
||||
func (r *Rooms) manage(ctx context.Context, x *proxy.Exchange) bool {
|
||||
form := params(x)
|
||||
n, hosted := RoomNumber(form.Get("room_instance_id"))
|
||||
action := form.Get("action")
|
||||
if !hosted && !(action == "create" && form.Get("t5de") == "1") {
|
||||
return false
|
||||
}
|
||||
if action == "delete" {
|
||||
if err := r.Hub.Call(ctx, "DELETE", fmt.Sprintf("/api/v1/hosted-rooms/%d", n), nil, nil); err != nil {
|
||||
x.Response = manageErrors(err)
|
||||
return true
|
||||
}
|
||||
x.Response = manageJSON(map[string]any{"result": true})
|
||||
return true
|
||||
}
|
||||
if action != "create" && action != "edit" {
|
||||
x.Response = manageJSON(map[string]any{"errors": []any{map[string]string{"0": "T5DE rooms can't do that."}}})
|
||||
return true
|
||||
}
|
||||
|
||||
room := map[string]any{"name": strings.TrimSpace(form.Get("room_name")), "description": form.Get("room_description")}
|
||||
if pid, err := strconv.ParseInt(form.Get("room_pid"), 10, 64); err == nil && pid > 0 && action == "create" {
|
||||
room["roomPid"] = pid // (the dialog doesn't show the room when editing)
|
||||
}
|
||||
if max, err := strconv.Atoi(form.Get("max_users")); err == nil && max > 0 {
|
||||
room["maxUsers"] = max
|
||||
}
|
||||
room["rating"] = "general"
|
||||
if ap := form.Get("ap"); ap != "" && ap != "0" {
|
||||
room["rating"] = "adult"
|
||||
}
|
||||
room["privacy"] = "public"
|
||||
if form.Get("permissions") == "0" {
|
||||
room["privacy"] = "private"
|
||||
}
|
||||
var answer struct {
|
||||
Room Room `json:"room"`
|
||||
}
|
||||
if action == "create" {
|
||||
if room["roomPid"] == nil {
|
||||
x.Response = manageErrors(errors.New("Choose a room."))
|
||||
return true
|
||||
}
|
||||
room["imvuId"] = r.user()
|
||||
if err := r.Hub.Call(ctx, "POST", "/api/v1/hosted-rooms", room, &answer); err != nil {
|
||||
x.Response = manageErrors(err)
|
||||
return true
|
||||
}
|
||||
n = answer.Room.N
|
||||
}
|
||||
// Moderators by name or ID ("a, b"), set after creating (the hub looks names up).
|
||||
mods := []string{}
|
||||
for _, m := range strings.FieldsFunc(form.Get("lieutenants"), func(c rune) bool { return c == ',' || c == ';' || c == '\n' }) {
|
||||
if m = strings.TrimSpace(m); m != "" {
|
||||
mods = append(mods, m)
|
||||
}
|
||||
}
|
||||
change := room
|
||||
if action == "create" {
|
||||
change = map[string]any{}
|
||||
}
|
||||
if action == "edit" || len(mods) > 0 {
|
||||
change["moderators"] = mods
|
||||
}
|
||||
if len(change) > 0 {
|
||||
if err := r.Hub.Call(ctx, "PATCH", fmt.Sprintf("/api/v1/hosted-rooms/%d", n), change, &answer); err != nil {
|
||||
x.Response = manageErrors(err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if err := r.setImage(ctx, n, answer.Room.Image, form.Get("image"), &answer.Room); err != nil {
|
||||
x.Response = manageErrors(err)
|
||||
return true
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.owners == nil {
|
||||
r.owners = map[int64]int64{}
|
||||
}
|
||||
r.owners[n] = answer.Room.OwnerImvuID
|
||||
r.mu.Unlock()
|
||||
x.Response = manageJSON(map[string]any{"result": manageRow(answer.Room)})
|
||||
return true
|
||||
}
|
||||
|
||||
// setImage gives a room the image chosen in the dialog (a picture from the person's IMVU gallery, uploaded to the hub),
|
||||
// or takes it away ("" when it had one). The hub's own image URL means unchanged.
|
||||
func (r *Rooms) setImage(ctx context.Context, n int64, current, chosen string, into *Room) error {
|
||||
if chosen == current || (chosen != "" && current != "" && strings.SplitN(chosen, "?", 2)[0] == strings.SplitN(current, "?", 2)[0]) {
|
||||
return nil
|
||||
}
|
||||
var answer struct {
|
||||
Room Room `json:"room"`
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/hosted-rooms/%d/image", n)
|
||||
if chosen == "" {
|
||||
if current == "" {
|
||||
return nil
|
||||
}
|
||||
if err := r.Hub.Call(ctx, "DELETE", path, nil, &answer); err != nil {
|
||||
return err
|
||||
}
|
||||
*into = answer.Room
|
||||
return nil
|
||||
}
|
||||
up, ok := r.Hub.(uploader)
|
||||
if !ok {
|
||||
return errors.New("T5DE's hub can't take images here.")
|
||||
}
|
||||
u, err := url.Parse(chosen)
|
||||
if err != nil || !(u.Scheme == "http" || u.Scheme == "https") || !strings.HasSuffix(strings.ToLower(u.Hostname()), ".imvu.com") {
|
||||
return errors.New("Choose a picture from your gallery.")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := r.client().Do(req)
|
||||
if err != nil {
|
||||
return errors.New("Couldn't get that picture from IMVU.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<10+1))
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return errors.New("Couldn't get that picture from IMVU.")
|
||||
}
|
||||
if len(data) > 512<<10 {
|
||||
return errors.New("That picture is too big for a T5DE room (512 KB at most).")
|
||||
}
|
||||
if err := up.Upload(ctx, "PUT", path, "image", "room", http.DetectContentType(data), data, &answer); err != nil {
|
||||
return err
|
||||
}
|
||||
*into = answer.Room
|
||||
return nil
|
||||
}
|
||||
|
||||
func manageJSON(v any) *http.Response {
|
||||
b, _ := json.Marshal(v)
|
||||
return proxy.LocalResponse(200, "application/json", b)
|
||||
}
|
||||
|
||||
// manageErrors is a failed save as the room dialog shows it.
|
||||
func manageErrors(err error) *http.Response {
|
||||
msg := err.Error()
|
||||
var he *hub.Error
|
||||
if errors.As(err, &he) {
|
||||
msg = he.Message
|
||||
if msg == "" {
|
||||
msg = "T5DE's hub didn't take that. Check the room's details."
|
||||
}
|
||||
} else if !strings.HasSuffix(msg, ".") {
|
||||
msg = "T5DE's hub isn't available."
|
||||
}
|
||||
return manageJSON(map[string]any{"errors": []any{map[string]string{"0": msg}}})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package hosted
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// manageHub is a hub for the Manage tab: the person's rooms, and what was asked of it.
|
||||
type manageHub struct {
|
||||
calls []string
|
||||
bodies []map[string]any
|
||||
uploads [][]byte
|
||||
}
|
||||
|
||||
func (h *manageHub) Send(v any) bool { return true }
|
||||
|
||||
func (h *manageHub) Call(ctx context.Context, method, path string, body, out any) error {
|
||||
h.calls = append(h.calls, method+" "+path)
|
||||
b, _ := json.Marshal(body)
|
||||
var m map[string]any
|
||||
json.Unmarshal(b, &m)
|
||||
h.bodies = append(h.bodies, m)
|
||||
room := `{"room": {"n": 4, "name": "Den", "ownerImvuId": 7, "roomPid": 555, "rating": "general", "maxUsers": 6,
|
||||
"privacy": "private", "moderators": [88], "image": "http://hub/api/v1/hosted-rooms/4/image?v=1"}}`
|
||||
switch {
|
||||
case method == "GET" && path == "/api/v1/hosted-rooms?mine=1":
|
||||
return json.Unmarshal([]byte(`{"rooms": [{"n": 4, "name": "Den", "ownerImvuId": 7, "maxUsers": 6, "privacy": "private", "moderators": [88]},
|
||||
{"n": 5, "name": "Other account's", "ownerImvuId": 99, "maxUsers": 10}]}`), out)
|
||||
case method == "POST" && path == "/api/v1/hosted-rooms":
|
||||
if m["name"] == "" {
|
||||
return &hub.Error{Status: 400, Code: "invalid", Message: "A room needs a name."}
|
||||
}
|
||||
return json.Unmarshal([]byte(room), out)
|
||||
case (method == "PATCH" || method == "DELETE") && strings.HasPrefix(path, "/api/v1/hosted-rooms/4"):
|
||||
if out != nil {
|
||||
return json.Unmarshal([]byte(room), out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unexpected %s %s", method, path)
|
||||
}
|
||||
|
||||
func (h *manageHub) Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error {
|
||||
h.calls = append(h.calls, "UPLOAD "+path)
|
||||
h.uploads = append(h.uploads, file)
|
||||
return json.Unmarshal([]byte(`{"room": {"n": 4, "name": "Den", "ownerImvuId": 7, "image": "http://hub/api/v1/hosted-rooms/4/image?v=2"}}`), out)
|
||||
}
|
||||
|
||||
func manageCall(t *testing.T, r *Rooms, path string, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
u, _ := url.Parse("https://client-dynamic.imvu.com" + path)
|
||||
var body []byte
|
||||
method := "GET"
|
||||
if form != nil {
|
||||
method = "POST"
|
||||
m := map[string]string{}
|
||||
for k := range form {
|
||||
m[k] = form.Get(k)
|
||||
}
|
||||
body, _ = json.Marshal(m) // as imvu.http.securePost sends it
|
||||
}
|
||||
req, _ := http.NewRequest(method, u.String(), bytes.NewReader(body))
|
||||
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||
r.Intercept(x)
|
||||
if x.Response == nil {
|
||||
t.Fatalf("%s left to IMVU", path)
|
||||
}
|
||||
b, _ := io.ReadAll(x.Response.Body)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("%s: %s", path, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestManage(t *testing.T) {
|
||||
h := &manageHub{}
|
||||
gallery := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte("\x89PNG\r\n\x1a\n picture"))
|
||||
}))
|
||||
defer gallery.Close()
|
||||
r := &Rooms{Hub: h, User: func() int64 { return 7 }, HTTPClient: &http.Client{Transport: rewrite{gallery.URL}}}
|
||||
|
||||
// The list: this account's rooms, as IMVU lists the person's; the cap counts the person's other rooms.
|
||||
info := manageCall(t, r, "/api/room_management_info.php?t5de=1", nil)
|
||||
res := info["result"].(map[string]any)
|
||||
rooms := res["rooms"].([]any)
|
||||
if len(rooms) != 1 || res["limit"] != float64(9) {
|
||||
t.Fatalf("info %v", info)
|
||||
}
|
||||
row := rooms[0].(map[string]any)
|
||||
if row["room_instance_id"] != "7-2100000004" || row["open"] != float64(0) || row["lieutenants"] != "88" || row["t5de_t5"] != float64(1) {
|
||||
t.Fatalf("row %v", row)
|
||||
}
|
||||
|
||||
// Creating: the room, then its moderators, then its picture from the gallery.
|
||||
created := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"create"}, "t5de": {"1"}, "room_name": {"Den"},
|
||||
"room_pid": {"555"}, "max_users": {"6"}, "permissions": {"0"}, "ap": {"0"}, "lieutenants": {"Erin, 99"},
|
||||
"image": {"http://userimages.imvu.com/userdata/p.png"}})
|
||||
if created["result"].(map[string]any)["room_instance_id"] != "7-2100000004" {
|
||||
t.Fatalf("created %v", created)
|
||||
}
|
||||
if fmt.Sprint(h.calls) != "[GET /api/v1/hosted-rooms?mine=1 POST /api/v1/hosted-rooms PATCH /api/v1/hosted-rooms/4 UPLOAD /api/v1/hosted-rooms/4/image]" {
|
||||
t.Fatalf("calls %v", h.calls)
|
||||
}
|
||||
if b := h.bodies[1]; b["imvuId"] != float64(7) || b["privacy"] != "private" || b["roomPid"] != float64(555) || b["rating"] != "general" {
|
||||
t.Fatalf("create body %v", b)
|
||||
}
|
||||
if fmt.Sprint(h.bodies[2]["moderators"]) != "[Erin 99]" || len(h.uploads) != 1 {
|
||||
t.Fatalf("moderators %v, uploads %d", h.bodies[2], len(h.uploads))
|
||||
}
|
||||
|
||||
// Editing with the hub's own picture keeps it; a gallery picture elsewhere than IMVU is refused.
|
||||
h.calls = nil
|
||||
manageCall(t, r, "/api/room_management.php", url.Values{"action": {"edit"}, "room_instance_id": {"7-2100000004"}, "room_name": {"Den 2"},
|
||||
"room_pid": {"777"}, "permissions": {"2"}, "image": {"http://hub/api/v1/hosted-rooms/4/image"}})
|
||||
if fmt.Sprint(h.calls) != "[PATCH /api/v1/hosted-rooms/4]" || h.bodies[len(h.bodies)-1]["privacy"] != "public" || h.bodies[len(h.bodies)-1]["roomPid"] != nil {
|
||||
t.Fatalf("edit calls %v %v", h.calls, h.bodies[len(h.bodies)-1])
|
||||
}
|
||||
bad := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"edit"}, "room_instance_id": {"7-2100000004"},
|
||||
"room_name": {"Den"}, "image": {"https://evil.example/p.png"}})
|
||||
if !strings.Contains(fmt.Sprint(bad["errors"]), "gallery") {
|
||||
t.Fatalf("bad image %v", bad)
|
||||
}
|
||||
|
||||
// The hub's refusals are shown in the dialog.
|
||||
refused := manageCall(t, r, "/api/room_management.php", url.Values{"action": {"create"}, "t5de": {"1"}, "room_name": {""}, "room_pid": {"555"}})
|
||||
if !strings.Contains(fmt.Sprint(refused["errors"]), "A room needs a name.") {
|
||||
t.Fatalf("refused %v", refused)
|
||||
}
|
||||
|
||||
// Deleting.
|
||||
h.calls = nil
|
||||
manageCall(t, r, "/api/room_management.php", url.Values{"action": {"delete"}, "room_instance_id": {"7-2100000004"}})
|
||||
if fmt.Sprint(h.calls) != "[DELETE /api/v1/hosted-rooms/4]" {
|
||||
t.Fatalf("delete %v", h.calls)
|
||||
}
|
||||
|
||||
// IMVU's own rooms are IMVU's.
|
||||
u, _ := url.Parse("https://client-dynamic.imvu.com/api/room_management.php")
|
||||
body := []byte(`{"action": "edit", "room_instance_id": "7-3"}`)
|
||||
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
||||
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
||||
r.Intercept(x)
|
||||
if x.Response != nil {
|
||||
t.Fatal("an IMVU room's edit was answered here")
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -8,13 +8,16 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -491,10 +494,52 @@ func (c *Client) Call(ctx context.Context, method, path string, body, out any) e
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return answer(resp, data, out)
|
||||
}
|
||||
|
||||
// Upload sends a file to the hub as multipart form data (one field), as Call does JSON.
|
||||
func (c *Client) Upload(ctx context.Context, method, path, field, filename, contentType string, file []byte, out any) error {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename))
|
||||
h.Set("Content-Type", contentType)
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
part.Write(file)
|
||||
w.Close()
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.saved.URL+path, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.saved.Token)
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return answer(resp, data, out)
|
||||
}
|
||||
|
||||
// Error is the hub refusing a call: its status, error code and message (shown to people as is).
|
||||
type Error struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return fmt.Sprintf("the hub answered %d %s", e.Status, e.Code) }
|
||||
|
||||
func answer(resp *http.Response, data []byte, out any) error {
|
||||
if resp.StatusCode/100 != 2 {
|
||||
var e struct{ Error, Message string }
|
||||
json.Unmarshal(data, &e)
|
||||
return fmt.Errorf("the hub answered %d %s", resp.StatusCode, e.Error)
|
||||
return &Error{Status: resp.StatusCode, Code: e.Error, Message: e.Message}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user