├── .gitignore
├── .goreleaser.yml
├── LICENSE
├── Makefile
├── README.md
├── daemon.go
├── dsp
├── Makefile
└── main.go
├── dsplib
├── Makefile
├── campaign.go
└── dsp.go
├── example.html
├── main.go
├── main_test.go
├── mux.go
├── openrtb
└── rtb.go
├── ssp.json
├── ssp
├── Makefile
├── auction.go
├── auction_test.go
├── bid.go
├── bid_test.go
├── dsp.go
├── dsp_test.go
├── id.go
├── placement.go
└── testdsp.go
├── static.go
├── static
├── html5vast.css
└── html5vast.js
└── vendor
├── github.com
└── julienschmidt
│ └── httprouter
│ ├── LICENSE
│ ├── README.md
│ ├── path.go
│ ├── router.go
│ └── tree.go
└── vendor.json
/.gitignore:
--------------------------------------------------------------------------------
1 | /my_first_ssp
2 | /dsp/dsp
3 | /dist/
4 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | builds:
2 | - binary: ssp
3 | goos:
4 | - linux
5 | - darwin
6 | goarch:
7 | - amd64
8 | archive:
9 | files:
10 | - LICENSE
11 | - ssp.json
12 | wrap_in_directory: true
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Harmen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all test build static release
2 |
3 | all: test build
4 |
5 | test:
6 | go test ./...
7 |
8 | build:
9 | go build -i ./...
10 | go build -o ./my_first_ssp
11 |
12 | static:
13 | go get github.com/mjibson/esc
14 | esc -o static.go --prefix static/ static/
15 | $(MAKE) build
16 |
17 | release:
18 | go get -v github.com/goreleaser/goreleaser
19 | goreleaser --rm-dist # --snapshot
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | **Project status**: Please note that this project works, but is otherwise left as-is. I won't maintain this, and I can't provide any support.
2 |
3 |
4 | # Minimal SSP
5 |
6 | A very basic openrtb2.3 SSP used to tests RTB campaigns. It has a few placements
7 | and a few RTB backends configured via a JSON file, and you can see how the
8 | placements behave in a browser. Win-notification URLs are called, but the
9 | system is stateless, so there is no big "money spent" counter.
10 | Basic support for VAST3 video.
11 |
12 | # Auction
13 |
14 | It's a proper second-price auction, and if there is only a single bid it'll
15 | charge the bid floor for the placement.
16 |
--------------------------------------------------------------------------------
/daemon.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log"
6 | "net"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/alicebob/ssp/ssp"
11 | )
12 |
13 | const (
14 | timeout = 100 * time.Millisecond
15 | cookieName = "uid"
16 | )
17 |
18 | type Daemon struct {
19 | BaseURL string
20 | DSPs []ssp.DSP
21 | }
22 |
23 | func NewDaemon(base string, dsps []ssp.DSP) *Daemon {
24 | return &Daemon{
25 | BaseURL: base,
26 | DSPs: dsps,
27 | }
28 | }
29 |
30 | // RunAuction for a placement. Can take up to $timeout to run.
31 | func (d *Daemon) RunAuction(pl *ssp.Placement, r *http.Request, userID string) *ssp.Auction {
32 | a := ssp.NewAuction()
33 | a.UserAgent = r.UserAgent()
34 | if addr := r.RemoteAddr; addr != "" {
35 | a.IP, _, _ = net.SplitHostPort(addr)
36 | }
37 | a.UserID = userID
38 | a.PlacementID = pl.ID
39 | a.PlacementType = pl.Type
40 | a.FloorCPM = pl.FloorCPM
41 | a.Width = pl.Width
42 | a.Height = pl.Height
43 |
44 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
45 | defer cancel()
46 | won, err := ssp.RunAuction(ctx, d.DSPs, a)
47 | if err != nil {
48 | log.Printf("bid error: %v", err)
49 | return a
50 | }
51 | if won == nil {
52 | return nil
53 | }
54 | log.Printf("winning bid: %s: %f", won.SSPID, won.PriceCPM)
55 | a.PriceCPM = won.PriceCPM
56 | a.NotificationURL = won.NotificationURL
57 | a.AdMarkup = won.AdMarkup
58 | a.Won()
59 | return a
60 | }
61 |
62 | func getUserID(r *http.Request) string {
63 | if c, err := r.Cookie(cookieName); err == nil && c != nil {
64 | return c.Value
65 | }
66 | return ssp.RandomID(10)
67 | }
68 |
69 | func userID(w http.ResponseWriter, r *http.Request) string {
70 | userID := getUserID(r)
71 | http.SetCookie(w, &http.Cookie{
72 | Name: cookieName,
73 | Value: userID,
74 | Path: "/",
75 | MaxAge: 100 * 24 * 60 * 60,
76 | HttpOnly: true,
77 | })
78 | return userID
79 | }
80 |
--------------------------------------------------------------------------------
/dsp/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 |
3 | build:
4 | go build
5 |
--------------------------------------------------------------------------------
/dsp/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/alicebob/ssp/dsplib"
7 | )
8 |
9 | const listen = "localhost:9990"
10 |
11 | var (
12 | campaigns = []dsplib.Campaign{
13 | dsplib.Campaign{
14 | ID: "camp1",
15 | Type: "banner",
16 | Width: 466,
17 | Height: 214,
18 | BidCPM: 0.43,
19 | ImageURL: "https://imgs.xkcd.com/comics/debugger.png",
20 | ClickURL: "https://xkcd.com/1163/",
21 | },
22 | dsplib.Campaign{
23 | ID: "camp2",
24 | Type: "banner",
25 | Width: 300,
26 | Height: 330,
27 | BidCPM: 0.12,
28 | ImageURL: "https://imgs.xkcd.com/comics/duty_calls.png",
29 | ClickURL: "https://xkcd.com/386/",
30 | },
31 | dsplib.Campaign{
32 | ID: "vid1",
33 | Type: "video",
34 | Width: 400,
35 | Height: 400,
36 | BidCPM: 0.8,
37 | VideoURL: "http://techslides.com/demos/sample-videos/small.mp4",
38 | ClickURL: "https://xkcd.com/386/",
39 | },
40 | }
41 | )
42 |
43 | func main() {
44 | s := dsplib.NewDSP(listen, campaigns)
45 | defer s.Close()
46 | log.Printf("configured campaigns:")
47 | for _, c := range campaigns {
48 | log.Printf(" - %s %dx%d: %s ($%.2f)", c.Type, c.Width, c.Height, c.ID, c.BidCPM)
49 | }
50 | log.Printf("BidURL: %s", s.BidURL)
51 | for {
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/dsplib/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 |
3 | build:
4 | go build -i
5 |
--------------------------------------------------------------------------------
/dsplib/campaign.go:
--------------------------------------------------------------------------------
1 | package dsplib
2 |
3 | type Campaign struct {
4 | ID string
5 | Type string
6 | Width, Height int
7 | ImageURL string
8 | VideoURL string
9 | ClickURL string
10 | BidCPM float64
11 | }
12 |
--------------------------------------------------------------------------------
/dsplib/dsp.go:
--------------------------------------------------------------------------------
1 | // Basic test DSP
2 | package dsplib
3 |
4 | import (
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "net"
9 | "net/http"
10 | "strconv"
11 | "sync"
12 |
13 | "github.com/julienschmidt/httprouter"
14 |
15 | "github.com/alicebob/ssp/openrtb"
16 | )
17 |
18 | type DSP struct {
19 | BaseURL string
20 | BidURL string
21 | server *http.Server
22 | campaigns []Campaign
23 | wonCount int
24 | wonCPM float64
25 | mu sync.Mutex
26 | }
27 |
28 | func NewDSP(listen string, cs []Campaign) *DSP {
29 | l, err := net.Listen("tcp", listen)
30 | if err != nil {
31 | panic(err.Error())
32 | }
33 | port := l.Addr().(*net.TCPAddr).Port
34 | base := fmt.Sprintf("http://localhost:%d/", port)
35 | d := &DSP{
36 | BaseURL: base,
37 | BidURL: fmt.Sprintf("%srtb", base),
38 | campaigns: cs,
39 | }
40 | d.server = &http.Server{
41 | Addr: listen,
42 | Handler: d.Mux(),
43 | }
44 | go d.server.Serve(l)
45 | return d
46 | }
47 |
48 | func (dsp *DSP) Close() error {
49 | return dsp.server.Close()
50 | }
51 |
52 | func (dsp *DSP) Mux() *httprouter.Router {
53 | r := httprouter.New()
54 | r.POST("/rtb", dsp.rtbHandler())
55 | r.GET("/win", dsp.winHandler())
56 | return r
57 | }
58 |
59 | func (dsp *DSP) rtbHandler() httprouter.Handle {
60 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
61 | log.Printf("RTB request")
62 | var req openrtb.BidRequest
63 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
64 | log.Printf("req: %s", err)
65 | http.Error(w, http.StatusText(400), 400)
66 | return
67 | }
68 |
69 | // log.Printf("RTB request: %#v", req)
70 | var bids []openrtb.Bid
71 | for _, imp := range req.Impressions {
72 | bids = append(bids, dsp.makeBid(imp)...)
73 | }
74 | if len(bids) == 0 {
75 | log.Printf("no bids")
76 | w.WriteHeader(204)
77 | fmt.Fprintf(w, "{}")
78 | return
79 | }
80 | res := openrtb.BidResponse{
81 | ID: req.ID,
82 | Seatbids: []openrtb.Seatbid{
83 | {
84 | Bids: bids,
85 | },
86 | },
87 | }
88 | pl, err := json.Marshal(res)
89 | if err != nil {
90 | log.Printf("req: %s", err)
91 | http.Error(w, http.StatusText(500), 500)
92 | return
93 | }
94 | w.Header().Set("Content-Type", "application/json")
95 | if n, err := w.Write(pl); err != nil || n != len(pl) {
96 | log.Printf("req: %s (n: %d/%d)", err, n, len(pl))
97 | }
98 | }
99 | }
100 |
101 | // place a bid on every campaign which matches.
102 | func (dsp *DSP) makeBid(imp openrtb.Impression) []openrtb.Bid {
103 | var bids []openrtb.Bid
104 | for _, c := range dsp.campaigns {
105 | switch {
106 | case imp.Banner != nil && (c.Type == "banner" || c.Type == ""):
107 | b := imp.Banner
108 | if b.Width == c.Width && b.Height == c.Height {
109 | bids = append(
110 | bids,
111 | openrtb.Bid{
112 | ImpressionID: imp.ID,
113 | Price: c.BidCPM,
114 | AdMarkup: fmt.Sprintf(
115 | ``,
116 | c.ClickURL,
117 | c.ImageURL,
118 | c.Width,
119 | c.Height,
120 | ),
121 | NotificationURL: dsp.winURL(),
122 | },
123 | )
124 | }
125 | case imp.Video != nil && c.Type == "video":
126 | // TODO: fuzzier dimension matching
127 | v := imp.Video
128 | if v.Width == c.Width && v.Height == c.Height {
129 | bids = append(
130 | bids,
131 | openrtb.Bid{
132 | ImpressionID: imp.ID,
133 | Price: c.BidCPM,
134 | AdMarkup: vast30(c),
135 | NotificationURL: dsp.winURL(),
136 | },
137 | )
138 | }
139 | }
140 | }
141 | return bids
142 | }
143 |
144 | func (dsp *DSP) winHandler() httprouter.Handle {
145 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
146 | pr := r.FormValue("p")
147 | if pr == "" {
148 | log.Printf("no price")
149 | http.Error(w, http.StatusText(400), 400)
150 | return
151 | }
152 | price, err := strconv.ParseFloat(pr, 64)
153 | if err != nil {
154 | log.Printf("invalid price %q: %s", pr, err)
155 | http.Error(w, http.StatusText(400), 400)
156 | return
157 | }
158 | log.Printf("won %f", price)
159 | dsp.mu.Lock()
160 | defer dsp.mu.Unlock()
161 | dsp.wonCount++
162 | dsp.wonCPM += price
163 | }
164 | }
165 |
166 | func (dsp *DSP) winURL() string {
167 | return fmt.Sprintf("%swin?p=${AUCTION_PRICE}", dsp.BaseURL)
168 | }
169 |
170 | // Won returns count+total CPM of winnotices
171 | func (dsp *DSP) Won() (int, float64) {
172 | dsp.mu.Lock()
173 | defer dsp.mu.Unlock()
174 | return dsp.wonCount, dsp.wonCPM
175 | }
176 |
177 | func vast30(c Campaign) string {
178 | return fmt.Sprintf(`