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  }