diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index c5d380a..e58631b 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -35,6 +35,7 @@ import ( "time" "git.t5de.dev/t5de/t5de-proxy/internal/buildinfo" + "git.t5de.dev/t5de/t5de-proxy/internal/cards" "git.t5de.dev/t5de/t5de-proxy/internal/control" "git.t5de.dev/t5de/t5de-proxy/internal/fetch" "git.t5de.dev/t5de/t5de-proxy/internal/hosted" @@ -209,7 +210,7 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa } } manager.Start() - chain := proxy.Interceptors{manager, rooms} // hosted rooms' calls before the products' + chain := proxy.Interceptors{manager, rooms, &cards.Cards{}} // hosted rooms' calls before the products' if prods != nil { chain = append(chain, prods) } diff --git a/internal/cards/cards.go b/internal/cards/cards.go new file mode 100644 index 0000000..8933a15 --- /dev/null +++ b/internal/cards/cards.go @@ -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-, 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 +} diff --git a/internal/cards/cards_test.go b/internal/cards/cards_test.go new file mode 100644 index 0000000..f77b9a2 --- /dev/null +++ b/internal/cards/cards_test.go @@ -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) + } +} diff --git a/internal/hosted/hosted.go b/internal/hosted/hosted.go index 045b908..a08a5df 100644 --- a/internal/hosted/hosted.go +++ b/internal/hosted/hosted.go @@ -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) diff --git a/internal/hosted/manage.go b/internal/hosted/manage.go new file mode 100644 index 0000000..7756e18 --- /dev/null +++ b/internal/hosted/manage.go @@ -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}}}) +} diff --git a/internal/hosted/manage_test.go b/internal/hosted/manage_test.go new file mode 100644 index 0000000..21ff4b5 --- /dev/null +++ b/internal/hosted/manage_test.go @@ -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") + } +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 255825c..9005e1d 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -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