package handlers import ( "encoding/hex" "errors" "log/slog" "net/http" "github.com/mk6i/open-oscar-server/state" "github.com/mk6i/open-oscar-server/wire" ) // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints. type ExpressionsHandler struct { IconSource BuddyIconSource Logger *slog.Logger } // NewExpressionsHandler creates a new ExpressionsHandler. func NewExpressionsHandler(iconSource BuddyIconSource, logger *slog.Logger) *ExpressionsHandler { return &ExpressionsHandler{ IconSource: iconSource, Logger: logger, } } // Get handles GET /expressions/get requests for buddy icons and expressions. // // The AIM client calls this endpoint two different ways: // // - With type=buddyIcon it fetches the image itself. The buddyIcon URL // published in presence, buddylist and myInfo payloads points here, and the // client renders it directly as an source. // - With no type it asks for the user's expressions as JSON, then scans the // returned array for the entry typed bigBuddyIcon and uses its url to render // hovercards and other large views. We have only one icon per user, so it is // offered as both. func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) { ctx := r.Context() target := r.URL.Query().Get("t") if target == "" { SendError(w, http.StatusBadRequest, "missing target") return } screenName := state.NewIdentScreenName(target) switch r.URL.Query().Get("type") { case "buddyIcon", "bigBuddyIcon": h.serveIcon(w, r, screenName) return } iconURL := h.IconSource.URL(ctx, baseURLFromRequest(r), screenName) // f=redirect asks for the icon itself rather than a description of it. if r.URL.Query().Get("f") == "redirect" { if iconURL == "" { w.WriteHeader(http.StatusNotFound) return } http.Redirect(w, r, iconURL, http.StatusFound) return } expressions := []any{} if iconURL != "" { expressions = append(expressions, map[string]any{ "type": "bigBuddyIcon", "url": iconURL, }) } resp := BaseResponse{} resp.Response.StatusCode = 200 resp.Response.StatusText = "OK" resp.Response.Data = map[string]any{"expressions": expressions} SendResponse(w, r, resp, h.Logger) } // serveIcon writes a user's buddy icon image. // // A bartId names an exact image by content hash, so the endpoint serves that // image regardless of the user's current icon and lets browsers cache it // forever. Without a bartId it serves whatever the user's icon is now — which // keeps changing — so that response is not cacheable, and a user with no icon // gets the blank placeholder so the client's still renders. func (h *ExpressionsHandler) serveIcon(w http.ResponseWriter, r *http.Request, screenName state.IdentScreenName) { ctx := r.Context() var ( icon []byte err error immutable bool ) if raw := r.URL.Query().Get("bartId"); raw != "" { hash, decodeErr := hex.DecodeString(raw) if decodeErr != nil || len(hash) == 0 { http.Error(w, "invalid bartId", http.StatusBadRequest) return } icon, err = h.IconSource.ImageForHash(ctx, screenName, hash) immutable = true } else { icon, err = h.IconSource.Image(ctx, screenName) if errors.Is(err, ErrNoBuddyIcon) { // Serve the blank placeholder rather than 404 so a cleared icon still // renders something and the client stops showing the previous one. icon, err = h.IconSource.ImageForHash(ctx, screenName, wire.GetClearIconHash()) } } switch { case errors.Is(err, ErrNoBuddyIcon): // The client swaps in its own placeholder when the icon fails to load. http.Error(w, "icon not found", http.StatusNotFound) return case err != nil: h.Logger.ErrorContext(ctx, "failed to retrieve buddy icon", "screenName", screenName.String(), "err", err.Error()) http.Error(w, "internal server error", http.StatusInternalServerError) return } if immutable { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } w.Header().Set("Content-Type", http.DetectContentType(icon)) _, _ = w.Write(icon) }