|
| 1 | +use std::ops::{Add, Mul}; |
| 2 | + |
| 3 | +use adex_primitives::{ |
| 4 | + campaign::Validators, |
| 5 | + sentry::{Event, EventType, InsertEventsRequest, CLICK, IMPRESSION}, |
| 6 | + supermarket::units_for_slot::response::AdUnit, |
| 7 | + BigNum, CampaignId, |
| 8 | +}; |
| 9 | +use num_integer::Integer; |
| 10 | + |
| 11 | +use crate::{ |
| 12 | + manager::{Options, Size}, |
| 13 | + WAIT_FOR_IMPRESSION, |
| 14 | +}; |
| 15 | + |
| 16 | +const IPFS_GATEWAY: &str = "https://ipfs.moonicorn.network/ipfs/"; |
| 17 | + |
| 18 | +fn normalize_url(url: &str) -> String { |
| 19 | + if url.starts_with("ipfs://") { |
| 20 | + url.replacen("ipfs://", IPFS_GATEWAY, 1) |
| 21 | + } else { |
| 22 | + url.to_string() |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +fn image_html(on_load: &str, size: Option<Size>, image_url: &str) -> String { |
| 27 | + let size = size |
| 28 | + .map(|Size { width, height }| format!("width=\"{width}\" height=\"{height}\"")) |
| 29 | + .unwrap_or_default(); |
| 30 | + |
| 31 | + format!("<img loading=\"lazy\" src=\"{image_url}\" alt=\"AdEx ad\" rel=\"nofollow\" onload=\"{on_load}\" {size}>") |
| 32 | +} |
| 33 | + |
| 34 | +fn video_html(on_load: &str, size: Option<Size>, image_url: &str, media_mime: &str) -> String { |
| 35 | + let size = size |
| 36 | + .map(|Size { width, height }| format!("width=\"{width}\" height=\"{height}\"")) |
| 37 | + .unwrap_or_default(); |
| 38 | + |
| 39 | + format!( |
| 40 | + "<video {size} loop autoplay onloadeddata=\"{on_load}\" muted> |
| 41 | + <source src=\"{image_url}\" type=\"{media_mime}\"> |
| 42 | + </video>", |
| 43 | + ) |
| 44 | +} |
| 45 | + |
| 46 | +fn adex_icon() -> &'static str { |
| 47 | + r#"<a href="https://www.adex.network" target="_blank" rel="noopener noreferrer" |
| 48 | + style="position: absolute; top: 0; right: 0;" |
| 49 | + > |
| 50 | + <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="18px" |
| 51 | + height="18px" viewBox="0 0 18 18" style="enable-background:new 0 0 18 18;" xml:space="preserve"> |
| 52 | + <style type="text/css"> |
| 53 | + .st0{fill:#FFFFFF;} |
| 54 | + .st1{fill:#1B75BC;} |
| 55 | + </style> |
| 56 | + <defs> |
| 57 | + </defs> |
| 58 | + <rect class="st0" width="18" height="18"/> |
| 59 | + <path class="st1" d="M14,12.1L10.9,9L14,5.9L12.1,4L9,7.1L5.9,4L4,5.9L7.1,9L4,12.1L5.9,14L9,10.9l3.1,3.1L14,12.1z M7.9,2L6.4,3.5 |
| 60 | + L7.9,5L9,3.9L10.1,5l1.5-1.5L10,1.9l-1-1L7.9,2 M7.9,16l-1.5-1.5L7.9,13L9,14.1l1.1-1.1l1.5,1.5L10,16.1l-1,1L7.9,16"/> |
| 61 | + </svg> |
| 62 | + </a>"# |
| 63 | +} |
| 64 | + |
| 65 | +pub(crate) fn is_video(ad_unit: &AdUnit) -> bool { |
| 66 | + ad_unit.media_mime.split('/').next() == Some("video") |
| 67 | +} |
| 68 | + |
| 69 | +/// Does not copy the JS impl, instead it generates the BigNum from the IPFS CID bytes |
| 70 | +pub(crate) fn randomized_sort_pos(ad_unit: &AdUnit, seed: BigNum) -> BigNum { |
| 71 | + let bytes = ad_unit.id.0.to_bytes(); |
| 72 | + |
| 73 | + let unit_id = BigNum::from_bytes_be(&bytes); |
| 74 | + |
| 75 | + let x: BigNum = unit_id.mul(seed).add(BigNum::from(12345)); |
| 76 | + |
| 77 | + x.mod_floor(&BigNum::from(0x80000000)) |
| 78 | +} |
| 79 | + |
| 80 | +/// Generates the AdUnit HTML for a given ad |
| 81 | +pub(crate) fn get_unit_html( |
| 82 | + size: Option<Size>, |
| 83 | + ad_unit: &AdUnit, |
| 84 | + hostname: &str, |
| 85 | + on_load: &str, |
| 86 | + on_click: &str, |
| 87 | +) -> String { |
| 88 | + // replace all `"` quotes with a single quote `'` |
| 89 | + // these values are used inside `onclick` & `onload` html attributes |
| 90 | + let on_load = on_load.replace('\"', "'"); |
| 91 | + let on_click = on_click.replace('\"', "'"); |
| 92 | + let image_url = normalize_url(&ad_unit.media_url); |
| 93 | + |
| 94 | + let element_html = if is_video(ad_unit) { |
| 95 | + video_html(&on_load, size, &image_url, &ad_unit.media_mime) |
| 96 | + } else { |
| 97 | + image_html(&on_load, size, &image_url) |
| 98 | + }; |
| 99 | + |
| 100 | + // @TODO click protection page |
| 101 | + let final_target_url = ad_unit.target_url.replace( |
| 102 | + "utm_source=adex_PUBHOSTNAME", |
| 103 | + &format!("utm_source=AdEx+({hostname})", hostname = hostname), |
| 104 | + ); |
| 105 | + |
| 106 | + let max_min_size = size |
| 107 | + .map(|Size { width, height }| { |
| 108 | + format!( |
| 109 | + "max-width: {width}px; min-width: {min_width}px; height: {height}px;", |
| 110 | + // u64 / 2 will floor the result! |
| 111 | + min_width = width / 2 |
| 112 | + ) |
| 113 | + }) |
| 114 | + .unwrap_or_default(); |
| 115 | + |
| 116 | + format!("<div style=\"position: relative; overflow: hidden; {style}\"> |
| 117 | + <a href=\"{final_target_url}\" target=\"_blank\" onclick=\"{on_click}\" rel=\"noopener noreferrer\"> |
| 118 | + {element_html} |
| 119 | + </a> |
| 120 | + {adex_icon} |
| 121 | + </div>", style=max_min_size, adex_icon=adex_icon()) |
| 122 | +} |
| 123 | + |
| 124 | +/// Generates the HTML for showing an Ad ([`AdUnit`]), as well as, the code for sending the events. |
| 125 | +/// |
| 126 | +/// `no_impression` - whether or not an [`IMPRESSION`] event should be sent with `onload`. |
| 127 | +/// |
| 128 | +/// - [`WAIT_FOR_IMPRESSION`] - The time that needs to pass before sending the [`IMPRESSION`] event to all validators. |
| 129 | +pub fn get_unit_html_with_events( |
| 130 | + options: &Options, |
| 131 | + ad_unit: &AdUnit, |
| 132 | + hostname: &str, |
| 133 | + campaign_id: CampaignId, |
| 134 | + validators: &Validators, |
| 135 | + no_impression: bool, |
| 136 | +) -> String { |
| 137 | + let get_fetch_code = |event_type: EventType| -> String { |
| 138 | + let event = match event_type { |
| 139 | + EventType::Impression => Event::Impression { |
| 140 | + publisher: options.publisher_addr, |
| 141 | + ad_unit: ad_unit.id, |
| 142 | + ad_slot: options.market_slot, |
| 143 | + referrer: Some("document.referrer".to_string()), |
| 144 | + }, |
| 145 | + EventType::Click => Event::Click { |
| 146 | + publisher: options.publisher_addr, |
| 147 | + ad_unit: ad_unit.id, |
| 148 | + ad_slot: options.market_slot, |
| 149 | + referrer: Some("document.referrer".to_string()), |
| 150 | + }, |
| 151 | + }; |
| 152 | + let events_body = InsertEventsRequest { |
| 153 | + events: vec![event], |
| 154 | + }; |
| 155 | + let body = |
| 156 | + serde_json::to_string(&events_body).expect("It should always serialize EventBody"); |
| 157 | + |
| 158 | + // TODO: check whether the JSON body with `''` quotes executes correctly! |
| 159 | + let fetch_opts = format!("var fetchOpts = {{ method: 'POST', headers: {{ 'content-type': 'application/json' }}, body: {body} }};"); |
| 160 | + |
| 161 | + let validators: String = validators |
| 162 | + .iter() |
| 163 | + .map(|validator| { |
| 164 | + let fetch_url = format!( |
| 165 | + "{}/campaign/{}/events?pubAddr={}", |
| 166 | + validator.url, campaign_id, options.publisher_addr |
| 167 | + ); |
| 168 | + |
| 169 | + format!("fetch('{}', fetchOpts)", fetch_url) |
| 170 | + }) |
| 171 | + .collect::<Vec<_>>() |
| 172 | + .join("; "); |
| 173 | + |
| 174 | + format!("{fetch_opts} {validators}") |
| 175 | + }; |
| 176 | + |
| 177 | + let get_timeout_code = |event_type: EventType| -> String { |
| 178 | + format!( |
| 179 | + "setTimeout(function() {{ {code} }}, {timeout})", |
| 180 | + code = get_fetch_code(event_type), |
| 181 | + timeout = WAIT_FOR_IMPRESSION.num_milliseconds() |
| 182 | + ) |
| 183 | + }; |
| 184 | + |
| 185 | + let on_load = if no_impression { |
| 186 | + String::new() |
| 187 | + } else { |
| 188 | + get_timeout_code(IMPRESSION) |
| 189 | + }; |
| 190 | + |
| 191 | + get_unit_html( |
| 192 | + options.size, |
| 193 | + ad_unit, |
| 194 | + hostname, |
| 195 | + &on_load, |
| 196 | + &get_fetch_code(CLICK), |
| 197 | + ) |
| 198 | +} |
| 199 | + |
| 200 | +#[cfg(test)] |
| 201 | +mod test { |
| 202 | + use super::*; |
| 203 | + use adex_primitives::test_util::DUMMY_IPFS; |
| 204 | + |
| 205 | + fn get_ad_unit(media_mime: &str) -> AdUnit { |
| 206 | + AdUnit { |
| 207 | + id: DUMMY_IPFS[0], |
| 208 | + media_url: "".to_string(), |
| 209 | + media_mime: media_mime.to_string(), |
| 210 | + target_url: "".to_string(), |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + #[test] |
| 215 | + fn test_is_video() { |
| 216 | + assert!(is_video(&get_ad_unit("video/avi"))); |
| 217 | + assert!(!is_video(&get_ad_unit("image/jpeg"))); |
| 218 | + } |
| 219 | + |
| 220 | + #[test] |
| 221 | + fn normalization_of_url() { |
| 222 | + // IPFS case |
| 223 | + assert_eq!(format!("{}123", IPFS_GATEWAY), normalize_url("ipfs://123")); |
| 224 | + assert_eq!( |
| 225 | + format!("{}123ipfs://", IPFS_GATEWAY), |
| 226 | + normalize_url("ipfs://123ipfs://") |
| 227 | + ); |
| 228 | + |
| 229 | + // Non-IPFS case |
| 230 | + assert_eq!("http://123".to_string(), normalize_url("http://123")); |
| 231 | + } |
| 232 | + |
| 233 | + mod randomized_sort_pos { |
| 234 | + |
| 235 | + use super::*; |
| 236 | + |
| 237 | + #[test] |
| 238 | + fn test_randomized_position() { |
| 239 | + let ad_unit = AdUnit { |
| 240 | + id: DUMMY_IPFS[0], |
| 241 | + media_url: "ipfs://QmWWQSuPMS6aXCbZKpEjPHPUZN2NjB3YrhJTHsV4X3vb2t".to_string(), |
| 242 | + media_mime: "image/jpeg".to_string(), |
| 243 | + target_url: "https://google.com".to_string(), |
| 244 | + }; |
| 245 | + |
| 246 | + let result = randomized_sort_pos(&ad_unit, 5.into()); |
| 247 | + |
| 248 | + // The seed is responsible for generating different results since the AdUnit IPFS can be the same |
| 249 | + assert_eq!(BigNum::from(177_349_401), result); |
| 250 | + } |
| 251 | + } |
| 252 | +} |
0 commit comments