diff --git a/plugins/samples/ad_insertion/BUILD b/plugins/samples/ad_insertion/BUILD index 4a2accaf..1daacb3c 100644 --- a/plugins/samples/ad_insertion/BUILD +++ b/plugins/samples/ad_insertion/BUILD @@ -2,6 +2,11 @@ load("//:plugins.bzl", "proxy_wasm_plugin_cpp", "proxy_wasm_plugin_rust", "proxy licenses(["notice"]) # Apache 2 +proxy_wasm_plugin_go( + name = "plugin_go.wasm", + srcs = ["plugin.go"], +) + proxy_wasm_plugin_rust( name = "plugin_rust.wasm", srcs = ["plugin.rs"], @@ -22,6 +27,7 @@ proxy_wasm_tests( name = "tests", config = ":tests.config", plugins = [ + ":plugin_go.wasm", ":plugin_rust.wasm", ":plugin_cpp.wasm", ], diff --git a/plugins/samples/ad_insertion/plugin.go b/plugins/samples/ad_insertion/plugin.go new file mode 100644 index 00000000..2858ff04 --- /dev/null +++ b/plugins/samples/ad_insertion/plugin.go @@ -0,0 +1,280 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// [START serviceextensions_plugin_ad_insertion] +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/proxy-wasm/proxy-wasm-go-sdk/proxywasm" + "github.com/proxy-wasm/proxy-wasm-go-sdk/proxywasm/types" +) + +func main() {} + +func init() { + proxywasm.SetVMContext(&vmContext{}) +} + +type vmContext struct { + types.DefaultVMContext +} + +type pluginContext struct { + types.DefaultPluginContext + adConfigs map[string]adConfig + gptLibraryURL string + injectGptLibrary bool +} + +type httpContext struct { + types.DefaultHttpContext + pluginContext *pluginContext + shouldInsertAds bool + isAdRequest bool +} + +type adConfig struct { + Slot string // GAM ad slot path (e.g., "/1234/header_ad") + Size string // Ad dimensions (e.g., "728x90") + Marker string // HTML tag to insert ads relative to + InsertBefore bool // Insert before (true) or after (false) the marker +} + +type insertion struct { + pos int + content string +} + +func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext { + return &pluginContext{} +} + +func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { + // Set default configurations for fallback and testing purposes. + ctx.gptLibraryURL = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" + ctx.injectGptLibrary = true + ctx.adConfigs = map[string]adConfig{ + "header": { + Slot: "/1234/header_ad", + Size: "728x90", + Marker: "", + InsertBefore: false, + }, + "content": { + Slot: "/1234/content_ad", + Size: "300x250", + Marker: "
", + InsertBefore: false, + }, + "sidebar": { + Slot: "/1234/sidebar_ad", + Size: "160x600", + Marker: "
", + InsertBefore: true, + }, + } + + if pluginConfigurationSize == 0 { + proxywasm.LogInfo("No configuration provided. Using default ad insertion config.") + return types.OnPluginStartStatusOK + } + + config, err := proxywasm.GetPluginConfiguration() + if err != nil || len(config) == 0 { + return types.OnPluginStartStatusOK + } + + // Clear default ad configs since we are loading custom ones + ctx.adConfigs = make(map[string]adConfig) + + // Parse the CSV-like configuration format + for _, line := range strings.Split(string(config), "\n") { + line = strings.TrimSpace(line) + if len(line) == 0 || strings.HasPrefix(line, "#") { + continue // Skip empty lines and comments + } + + parts := strings.Split(line, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + + if parts[0] == "gpt_url" && len(parts) >= 2 { + ctx.gptLibraryURL = parts[1] + } else if parts[0] == "inject_gpt" && len(parts) >= 2 { + ctx.injectGptLibrary = parts[1] == "true" + } else if parts[0] == "ad" && len(parts) >= 6 { + position := parts[1] + ctx.adConfigs[position] = adConfig{ + Slot: parts[2], + Size: parts[3], + InsertBefore: parts[4] == "true", + Marker: parts[5], + } + } else { + proxywasm.LogWarnf("Invalid configuration line: %s", line) + } + } + + proxywasm.LogInfo("Ad Insertion plugin configured successfully from custom payload.") + return types.OnPluginStartStatusOK +} + +func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { + return &httpContext{pluginContext: ctx} +} + +func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + // Skip ad insertion for ad requests to avoid infinite loops + path, err := proxywasm.GetHttpRequestHeader(":path") + if err == nil && strings.Contains(path, "/ads/") { + ctx.isAdRequest = true + } + return types.ActionContinue +} + +func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { + // Check if response is HTML and should process for ad insertion + contentType, err := proxywasm.GetHttpResponseHeader("Content-Type") + if err == nil && strings.Contains(contentType, "text/html") { + ctx.shouldInsertAds = true + // Remove Content-Length header since we'll modify the body + proxywasm.RemoveHttpResponseHeader("Content-Length") + } + return types.ActionContinue +} + +func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { + if !ctx.shouldInsertAds || ctx.isAdRequest { + return types.ActionContinue + } + + // Process HTML body and inject GAM ads + body, err := proxywasm.GetHttpResponseBody(0, bodySize) + if err != nil { + return types.ActionContinue + } + + bodyStr := string(body) + modifiedBody := ctx.processBodyWithGAM(bodyStr) + + proxywasm.ReplaceHttpResponseBody([]byte(modifiedBody)) + return types.ActionContinue +} + +func (ctx *httpContext) isGptAlreadyLoaded(body string) bool { + return strings.Contains(body, "googletag") || + strings.Contains(body, "gpt.js") || + strings.Contains(body, "doubleclick.net/tag/js/gpt") +} + +func (ctx *httpContext) processBodyWithGAM(body string) string { + // Slice to store all insertions: (position, content) + insertions := []insertion{} + + // 1. Prepare GPT library injection if needed and not already present + if ctx.pluginContext.injectGptLibrary && !ctx.isGptAlreadyLoaded(body) { + ctx.prepareGptLibraryInjection(body, &insertions) + } + + // 2. Prepare all ad insertions in single pass + for position, config := range ctx.pluginContext.adConfigs { + ctx.prepareAdInsertion(body, position, config, &insertions) + } + + // 3. Apply all insertions in reverse order (to maintain correct indices) + if len(insertions) > 0 { + return ctx.applyAllInsertions(body, insertions) + } + + return body +} + +func (ctx *httpContext) prepareGptLibraryInjection(body string, insertions *[]insertion) { + if headPos := strings.Index(body, ""); headPos != -1 { + gptScript := fmt.Sprintf("\n ", ctx.pluginContext.gptLibraryURL) + *insertions = append(*insertions, insertion{pos: headPos + 6, content: gptScript}) + return + } + + if bodyPos := strings.Index(body, ""); bodyPos != -1 { + gptScript := fmt.Sprintf("\n", ctx.pluginContext.gptLibraryURL) + *insertions = append(*insertions, insertion{pos: bodyPos, content: gptScript}) + } +} + +func (ctx *httpContext) prepareAdInsertion(body string, position string, config adConfig, insertions *[]insertion) { + markerPos := strings.Index(body, config.Marker) + if markerPos == -1 { + return + } + + insertPos := markerPos + if !config.InsertBefore { + insertPos += len(config.Marker) + } + + adHTML := ctx.generateGAMAdHTML(position, config) + *insertions = append(*insertions, insertion{pos: insertPos, content: adHTML}) +} + +func (ctx *httpContext) applyAllInsertions(body string, insertions []insertion) string { + // Sort insertions by position in DESCENDING order + // This ensures that later insertions don't affect positions of earlier ones + sort.Slice(insertions, func(i, j int) bool { + return insertions[i].pos > insertions[j].pos + }) + + // Apply all insertions + result := body + for _, ins := range insertions { + result = result[:ins.pos] + ins.content + result[ins.pos:] + } + + return result +} + +func (ctx *httpContext) generateGAMAdHTML(position string, config adConfig) string { + // GAM Ad HTML Template + return fmt.Sprintf(`
+ + +
+ +
+
`, position, config.Slot, config.Slot, config.Size, position, position, position) +} + +// [END serviceextensions_plugin_ad_insertion]