github.com/prebid/prebid-server/v2@v2.18.0/adapters/audienceNetwork/facebook.go (about) 1 package audienceNetwork 2 3 import ( 4 "crypto/hmac" 5 "crypto/sha256" 6 "encoding/hex" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "net/http" 11 "strings" 12 13 "github.com/buger/jsonparser" 14 "github.com/prebid/openrtb/v20/openrtb2" 15 16 "github.com/prebid/prebid-server/v2/adapters" 17 "github.com/prebid/prebid-server/v2/config" 18 "github.com/prebid/prebid-server/v2/errortypes" 19 "github.com/prebid/prebid-server/v2/openrtb_ext" 20 "github.com/prebid/prebid-server/v2/util/jsonutil" 21 "github.com/prebid/prebid-server/v2/util/maputil" 22 "github.com/prebid/prebid-server/v2/util/ptrutil" 23 ) 24 25 var supportedBannerHeights = map[int64]struct{}{ 26 50: {}, 27 250: {}, 28 } 29 30 type adapter struct { 31 uri string 32 platformID string 33 appSecret string 34 } 35 36 type facebookAdMarkup struct { 37 BidID string `json:"bid_id"` 38 } 39 40 type facebookReqExt struct { 41 PlatformID string `json:"platformid"` 42 AuthID string `json:"authentication_id"` 43 } 44 45 func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { 46 if len(request.Imp) == 0 { 47 return nil, []error{&errortypes.BadInput{ 48 Message: "No impressions provided", 49 }} 50 } 51 52 if request.User == nil || request.User.BuyerUID == "" { 53 return nil, []error{&errortypes.BadInput{ 54 Message: "Missing bidder token in 'user.buyeruid'", 55 }} 56 } 57 58 if request.Site != nil { 59 return nil, []error{&errortypes.BadInput{ 60 Message: "Site impressions are not supported.", 61 }} 62 } 63 64 return a.buildRequests(request) 65 } 66 67 func (a *adapter) buildRequests(request *openrtb2.BidRequest) ([]*adapters.RequestData, []error) { 68 // Documentation suggests bid request splitting by impression so that each 69 // request only represents a single impression 70 reqs := make([]*adapters.RequestData, 0, len(request.Imp)) 71 headers := http.Header{} 72 var errs []error 73 74 headers.Add("Content-Type", "application/json;charset=utf-8") 75 headers.Add("Accept", "application/json") 76 headers.Add("X-Fb-Pool-Routing-Token", request.User.BuyerUID) 77 78 for _, imp := range request.Imp { 79 // Make a copy of the request so that we don't change the original request which 80 // is shared across multiple threads 81 fbreq := *request 82 fbreq.Imp = []openrtb2.Imp{imp} 83 84 if err := a.modifyRequest(&fbreq); err != nil { 85 errs = append(errs, err) 86 continue 87 } 88 89 body, err := json.Marshal(&fbreq) 90 if err != nil { 91 errs = append(errs, err) 92 continue 93 } 94 95 body, err = modifyImpCustom(body, &fbreq.Imp[0]) 96 if err != nil { 97 errs = append(errs, err) 98 continue 99 } 100 101 body, err = jsonutil.DropElement(body, "consented_providers_settings") 102 if err != nil { 103 errs = append(errs, err) 104 return reqs, errs 105 } 106 107 reqs = append(reqs, &adapters.RequestData{ 108 Method: "POST", 109 Uri: a.uri, 110 Body: body, 111 Headers: headers, 112 ImpIDs: openrtb_ext.GetImpIDs(fbreq.Imp), 113 }) 114 } 115 116 return reqs, errs 117 } 118 119 // The authentication ID is a sha256 hmac hash encoded as a hex string, based on 120 // the app secret and the ID of the bid request 121 func (a *adapter) makeAuthID(req *openrtb2.BidRequest) string { 122 h := hmac.New(sha256.New, []byte(a.appSecret)) 123 h.Write([]byte(req.ID)) 124 125 return hex.EncodeToString(h.Sum(nil)) 126 } 127 128 func (a *adapter) modifyRequest(out *openrtb2.BidRequest) error { 129 if len(out.Imp) != 1 { 130 panic("each bid request to facebook should only have a single impression") 131 } 132 133 imp := &out.Imp[0] 134 plmtId, pubId, err := extractPlacementAndPublisher(imp) 135 if err != nil { 136 return err 137 } 138 139 // Every outgoing FAN request has a single impression, so we can safely use the unique 140 // impression ID as the FAN request ID. We need to make sure that we update the request 141 // ID *BEFORE* we generate the auth ID since its a hash based on the request ID 142 out.ID = imp.ID 143 144 reqExt := facebookReqExt{ 145 PlatformID: a.platformID, 146 AuthID: a.makeAuthID(out), 147 } 148 149 if out.Ext, err = json.Marshal(reqExt); err != nil { 150 return err 151 } 152 153 imp.TagID = pubId + "_" + plmtId 154 imp.Ext = nil 155 156 if out.App != nil { 157 app := *out.App 158 app.Publisher = &openrtb2.Publisher{ID: pubId} 159 out.App = &app 160 } 161 162 if err = modifyImp(imp); err != nil { 163 return err 164 } 165 166 return nil 167 } 168 169 func modifyImp(out *openrtb2.Imp) error { 170 impType := resolveImpType(out) 171 172 if out.Instl == 1 && impType != openrtb_ext.BidTypeBanner { 173 return &errortypes.BadInput{ 174 Message: fmt.Sprintf("imp #%s: interstitial imps are only supported for banner", out.ID), 175 } 176 } 177 178 if impType == openrtb_ext.BidTypeBanner { 179 bannerCopy := *out.Banner 180 out.Banner = &bannerCopy 181 182 if out.Instl == 1 { 183 out.Banner.W = ptrutil.ToPtr[int64](0) 184 out.Banner.H = ptrutil.ToPtr[int64](0) 185 out.Banner.Format = nil 186 return nil 187 } 188 189 if out.Banner.H == nil { 190 for _, f := range out.Banner.Format { 191 if _, ok := supportedBannerHeights[f.H]; ok { 192 h := f.H 193 out.Banner.H = &h 194 break 195 } 196 } 197 if out.Banner.H == nil { 198 return &errortypes.BadInput{ 199 Message: fmt.Sprintf("imp #%s: banner height required", out.ID), 200 } 201 } 202 } 203 204 if _, ok := supportedBannerHeights[*out.Banner.H]; !ok { 205 return &errortypes.BadInput{ 206 Message: fmt.Sprintf("imp #%s: only banner heights 50 and 250 are supported", out.ID), 207 } 208 } 209 210 out.Banner.W = ptrutil.ToPtr[int64](-1) 211 out.Banner.Format = nil 212 } 213 214 return nil 215 } 216 217 func extractPlacementAndPublisher(out *openrtb2.Imp) (string, string, error) { 218 var bidderExt adapters.ExtImpBidder 219 if err := json.Unmarshal(out.Ext, &bidderExt); err != nil { 220 return "", "", &errortypes.BadInput{ 221 Message: err.Error(), 222 } 223 } 224 225 var fbExt openrtb_ext.ExtImpFacebook 226 if err := json.Unmarshal(bidderExt.Bidder, &fbExt); err != nil { 227 return "", "", &errortypes.BadInput{ 228 Message: err.Error(), 229 } 230 } 231 232 if fbExt.PlacementId == "" { 233 return "", "", &errortypes.BadInput{ 234 Message: "Missing placementId param", 235 } 236 } 237 238 placementID := fbExt.PlacementId 239 publisherID := fbExt.PublisherId 240 241 // Support the legacy path with the caller was expected to pass in just placementId 242 // which was an underscore concatenated string with the publisherId and placementId. 243 // The new path for callers is to pass in the placementId and publisherId independently 244 // and the below code will prefix the placementId that we pass to FAN with the publisherId 245 // so that we can abstract the implementation details from the caller 246 toks := strings.Split(placementID, "_") 247 if len(toks) == 1 { 248 if publisherID == "" { 249 return "", "", &errortypes.BadInput{ 250 Message: "Missing publisherId param", 251 } 252 } 253 254 return placementID, publisherID, nil 255 } else if len(toks) == 2 { 256 publisherID = toks[0] 257 placementID = toks[1] 258 } else { 259 return "", "", &errortypes.BadInput{ 260 Message: fmt.Sprintf("Invalid placementId param '%s' and publisherId param '%s'", placementID, publisherID), 261 } 262 } 263 264 return placementID, publisherID, nil 265 } 266 267 // modifyImpCustom modifies the impression after it's marshalled to add a non-openrtb field. 268 func modifyImpCustom(jsonData []byte, imp *openrtb2.Imp) ([]byte, error) { 269 impType := resolveImpType(imp) 270 271 // we only need to modify video and native impressions 272 if impType != openrtb_ext.BidTypeVideo && impType != openrtb_ext.BidTypeNative { 273 return jsonData, nil 274 } 275 276 var jsonMap map[string]interface{} 277 if err := json.Unmarshal(jsonData, &jsonMap); err != nil { 278 return jsonData, err 279 } 280 281 var impMap map[string]interface{} 282 if impSlice, ok := maputil.ReadEmbeddedSlice(jsonMap, "imp"); !ok { 283 return jsonData, errors.New("unable to find imp in json data") 284 } else if len(impSlice) == 0 { 285 return jsonData, errors.New("unable to find imp[0] in json data") 286 } else if impMap, ok = impSlice[0].(map[string]interface{}); !ok { 287 return jsonData, errors.New("unexpected type for imp[0] found in json data") 288 } 289 290 switch impType { 291 case openrtb_ext.BidTypeVideo: 292 videoMap, ok := maputil.ReadEmbeddedMap(impMap, "video") 293 if !ok { 294 return jsonData, errors.New("unable to find imp[0].video in json data") 295 } 296 297 // the openrtb library omits video.w/h if set to zero, so we need to force set those 298 // fields to zero post-serialization for the time being 299 videoMap["w"] = json.RawMessage("0") 300 videoMap["h"] = json.RawMessage("0") 301 302 case openrtb_ext.BidTypeNative: 303 nativeMap, ok := maputil.ReadEmbeddedMap(impMap, "native") 304 if !ok { 305 return jsonData, errors.New("unable to find imp[0].video in json data") 306 } 307 308 // Set w/h to -1 for native impressions based on the facebook native spec. 309 // We have to set this post-serialization since these fields are not included 310 // in the OpenRTB 2.5 spec. 311 nativeMap["w"] = json.RawMessage("-1") 312 nativeMap["h"] = json.RawMessage("-1") 313 314 // The FAN adserver does not expect the native request payload, all that information 315 // is derived server side based on the placement ID. We need to remove these pieces of 316 // information manually since OpenRTB (and thus mxmCherry) never omit native.request 317 delete(nativeMap, "ver") 318 delete(nativeMap, "request") 319 } 320 321 if jsonReEncoded, err := json.Marshal(jsonMap); err == nil { 322 return jsonReEncoded, nil 323 } else { 324 return nil, fmt.Errorf("unable to encode json data (%v)", err) 325 } 326 } 327 328 func (a *adapter) MakeBids(request *openrtb2.BidRequest, adapterRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { 329 if response.StatusCode == http.StatusNoContent { 330 return nil, nil 331 } 332 333 if response.StatusCode != http.StatusOK { 334 msg := response.Headers.Get("x-fb-an-errors") 335 return nil, []error{&errortypes.BadInput{ 336 Message: fmt.Sprintf("Unexpected status code %d with error message '%s'", response.StatusCode, msg), 337 }} 338 } 339 340 var bidResp openrtb2.BidResponse 341 if err := json.Unmarshal(response.Body, &bidResp); err != nil { 342 return nil, []error{err} 343 } 344 345 out := adapters.NewBidderResponseWithBidsCapacity(4) 346 var errs []error 347 348 for _, seatbid := range bidResp.SeatBid { 349 for i := range seatbid.Bid { 350 bid := seatbid.Bid[i] 351 352 if bid.AdM == "" { 353 errs = append(errs, &errortypes.BadServerResponse{ 354 Message: fmt.Sprintf("Bid %s missing 'adm'", bid.ID), 355 }) 356 continue 357 } 358 359 var obj facebookAdMarkup 360 if err := json.Unmarshal([]byte(bid.AdM), &obj); err != nil { 361 errs = append(errs, &errortypes.BadServerResponse{ 362 Message: err.Error(), 363 }) 364 continue 365 } 366 367 if obj.BidID == "" { 368 errs = append(errs, &errortypes.BadServerResponse{ 369 Message: fmt.Sprintf("bid %s missing 'bid_id' in 'adm'", bid.ID), 370 }) 371 continue 372 } 373 374 bid.AdID = obj.BidID 375 bid.CrID = obj.BidID 376 377 out.Bids = append(out.Bids, &adapters.TypedBid{ 378 Bid: &bid, 379 BidType: resolveBidType(&bid, request), 380 }) 381 } 382 } 383 384 return out, errs 385 } 386 387 func resolveBidType(bid *openrtb2.Bid, req *openrtb2.BidRequest) openrtb_ext.BidType { 388 for _, imp := range req.Imp { 389 if bid.ImpID == imp.ID { 390 return resolveImpType(&imp) 391 } 392 } 393 394 panic(fmt.Sprintf("Invalid bid imp ID %s does not match any imp IDs from the original bid request", bid.ImpID)) 395 } 396 397 func resolveImpType(imp *openrtb2.Imp) openrtb_ext.BidType { 398 if imp.Banner != nil { 399 return openrtb_ext.BidTypeBanner 400 } 401 402 if imp.Video != nil { 403 return openrtb_ext.BidTypeVideo 404 } 405 406 if imp.Audio != nil { 407 return openrtb_ext.BidTypeAudio 408 } 409 410 if imp.Native != nil { 411 return openrtb_ext.BidTypeNative 412 } 413 414 // Required to satisfy compiler. Not reachable in practice due to validations performed in PBS-Core. 415 return openrtb_ext.BidTypeBanner 416 } 417 418 // Builder builds a new instance of Facebook's Audience Network adapter for the given bidder with the given config. 419 func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { 420 if config.PlatformID == "" { 421 return nil, errors.New("PartnerID is not configured. Did you set adapters.facebook.platform_id in the app config?") 422 } 423 424 if config.AppSecret == "" { 425 return nil, errors.New("AppSecret is not configured. Did you set adapters.facebook.app_secret in the app config?") 426 } 427 428 bidder := &adapter{ 429 uri: config.Endpoint, 430 platformID: config.PlatformID, 431 appSecret: config.AppSecret, 432 } 433 return bidder, nil 434 } 435 436 func (a *adapter) MakeTimeoutNotification(req *adapters.RequestData) (*adapters.RequestData, []error) { 437 var ( 438 rID string 439 pubID string 440 err error 441 ) 442 443 // Note, the facebook adserver can only handle single impression requests, so we have to split multi-imp requests into 444 // multiple request. In order to ensure that every split request has a unique ID, the split request IDs are set to the 445 // corresponding imp's ID 446 rID, err = jsonparser.GetString(req.Body, "id") 447 if err != nil { 448 return &adapters.RequestData{}, []error{err} 449 } 450 451 // The publisher ID is expected in the app object 452 pubID, err = jsonparser.GetString(req.Body, "app", "publisher", "id") 453 if err != nil { 454 return &adapters.RequestData{}, []error{ 455 errors.New("path app.publisher.id not found in the request"), 456 } 457 } 458 459 uri := fmt.Sprintf("https://www.facebook.com/audiencenetwork/nurl/?partner=%s&app=%s&auction=%s&ortb_loss_code=2", a.platformID, pubID, rID) 460 timeoutReq := adapters.RequestData{ 461 Method: "GET", 462 Uri: uri, 463 Body: nil, 464 Headers: http.Header{}, 465 } 466 467 return &timeoutReq, nil 468 }