forked from fenjo26/Orbitra.link
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkclient.php
More file actions
490 lines (443 loc) · 18.9 KB
/
Copy pathkclient.php
File metadata and controls
490 lines (443 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
<?php
/**
* kclient.php — KClient PHP, Keitaro-compatible.
*
* Download: open /kclient.php?download=1 in a browser (the file would otherwise
* execute and return nothing) and save it next to your site's index.php.
*
* Place this file next to your site's index.php and put the tracking code from
* the campaign's Tracking tab in the FIRST lines of index.php (before DOCTYPE):
*
* <?php
* require_once dirname(__FILE__) . '/kclient.php';
* $client = new KClickClient('https://your-tracker.com', 'CAMPAIGN_TOKEN');
* $client->sendAllParams();
* $client->execute();
*
* The client talks to the tracker's Click API v3: one server-side request per
* visit. The tracker resolves the campaign's streams (filters, weights,
* landing/offer split) and answers with instructions this client carries out:
* - a Location header → redirect (executeAndBreak / forceRedirectOffer)
* - a body → stream content ("Show as HTML/text") — echo it, or
* fetch it yourself with getContent()
* - neither → "Do nothing": the visitor stays on your site
*
* The click id (subid) is kept in the PHP session and a cookie, so secondary
* pages can restore it with restoreFromSession() / restoreFromQuery() without
* creating a new click, and conversions can be posted back with the subid.
*
* To measure how long visitors stay — bounces included, not only the ones who
* press the offer button — echo timerScript() once before </body>:
*
* <?php echo $client->timerScript(); ?>
* </body>
*/
// The tracker serves this file to be downloaded — without this guard a direct
// request would simply execute the class definition and return an empty page.
if (isset($_GET['download'])) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="kclient.php"');
header('Content-Length: ' . filesize(__FILE__));
readfile(__FILE__);
exit;
}
class KClickClient
{
/** @var string tracker base URL, no trailing slash */
private $apiBase;
/** @var string campaign token (Campaign editor → Tracking tab) */
private $token;
/** @var array request parameters to pass through to the click */
private $params = [];
/** @var array|null decoded Click API response */
private $response = null;
/** @var string|null resolved Location header from the response */
private $location = null;
/** @var bool add force_redirect_offer=1 (redirect straight to the offer) */
private $forceRedirect = false;
/** @var bool keep the subid in the PHP session */
private $useSessions = true;
/** @var bool print the request log when debug() was called */
private $debug = false;
public function __construct($apiBase, $token)
{
$this->apiBase = rtrim((string) $apiBase, '/');
$this->token = (string) $token;
}
// ------------------------------------------------------------------
// Request building
// ------------------------------------------------------------------
/** Pass the current query string to the tracker (call before execute()). */
public function sendAllParams()
{
foreach ($_GET as $key => $value) {
if ($key === '' || $key[0] === '_') {
continue; // client-internal params (_subid, _new, …)
}
$this->param($key, $value);
}
if (!empty($_SERVER['HTTP_REFERER'])) {
$this->param('se_referrer', $_SERVER['HTTP_REFERER']);
}
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
$this->param('ua', $_SERVER['HTTP_USER_AGENT']);
}
return $this;
}
/** Pass a query-string-like parameter list: `param1=a¶m2=b`. */
public function params($queryString)
{
parse_str((string) $queryString, $parsed);
foreach ((array) $parsed as $key => $value) {
$this->param($key, $value);
}
return $this;
}
public function param($name, $value)
{
$this->params[(string) $name] = $value;
return $this;
}
/** Use $keyword as the click keyword (Keitaro: source placeholder or title). */
public function keyword($keyword)
{
return $this->param('keyword', $keyword);
}
/** Forward utm_* labels from the address bar. */
public function sendUtmLabels()
{
foreach ($_GET as $key => $value) {
if (strpos($key, 'utm_') === 0) {
$this->param($key, $value);
}
}
return $this;
}
/** Send the current page's URL as the referrer. */
public function currentPageAsReferrer()
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
if ($host !== '') {
$this->param('se_referrer', $scheme . '://' . $host . $uri);
}
return $this;
}
/** If the stream selected an offer, redirect to it right away. */
public function forceRedirectOffer()
{
$this->forceRedirect = true;
return $this;
}
/** Stop storing the subid in the PHP session (restore needs it back). */
public function disableSessions()
{
$this->useSessions = false;
return $this;
}
public function debug()
{
$this->debug = true;
return $this;
}
// ------------------------------------------------------------------
// Restore (secondary pages — no new click)
// ------------------------------------------------------------------
/** Restore a previous visit from the session instead of creating a click. */
public function restoreFromSession()
{
$this->startSession();
if (!empty($_SESSION['orbitra_kclient_subid'])) {
$this->clickId = $_SESSION['orbitra_kclient_subid'];
if (!empty($_SESSION['orbitra_kclient_offer'])) {
$this->offerUrl = $_SESSION['orbitra_kclient_offer'];
}
$this->restored = true;
}
return $this;
}
/** Restore from the _subid the tracker appends to the URL it sent the
* visitor to (and that the site carries forward in its own links). */
public function restoreFromQuery()
{
if (!empty($_GET['_subid'])) {
$this->clickId = (string) $_GET['_subid'];
$this->restored = true;
// The URL carries the click, not the offer, so getOffer() would come
// back empty on a secondary page — which is exactly where the
// integration panel tells people to put this. When the same site
// started the visit, the session still holds the offer for THIS
// click; the id comparison is what keeps a stale session from
// handing over some other click's offer.
if ($this->offerUrl === null) {
$this->startSession();
if (!empty($_SESSION['orbitra_kclient_subid'])
&& $_SESSION['orbitra_kclient_subid'] === $this->clickId
&& !empty($_SESSION['orbitra_kclient_offer'])) {
$this->offerUrl = $_SESSION['orbitra_kclient_offer'];
}
}
}
return $this;
}
// ------------------------------------------------------------------
// Execution
// ------------------------------------------------------------------
/** Execute the campaign's instructions; the page keeps running (Do nothing). */
public function execute()
{
$this->perform();
if ($this->forceRedirect && $this->location !== null) {
$this->redirect($this->location);
}
return $this;
}
/** Execute and stop the page when the tracker said redirect / show content. */
public function executeAndBreak()
{
$this->perform();
if ($this->location !== null) {
$this->redirect($this->location);
}
if ($this->response !== null && $this->response['body'] !== null) {
header('Content-Type: ' . ($this->response['contentType'] ?? 'text/html; charset=utf-8'));
echo $this->response['body'];
exit;
}
return $this;
}
/**
* The offer URL of the selected stream, or $default when the tracker did
* not resolve one. A bare id — getOffer(42) — or array('offer_id' => N)
* picks a specific offer of the stream (the URL carries offer_id — the
* tracker honors it on the landing→offer transition).
*
* The bare-id form is what the integration panel documents, so it must
* keep working: an id arrives as an int from PHP code and as a numeric
* string from a template.
*/
public function getOffer($opts = null, $default = null)
{
$this->perform();
$url = $this->offerUrl;
if (is_int($opts) || (is_string($opts) && ctype_digit($opts))) {
$opts = array('offer_id' => (int) $opts);
}
if ($url !== null && is_array($opts) && !empty($opts['offer_id'])) {
// The tracker already put its own offer_id on the transition link,
// so appending a second one leaves the URL carrying both. Take the
// existing one out first and the caller's choice is the only one
// there — a parser that reads the FIRST occurrence would otherwise
// silently send the visitor to the offer the tracker picked.
$url = preg_replace('/([?&])offer_id=[^&]*/', '$1', (string) $url);
$url = preg_replace('/[?&]+$/', '', str_replace(array('?&', '&&'), array('?', '&'), $url));
$sep = strpos($url, '?') === false ? '?' : '&';
$url = $url . $sep . 'offer_id=' . (int) $opts['offer_id'];
}
if ($url !== null && strpos((string) $url, '_lp=1') !== false) {
// Landing time for the tracker's Time-since-LP-click metric. Only
// on the tracker's own transition link — a raw offer URL would pass
// the parameter straight through to the affiliate network.
$elapsed = $this->landingElapsedSeconds();
if ($elapsed !== null) {
$sep = strpos((string) $url, '?') === false ? '?' : '&';
$url = $url . $sep . '_lt=' . $elapsed;
}
}
return $url !== null ? $url : $default;
}
/**
* <script> that reports how long this visitor stayed — bounce included.
*
* getOffer()'s _lt only measures visitors who press the offer button, so a
* page that bores everyone away reports nothing at all. Echo this once
* before </body> and the tracker gets a "Time on LP" (and scroll depth) for
* every visit, in the Logs and as a report dimension:
*
* <?php echo $client->timerScript(); ?>
* </body>
*
* Returns '' when no click was registered — there would be nothing to
* attribute the time to.
*/
public function timerScript()
{
$this->perform();
$clickId = (string) ($this->clickId !== null ? $this->clickId : '');
if ($clickId === '') {
return '';
}
$endpoint = rtrim((string) $this->apiBase, '/') . '/pixel.gif';
return "<script>(function(){var u=" . json_encode($endpoint, JSON_UNESCAPED_SLASHES)
. ",k=" . json_encode($clickId, JSON_UNESCAPED_SLASHES) . ";"
. "var ms=0,mark=Date.now(),off=document.hidden===true,depth=0,sent=-1,sentD=-1;"
. "function acc(){var n=Date.now();if(!off){ms+=n-mark;}mark=n;}"
. "function deep(){try{var d=document.documentElement,b=document.body||{},"
. "h=Math.max(d.scrollHeight||0,b.scrollHeight||0,d.offsetHeight||0),"
. "v=window.innerHeight||d.clientHeight||0,y=window.pageYOffset||d.scrollTop||0,"
. "p=h>v?Math.round(((y+v)/h)*100):100;if(p>depth){depth=p<0?0:(p>100?100:p);}}catch(e){}}"
. "function send(){acc();var t=Math.round(ms/1000);if(t<=sent&&depth<=sentD){return;}"
. "if(t<sent){t=sent;}sent=t;sentD=depth;"
. "var q=u+'?action=lp&subid='+encodeURIComponent(k)+'&t='+t+'&s='+depth+'&_='+Date.now();"
. "try{if(navigator.sendBeacon&&navigator.sendBeacon(q)){return;}}catch(e){}"
. "try{if(window.fetch){fetch(q,{keepalive:true,mode:'no-cors'}).catch(function(){});return;}}catch(e){}"
. "var i=new Image();i.src=q;}"
. "document.addEventListener('visibilitychange',function(){acc();off=document.hidden===true;"
. "mark=Date.now();if(off){deep();send();}});"
. "window.addEventListener('pagehide',function(){deep();send();});"
. "window.addEventListener('beforeunload',function(){deep();send();});"
. "try{window.addEventListener('scroll',deep,{passive:true});}catch(e){window.addEventListener('scroll',deep);}"
. "deep();var steps=[5,15,30,60],i=0;(function next(){var prev=i>0?steps[i-1]:0;"
. "var wait=i<steps.length?(steps[i]-prev):60;setTimeout(function(){i++;deep();send();next();},wait*1000);})();"
. "})();</script>";
}
/** Seconds since this session's visit began — null when unknown (sessions
* disabled, no visit stored yet, or a nonsensical clock). */
private function landingElapsedSeconds()
{
if (!$this->useSessions) {
return null;
}
$this->startSession();
if (empty($_SESSION['orbitra_kclient_visit_ts']) || !is_numeric($_SESSION['orbitra_kclient_visit_ts'])) {
return null;
}
$elapsed = time() - (int) $_SESSION['orbitra_kclient_visit_ts'];
if ($elapsed <= 0) {
return null;
}
return min($elapsed, 604800);
}
/** Stream content ("Show as HTML/text") — banner blocks, content injection. */
public function getContent()
{
$this->perform();
return $this->response['body'] ?? null;
}
public function getBody() { return $this->getContent(); }
public function getSubid() { $this->perform(); return $this->clickId; }
public function getHeaders() { $this->perform(); return $this->response['headers'] ?? []; }
/** Bot / uniqueness verdicts are decided server-side on redirect visits;
* the Click API does not expose them yet — null means "not available". */
public function isBot() { return null; }
public function isUnique($type = 'campaign') { return null; }
/** Tracker-side conversion URL for this click (email pixels, thank-you pages). */
public function getConversionUrl($status = 'lead', $payout = 0)
{
$this->perform();
if ($this->clickId === null) {
return null;
}
return $this->apiBase . '/pixel.gif?action=conversion'
. '&subid=' . urlencode($this->clickId)
. '&status=' . urlencode((string) $status)
. '&payout=' . urlencode((string) $payout);
}
// ------------------------------------------------------------------
// Internals
// ------------------------------------------------------------------
private $clickId = null;
private $offerUrl = null;
private $restored = false;
private $executed = false;
private function perform()
{
if ($this->executed || $this->restored) {
return;
}
$this->executed = true;
$query = [
'token' => $this->token,
'info' => '1',
];
if ($this->forceRedirect) {
$query['force_redirect_offer'] = '1';
}
foreach ($this->params as $key => $value) {
$query[(string) $key] = $value;
}
$url = $this->apiBase . '/click_api/v3?' . http_build_query($query);
$raw = $this->httpGet($url);
if ($this->debug) {
echo '<!-- KClickClient request: ' . htmlspecialchars($url, ENT_QUOTES) . ' -->';
}
$decoded = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($decoded)) {
if ($this->debug) {
echo '<!-- KClickClient: no response from tracker -->';
}
return;
}
$this->response = $decoded;
foreach ((array) ($decoded['headers'] ?? []) as $header) {
if (stripos((string) $header, 'Location:') === 0) {
$this->location = trim(substr((string) $header, 9));
break;
}
}
$info = $decoded['info'] ?? [];
if (!empty($info['sub_id'])) {
$this->clickId = (string) $info['sub_id'];
$this->startSession();
$_SESSION['orbitra_kclient_subid'] = $this->clickId;
// When this visit began — the source of the _lt landing-time
// parameter getOffer() appends to the tracker's transition link.
if (empty($_SESSION['orbitra_kclient_visit_ts'])) {
$_SESSION['orbitra_kclient_visit_ts'] = time();
}
@setcookie('orbitra_subid', $this->clickId, time() + 86400, '/');
}
// offer_link is the signed tracker-side transition (continues this click);
// url is the raw offer template — both valid, the signed one is better.
if (!empty($info['offer_link'])) {
$this->offerUrl = (string) $info['offer_link'];
} elseif (!empty($info['url'])) {
$this->offerUrl = (string) $info['url'];
}
if ($this->offerUrl !== null) {
$this->startSession();
$_SESSION['orbitra_kclient_offer'] = $this->offerUrl;
}
}
private function redirect($url)
{
header('Location: ' . $url, true, 302);
exit;
}
private function startSession()
{
if (!$this->useSessions) {
return;
}
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
}
/** @return string|null */
private function httpGet($url)
{
$timeout = 8;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
$body = curl_exec($ch);
$err = curl_error($ch);
// curl_close() is a no-op since PHP 8 and deprecated since 8.5 — skip it.
if ($body === false) {
if ($this->debug) {
echo '<!-- KClickClient curl error: ' . htmlspecialchars($err, ENT_QUOTES) . ' -->';
}
return null;
}
return (string) $body;
}
$ctx = stream_context_create(['http' => ['timeout' => $timeout]]);
$result = @file_get_contents($url, false, $ctx);
return is_string($result) ? $result : null;
}
}