diff --git a/GeoLite2-Country.mmdb b/GeoLite2-Country.mmdb new file mode 100755 index 0000000..8bae0b9 Binary files /dev/null and b/GeoLite2-Country.mmdb differ diff --git a/application/admin/command/Install/split_link_pixel.sql b/application/admin/command/Install/split_link_pixel.sql new file mode 100644 index 0000000..3c45c32 --- /dev/null +++ b/application/admin/command/Install/split_link_pixel.sql @@ -0,0 +1,12 @@ +-- 分流链接:像素配置 JSON 字段 + 权限节点 +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_link` + ADD COLUMN `pixel_config` mediumtext COMMENT '像素配置JSON' AFTER `auto_reply`; + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.link/pixel', '像素配置', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.link' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/pixel' LIMIT 1) +LIMIT 1; diff --git a/application/admin/controller/split/Link.php b/application/admin/controller/split/Link.php index 93ce85e..9d4eb94 100644 --- a/application/admin/controller/split/Link.php +++ b/application/admin/controller/split/Link.php @@ -9,6 +9,7 @@ use app\common\controller\Backend; use app\common\library\CountryIso; use app\common\service\SplitAutoReplyService; use app\common\service\SplitLinkCodeService; +use app\common\service\SplitPixelConfigService; use app\common\service\SplitPlatformDomainService; use think\Db; use think\Exception; @@ -224,8 +225,8 @@ class Link extends Backend $this->success('', null, [ 'platform_domains' => $platformDomains, 'my_domains' => $myDomains, - 'domain_index_url' => (string) url('domain/index'), - 'domain_add_url' => (string) url('domain/add'), + 'domain_index_url' => (string) url('domain', '', false, false), + 'domain_add_url' => (string) url('domain/add', '', false, false), 'config_index_url' => (string) url('general/config/index'), ]); } @@ -284,6 +285,58 @@ class Link extends Backend ]); } + /** + * 像素配置:读取 / 保存 + * + * @param string|null $ids 链接 ID + * @return Json + * @throws DbException + */ + public function pixel($ids = null): Json + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + $this->error(__('You have no permission')); + } + + if ($this->request->isPost()) { + $payload = $this->request->post('pixel_config/a', []); + if ($payload === []) { + $raw = (string) $this->request->post('pixel_config', ''); + $decoded = json_decode($raw, true); + $payload = is_array($decoded) ? $decoded : []; + } + $existing = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config')); + try { + $merged = SplitPixelConfigService::mergeForSave($payload, $existing); + $row->save([ + 'pixel_config' => SplitPixelConfigService::encodeStorage($merged), + ]); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + $this->error($e->getMessage()); + } + $this->success(__('Pixel config saved')); + } + + $config = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config')); + $masked = SplitPixelConfigService::maskForAdmin($config); + + $this->success('', null, [ + 'id' => (int) $row['id'], + 'link_code' => (string) $row['link_code'], + 'facebook' => $masked[SplitPixelConfigService::PLATFORM_FACEBOOK], + 'tiktok' => $masked[SplitPixelConfigService::PLATFORM_TIKTOK], + 'event_options' => SplitPixelConfigService::EVENT_OPTIONS, + ]); + } + /** * 编辑(分流链接码不可修改) * diff --git a/application/admin/lang/zh-cn/split/link.php b/application/admin/lang/zh-cn/split/link.php index 08e2eed..843d2b9 100644 --- a/application/admin/lang/zh-cn/split/link.php +++ b/application/admin/lang/zh-cn/split/link.php @@ -18,6 +18,7 @@ return [ 'Status hidden' => '停用', 'Copy split link' => '复制分流链接', 'Manage my domains' => '管理我的域名', + 'Domain management' => '域名管理', 'Select main domain' => '选择主域名', 'Platform assigned domain' => '平台分配域名', 'My domains' => '我的域名', @@ -39,4 +40,26 @@ return [ 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联', 'Auto reply saved' => '自动回复已保存', 'Reply statements column' => '回复语', + 'NS' => 'NS', + 'DNS' => 'DNS', + 'Pixel config' => '像素配置', + 'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access Token,Token 留空会保留原配置。', + 'Facebook Pixel' => 'Facebook Pixel', + 'TikTok Pixel' => 'TikTok Pixel', + 'Add FB Pixel' => '+ 添加FB Pixel', + 'Add TK Pixel' => '+ 添加TK Pixel', + 'Pixel enabled' => '启用', + 'Server postback' => '服务端回传', + 'Pixel ID' => 'Pixel ID', + 'Access Token' => 'Access Token', + 'Test code' => '测试码', + 'Pixel event' => '事件', + 'Pixel sort' => '排序', + 'Pixel config saved' => '像素配置已保存', + 'Pixel ID required' => '请填写 Pixel ID', + 'Remove row' => '删除', + 'Optional' => '选填', + 'Pixel list empty' => '暂无配置,请点击上方按钮添加', + 'Pixel row index' => '序号', + 'Operate' => '操作', ]; diff --git a/application/admin/model/split/Link.php b/application/admin/model/split/Link.php index 3bb6342..0773a4b 100644 --- a/application/admin/model/split/Link.php +++ b/application/admin/model/split/Link.php @@ -94,15 +94,11 @@ class Link extends Model } /** - * 列表「回复语」列展示(多行合并为单行,供省略号截断) + * 列表「回复语」列预览(最多 50 字 + ...,悬停用原始 auto_reply 换行展示) */ public function getAutoReplyTextAttr($value, $data): string { - $lines = \app\common\service\SplitAutoReplyService::parseLines((string)($data['auto_reply'] ?? '')); - if ($lines === []) { - return ''; - } - return implode(';', $lines); + return \app\common\service\SplitAutoReplyService::previewForList((string) ($data['auto_reply'] ?? '')); } public function getIpProtectTextAttr($value, $data): string diff --git a/application/common.php b/application/common.php index 3dc14f0..2b4e301 100755 --- a/application/common.php +++ b/application/common.php @@ -507,3 +507,10 @@ EOT; return $icon; } } + +if (!function_exists('raw')) { + function raw($str) + { + return $str; + } +} \ No newline at end of file diff --git a/application/common/service/SplitAutoReplyService.php b/application/common/service/SplitAutoReplyService.php index 26c0287..b057801 100644 --- a/application/common/service/SplitAutoReplyService.php +++ b/application/common/service/SplitAutoReplyService.php @@ -15,6 +15,29 @@ class SplitAutoReplyService /** 单条最大字符数 */ private const MAX_LINE_LENGTH = 500; + /** 列表预览最大字符数 */ + private const LIST_PREVIEW_MAX = 20; + + /** + * 列表单元格预览(单行最多 50 字,超出加 ...) + */ + public static function previewForList(string $raw, int $max = self::LIST_PREVIEW_MAX): string + { + $display = self::formatDisplay($raw); + if ($display === '') { + return ''; + } + $flat = preg_replace('/\s+/u', ' ', str_replace(["\r\n", "\r", "\n"], ' ', $display)); + $flat = trim((string) $flat); + if ($flat === '') { + return ''; + } + if (mb_strlen($flat, 'UTF-8') <= $max) { + return $flat; + } + return mb_substr($flat, 0, $max, 'UTF-8') . '...'; + } + /** * 解析为多行文本数组 * diff --git a/application/common/service/SplitFriendUrlBuilder.php b/application/common/service/SplitFriendUrlBuilder.php new file mode 100644 index 0000000..ceb427f --- /dev/null +++ b/application/common/service/SplitFriendUrlBuilder.php @@ -0,0 +1,92 @@ +country($ip); + $code = $record->country->isoCode ?? null; + if (!is_string($code) || $code === '') { + return null; + } + + return strtoupper($code); + } catch (AddressNotFoundException $e) { + return null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * MMDB 绝对路径 + */ + public static function getDatabasePath(): string + { + return rtrim((string) ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . self::DB_FILENAME; + } + + public static function isDatabaseAvailable(): bool + { + $path = self::getDatabasePath(); + + return is_file($path) && is_readable($path); + } + + private static function bootstrapLibrary(): void + { + static $bootstrapped = false; + if ($bootstrapped) { + return; + } + $bootstrapped = true; + $loader = ROOT_PATH . 'patches/third_party/load_geoip2.php'; + if (is_file($loader)) { + require_once $loader; + } + } + + private static function getReader(): Reader + { + if (self::$reader instanceof Reader) { + return self::$reader; + } + if (!self::isDatabaseAvailable()) { + throw new \RuntimeException('GeoLite2-Country.mmdb not readable at project root'); + } + self::$reader = new Reader(self::getDatabasePath()); + + return self::$reader; + } +} diff --git a/application/common/service/SplitIpProtectService.php b/application/common/service/SplitIpProtectService.php new file mode 100644 index 0000000..835a73a --- /dev/null +++ b/application/common/service/SplitIpProtectService.php @@ -0,0 +1,60 @@ + 大写 ISO2 列表 + */ + public static function parseAllowedCountries(string $storage): array + { + if (trim($storage) === '') { + return []; + } + $result = []; + foreach (explode(',', $storage) as $code) { + $code = strtoupper(trim($code)); + if ($code !== '' && !in_array($code, $result, true)) { + $result[] = $code; + } + } + + return $result; + } +} diff --git a/application/common/service/SplitPixelBrowserRenderer.php b/application/common/service/SplitPixelBrowserRenderer.php new file mode 100644 index 0000000..ad14812 --- /dev/null +++ b/application/common/service/SplitPixelBrowserRenderer.php @@ -0,0 +1,171 @@ +>, tiktok: array>} $config + * @return array{ + * head_html: string, + * body_html: string, + * track_lines: array, + * track_jobs: array> + * } + */ + public static function render(array $config): array + { + $fbRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK); + $tkRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK); + + $head = []; + $initLines = []; + $trackLines = []; + $jobs = []; + + if ($fbRows !== []) { + $head[] = self::facebookLoaderScript(); + } + if ($tkRows !== []) { + $head[] = self::tiktokLoaderScript(); + } + + foreach ($fbRows as $row) { + $pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? '')); + $testCode = trim((string) ($row['test_code'] ?? '')); + $event = self::escapeJsString((string) ($row['event'] ?? 'PageView')); + if ($pixelId === '') { + continue; + } + if ($testCode !== '') { + $testEsc = self::escapeJsString($testCode); + $initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}',{},{test_event_code:'{$testEsc}'});}"; + } else { + $initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}');}"; + } + $jobs[] = [ + 'platform' => 'facebook', + 'event' => (string) ($row['event'] ?? 'PageView'), + 'pixel_id' => (string) ($row['pixel_id'] ?? ''), + ]; + $trackLines[] = "if(typeof fbq!=='undefined'){fbq('track','{$event}');}"; + } + + foreach ($tkRows as $row) { + $pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? '')); + $event = self::mapTikTokBrowserEvent((string) ($row['event'] ?? 'PageView')); + $eventJs = self::escapeJsString($event); + if ($pixelId === '') { + continue; + } + $initLines[] = "if(typeof ttq!=='undefined'){ttq.load('{$pixelId}');ttq.page();}"; + $jobs[] = [ + 'platform' => 'tiktok', + 'event' => $event, + 'pixel_id' => (string) ($row['pixel_id'] ?? ''), + ]; + $trackLines[] = "if(typeof ttq!=='undefined'){ttq.track('{$eventJs}');}"; + } + + return [ + 'head_html' => implode("\n", $head), + 'body_html' => self::wrapScriptBlock($initLines), + 'track_lines' => $trackLines, + 'track_jobs' => $jobs, + ]; + } + + /** + * 跳转 orchestrator:在 setTimeout 回调内触发像素事件后再 replace + * + * @param array $trackLines + */ + public static function renderRedirectOrchestrator(string $redirectUrlJson, array $trackLines = [], int $maxWaitMs = 1500): string + { + $trackBlock = $trackLines !== [] ? implode("\n ", $trackLines) : ''; + $script = self::renderRedirectOrchestratorScript(); + + return str_replace( + ['{$redirectUrlJson}', '{$maxWaitMs}', '{$trackBlock}'], + [$redirectUrlJson, (string) $maxWaitMs, $trackBlock], + $script + ); + } + + private static function renderRedirectOrchestratorScript(): string + { + return <<<'JS' + +JS; + } + + /** + * @param array $lines + */ + private static function wrapScriptBlock(array $lines): string + { + if ($lines === []) { + return ''; + } + + return ''; + } + + private static function facebookLoaderScript(): string + { + return <<<'HTML' + +HTML; + } + + private static function tiktokLoaderScript(): string + { + return <<<'HTML' + +HTML; + } + + private static function mapTikTokBrowserEvent(string $event): string + { + $map = [ + 'PageView' => 'Pageview', + 'Lead' => 'SubmitForm', + 'Contact' => 'Contact', + 'AddToCart' => 'AddToCart', + 'Purchase' => 'CompletePayment', + 'Subscribe' => 'Subscribe', + ]; + + return $map[$event] ?? 'Pageview'; + } + + private static function escapeJsString(string $value): string + { + return str_replace(['\\', "'"], ['\\\\', "\\'"], $value); + } +} diff --git a/application/common/service/SplitPixelConfigService.php b/application/common/service/SplitPixelConfigService.php new file mode 100644 index 0000000..0151cea --- /dev/null +++ b/application/common/service/SplitPixelConfigService.php @@ -0,0 +1,250 @@ +>, tiktok: array>} + */ + public static function parseStorage(?string $raw): array + { + $empty = [ + self::PLATFORM_FACEBOOK => [], + self::PLATFORM_TIKTOK => [], + ]; + if ($raw === null || trim($raw) === '') { + return $empty; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $empty; + } + + return [ + self::PLATFORM_FACEBOOK => self::normalizePlatformList( + $decoded[self::PLATFORM_FACEBOOK] ?? [], + self::PLATFORM_FACEBOOK + ), + self::PLATFORM_TIKTOK => self::normalizePlatformList( + $decoded[self::PLATFORM_TIKTOK] ?? [], + self::PLATFORM_TIKTOK + ), + ]; + } + + /** + * 合并 POST 数据与已有配置(Token / 测试码留空保留原值) + * + * @param array $incoming + * @param array $existing + * @return array{facebook: array>, tiktok: array>} + */ + public static function mergeForSave(array $incoming, array $existing): array + { + $existingMap = self::indexById($existing); + $result = [ + self::PLATFORM_FACEBOOK => [], + self::PLATFORM_TIKTOK => [], + ]; + + foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) { + $rows = is_array($incoming[$platform] ?? null) ? $incoming[$platform] : []; + if (count($rows) > self::MAX_ITEMS_PER_PLATFORM) { + throw new \InvalidArgumentException('像素配置数量超出上限'); + } + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $normalized = self::normalizeRow($row, $platform); + $id = (string) ($normalized['id'] ?? ''); + if ($id !== '' && isset($existingMap[$id])) { + $old = $existingMap[$id]; + if (trim((string) ($normalized['access_token'] ?? '')) === '' + || strpos((string) ($normalized['access_token'] ?? ''), '***') !== false) { + $normalized['access_token'] = (string) ($old['access_token'] ?? ''); + } + if (trim((string) ($normalized['test_code'] ?? '')) === '') { + $normalized['test_code'] = (string) ($old['test_code'] ?? ''); + } + } + if (trim((string) ($normalized['pixel_id'] ?? '')) === '') { + continue; + } + $result[$platform][] = $normalized; + } + usort($result[$platform], static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + } + + return $result; + } + + /** + * @param array{facebook: array>, tiktok: array>} $config + */ + public static function encodeStorage(array $config): string + { + $payload = [ + self::PLATFORM_FACEBOOK => $config[self::PLATFORM_FACEBOOK] ?? [], + self::PLATFORM_TIKTOK => $config[self::PLATFORM_TIKTOK] ?? [], + ]; + + return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; + } + + /** + * 管理端 GET:脱敏 access_token + * + * @param array{facebook: array>, tiktok: array>} $config + * @return array{facebook: array>, tiktok: array>} + */ + public static function maskForAdmin(array $config): array + { + $mask = static function (array $rows): array { + $out = []; + foreach ($rows as $row) { + $item = $row; + $token = (string) ($item['access_token'] ?? ''); + $item['access_token'] = $token === '' ? '' : self::maskToken($token); + $item['has_access_token'] = $token !== ''; + unset($item['access_token_raw']); + $out[] = $item; + } + + return $out; + }; + + return [ + self::PLATFORM_FACEBOOK => $mask($config[self::PLATFORM_FACEBOOK] ?? []), + self::PLATFORM_TIKTOK => $mask($config[self::PLATFORM_TIKTOK] ?? []), + ]; + } + + /** + * 获取已启用且按 sort 排序的条目(中转页 / 服务端回传) + * + * @param array{facebook: array>, tiktok: array>} $config + * @return array> + */ + public static function getEnabledSorted(array $config, string $platform): array + { + $rows = $config[$platform] ?? []; + $enabled = []; + foreach ($rows as $row) { + if ((int) ($row['enabled'] ?? 0) === 1) { + $enabled[] = $row; + } + } + usort($enabled, static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + + return $enabled; + } + + public static function maskToken(string $token): string + { + $len = strlen($token); + if ($len <= 8) { + return '***'; + } + + return substr($token, 0, 3) . '***' . substr($token, -3); + } + + /** + * @param array> $rows + * @return array> + */ + private static function normalizePlatformList(array $rows, string $platform): array + { + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $normalized = self::normalizeRow($row, $platform); + if (trim((string) ($normalized['pixel_id'] ?? '')) === '') { + continue; + } + $result[] = $normalized; + } + usort($result, static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + + return $result; + } + + /** + * @param array $row + * @return array + */ + private static function normalizeRow(array $row, string $platform): array + { + $event = (string) ($row['event'] ?? 'PageView'); + if (!in_array($event, self::EVENT_OPTIONS, true)) { + $event = 'PageView'; + } + $id = trim((string) ($row['id'] ?? '')); + if ($id === '') { + $id = $platform . '_' . uniqid('', true); + } + + return [ + 'id' => $id, + 'enabled' => (int) ($row['enabled'] ?? 0) === 1 ? 1 : 0, + 'server_postback' => (int) ($row['server_postback'] ?? 0) === 1 ? 1 : 0, + 'pixel_id' => trim((string) ($row['pixel_id'] ?? '')), + 'access_token' => trim((string) ($row['access_token'] ?? '')), + 'test_code' => trim((string) ($row['test_code'] ?? '')), + 'event' => $event, + 'sort' => max(0, (int) ($row['sort'] ?? 0)), + ]; + } + + /** + * @param array{facebook: array>, tiktok: array>} $config + * @return array> + */ + private static function indexById(array $config): array + { + $map = []; + foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) { + foreach ($config[$platform] ?? [] as $row) { + $id = (string) ($row['id'] ?? ''); + if ($id !== '') { + $map[$id] = $row; + } + } + } + + return $map; + } +} diff --git a/application/common/service/SplitPixelPostbackService.php b/application/common/service/SplitPixelPostbackService.php new file mode 100644 index 0000000..7671698 --- /dev/null +++ b/application/common/service/SplitPixelPostbackService.php @@ -0,0 +1,169 @@ +>, tiktok: array>} $config + */ + public static function dispatch(array $config, string $clientIp, string $userAgent, string $eventSourceUrl = ''): void + { + foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK) as $row) { + if ((int) ($row['server_postback'] ?? 0) !== 1) { + continue; + } + $token = trim((string) ($row['access_token'] ?? '')); + if ($token === '') { + continue; + } + self::sendFacebook($row, $token, $clientIp, $userAgent, $eventSourceUrl); + } + + foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK) as $row) { + if ((int) ($row['server_postback'] ?? 0) !== 1) { + continue; + } + $token = trim((string) ($row['access_token'] ?? '')); + if ($token === '') { + continue; + } + self::sendTikTok($row, $token, $clientIp, $userAgent, $eventSourceUrl); + } + } + + /** + * @param array $row + */ + private static function sendFacebook(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void + { + $pixelId = trim((string) ($row['pixel_id'] ?? '')); + if ($pixelId === '') { + return; + } + + $eventName = (string) ($row['event'] ?? 'PageView'); + $testCode = trim((string) ($row['test_code'] ?? '')); + + $payload = [ + 'data' => [[ + 'event_name' => $eventName, + 'event_time' => time(), + 'action_source' => 'website', + 'user_data' => array_filter([ + 'client_ip_address' => $clientIp !== '' ? $clientIp : null, + 'client_user_agent' => $userAgent !== '' ? $userAgent : null, + ]), + ]], + ]; + if ($eventSourceUrl !== '') { + $payload['data'][0]['event_source_url'] = $eventSourceUrl; + } + if ($testCode !== '') { + $payload['test_event_code'] = $testCode; + } + + $url = 'https://graph.facebook.com/v19.0/' . rawurlencode($pixelId) . '/events?access_token=' . rawurlencode($accessToken); + self::postJson($url, $payload, [], 'facebook', $pixelId); + } + + /** + * @param array $row + */ + private static function sendTikTok(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void + { + $pixelId = trim((string) ($row['pixel_id'] ?? '')); + if ($pixelId === '') { + return; + } + + $event = self::mapTikTokServerEvent((string) ($row['event'] ?? 'PageView')); + $testCode = trim((string) ($row['test_code'] ?? '')); + + $payload = [ + 'pixel_code' => $pixelId, + 'event' => $event, + 'event_id' => uniqid('split_', true), + 'timestamp' => gmdate('c'), + 'context' => array_filter([ + 'ip' => $clientIp !== '' ? $clientIp : null, + 'user_agent' => $userAgent !== '' ? $userAgent : null, + 'page' => $eventSourceUrl !== '' ? ['url' => $eventSourceUrl] : null, + ]), + ]; + if ($testCode !== '') { + $payload['test_event_code'] = $testCode; + } + + $url = 'https://business-api.tiktok.com/open_api/v1.3/event/track/'; + self::postJson($url, $payload, [ + 'Access-Token: ' . $accessToken, + 'Content-Type: application/json', + ], 'tiktok', $pixelId); + } + + private static function mapTikTokServerEvent(string $event): string + { + $map = [ + 'PageView' => 'Pageview', + 'Lead' => 'SubmitForm', + 'Contact' => 'Contact', + 'AddToCart' => 'AddToCart', + 'Purchase' => 'CompletePayment', + 'Subscribe' => 'Subscribe', + ]; + + return $map[$event] ?? 'Pageview'; + } + + /** + * @param array $payload + * @param array $headers + */ + private static function postJson(string $url, array $payload, array $headers, string $platform, string $pixelId): void + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return; + } + + $ch = curl_init($url); + if ($ch === false) { + return; + } + + $defaultHeaders = ['Content-Type: application/json']; + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + CURLOPT_HTTPHEADER => array_merge($defaultHeaders, $headers), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 3, + CURLOPT_CONNECTTIMEOUT => 2, + ]); + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno !== 0 || ($httpCode >= 400 && $httpCode !== 0)) { + Log::error(sprintf( + 'Split pixel postback failed platform=%s pixel=%s http=%d curl=%d', + $platform, + $pixelId, + $httpCode, + $errno + )); + } elseif (is_string($response) && strpos($response, '"error"') !== false) { + Log::error(sprintf('Split pixel postback api error platform=%s pixel=%s', $platform, $pixelId)); + } + } +} diff --git a/application/common/service/SplitRedirectService.php b/application/common/service/SplitRedirectService.php new file mode 100644 index 0000000..1086b80 --- /dev/null +++ b/application/common/service/SplitRedirectService.php @@ -0,0 +1,87 @@ +roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore(); + } + + /** + * 根据链接码解析本次应跳转的加好友 URL;无效时返回 null + * + * @param string $linkCode 9 位链接码 + * @param string $clientIp 访客 IP(IP 防护开启时用于国家校验) + */ + public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string + { + $linkCode = SplitLinkCodeService::normalize($linkCode); + if (!SplitLinkCodeService::isValidFormat($linkCode)) { + return null; + } + + $link = Link::where('link_code', $linkCode) + ->where('status', 'normal') + ->find(); + if (!$link) { + return null; + } + + $ipProtect = (int) $link->getAttr('ip_protect'); + $countries = (string) $link->getAttr('countries'); + if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) { + return null; + } + + /** @var Collection|array $numbers */ + $numbers = Number::where('split_link_id', (int) $link['id']) + ->where('status', 'normal') + ->order('id', 'asc') + ->field('id,number,number_type,number_type_custom') + ->select(); + + $count = is_countable($numbers) ? count($numbers) : 0; + if ($count === 0) { + return null; + } + + $linkId = (int) $link->getAttr('id'); + $index = $this->roundRobinStore->nextIndex($linkId, $count); + $list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; + $picked = $list[$index] ?? $list[0] ?? null; + if ($picked === null) { + return null; + } + + $numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type'); + $numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number'); + $numberCustom = is_array($picked) + ? (string) ($picked['number_type_custom'] ?? '') + : (string) $picked->getAttr('number_type_custom'); + + $redirectUrl = SplitFriendUrlBuilder::build($numberType, $numberValue, $numberCustom); + if ($redirectUrl === '') { + return null; + } + + $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); + if ($numberId > 0) { + Number::where('id', $numberId)->setInc('visit_count'); + } + + return $redirectUrl; + } +} diff --git a/application/common/service/SplitRoundRobinStore.php b/application/common/service/SplitRoundRobinStore.php new file mode 100644 index 0000000..2012988 --- /dev/null +++ b/application/common/service/SplitRoundRobinStore.php @@ -0,0 +1,133 @@ +ensureRedis()) { + $key = self::KEY_PREFIX . $splitLinkId; + $seq = (int) $this->redis->incr($key); + if ($seq <= 0) { + $seq = 1; + } + + return ($seq - 1) % $numberCount; + } + + return $this->nextIndexFallback($splitLinkId, $numberCount); + } + + /** + * 尝试连接 Redis(配置来自 queue 扩展) + */ + private function ensureRedis(): bool + { + if ($this->redisAttempted) { + return $this->redisReady; + } + $this->redisAttempted = true; + + if (!extension_loaded('redis')) { + return false; + } + + $config = Config::get('queue'); + if (!is_array($config) || ($config['connector'] ?? '') !== 'Redis') { + $appPath = defined('APP_PATH') ? APP_PATH : (defined('ROOT_PATH') ? ROOT_PATH . 'application/' : ''); + $file = $appPath . 'extra/queue.php'; + if (is_file($file)) { + $loaded = include $file; + $config = is_array($loaded) ? $loaded : []; + } else { + $config = []; + } + } + + $host = (string) ($config['host'] ?? '127.0.0.1'); + $port = (int) ($config['port'] ?? 6379); + $password = (string) ($config['password'] ?? ''); + $select = (int) ($config['select'] ?? 0); + $timeout = (float) ($config['timeout'] ?? 1.0); + + try { + $redis = new \Redis(); + $connected = $timeout > 0 + ? @$redis->connect($host, $port, $timeout) + : @$redis->connect($host, $port); + if (!$connected) { + return false; + } + if ($password !== '') { + if (!$redis->auth($password)) { + return false; + } + } + if ($select > 0) { + $redis->select($select); + } + $this->redis = $redis; + $this->redisReady = true; + } catch (\Throwable $e) { + $this->redisReady = false; + } + + return $this->redisReady; + } + + /** + * 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis) + */ + private function nextIndexFallback(int $splitLinkId, int $numberCount): int + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_rr/'; + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + return 0; + } + $file = $dir . $splitLinkId . '.cnt'; + $fp = @fopen($file, 'c+'); + if ($fp === false) { + return 0; + } + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return 0; + } + $raw = stream_get_contents($fp); + $seq = (int) $raw; + $seq++; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, (string) $seq); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + + return ($seq - 1) % $numberCount; + } +} diff --git a/application/common/service/SplitVisitPageService.php b/application/common/service/SplitVisitPageService.php new file mode 100644 index 0000000..94f9ba9 --- /dev/null +++ b/application/common/service/SplitVisitPageService.php @@ -0,0 +1,58 @@ +resolveRedirectUrl($linkCode, $clientIp); + if ($redirectUrl === null || $redirectUrl === '') { + return null; + } + + $link = Link::where('link_code', SplitLinkCodeService::normalize($linkCode)) + ->where('status', 'normal') + ->field('id,pixel_config') + ->find(); + + $config = SplitPixelConfigService::parseStorage( + $link ? (string) $link->getAttr('pixel_config') : '' + ); + + SplitPixelPostbackService::dispatch($config, $clientIp, $userAgent, $pageUrl); + + $pixel = SplitPixelBrowserRenderer::render($config); + $redirectJson = json_encode( + $redirectUrl, + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES + ); + + return [ + 'redirect_url' => $redirectUrl, + 'redirect_url_json' => $redirectJson ?: '""', + 'pixel_head_html' => $pixel['head_html'], + 'pixel_body_html' => $pixel['body_html'], + 'orchestrator_html' => SplitPixelBrowserRenderer::renderRedirectOrchestrator( + $redirectJson ?: '""', + $pixel['track_lines'] ?? [] + ), + ]; + } +} diff --git a/application/config.php b/application/config.php index ee4b318..5750507 100755 --- a/application/config.php +++ b/application/config.php @@ -183,16 +183,27 @@ return [ // +---------------------------------------------------------------------- // | 缓存设置 // +---------------------------------------------------------------------- - 'cache' => [ - // 驱动方式 - 'type' => 'File', - // 缓存保存目录 - 'path' => CACHE_PATH, - // 缓存前缀 - 'prefix' => '', - // 缓存有效期 0表示永久缓存 - 'expire' => 0, + // 'cache' => [ + // // 驱动方式 + // 'type' => 'File', + // // 缓存保存目录 + // 'path' => CACHE_PATH, + // // 缓存前缀 + // 'prefix' => '', + // // 缓存有效期 0表示永久缓存 + // 'expire' => 0, + // ], + + 'cache' => [ + 'type' => 'redis', // 修改为 redis + 'host' => '127.0.0.1', // Redis 服务器地址 + 'port' => 6379, // Redis 端口 + 'password' => '', // Redis 密码,若无则留空 + 'select' => 0, // 数据库索引 + 'expire' => 0, // 缓存有效期 0表示永久 + 'timeout' => 0, // 超时时间 ], + // +---------------------------------------------------------------------- // | 会话设置 // +---------------------------------------------------------------------- diff --git a/application/index/controller/S.php b/application/index/controller/S.php new file mode 100644 index 0000000..29518b6 --- /dev/null +++ b/application/index/controller/S.php @@ -0,0 +1,20 @@ +visit($link_code); + } +} diff --git a/application/index/controller/Split.php b/application/index/controller/Split.php new file mode 100644 index 0000000..d59e94f --- /dev/null +++ b/application/index/controller/Split.php @@ -0,0 +1,84 @@ +abortNotFound(); + } + + $page = SplitVisitPageService::build( + $code, + (string) $this->request->ip(), + (string) $this->request->server('HTTP_USER_AGENT', ''), + (string) $this->request->url(true) + ); + if ($page === null) { + $this->abortNotFound(); + } + + $this->view->assign('redirect_url', $page['redirect_url']); + $this->view->assign('redirect_url_json', $page['redirect_url_json']); + $this->view->assign('pixel_head_html', $page['pixel_head_html']); + $this->view->assign('pixel_body_html', $page['pixel_body_html']); + $this->view->assign('orchestrator_html', $page['orchestrator_html']); + + return $this->fetchPatch('visit'); + } + + /** + * 渲染模板(优先 patches 视图,回退 application) + */ + private function fetchPatch(string $template): string + { + $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; + $appFile = APP_PATH . 'index/view/split/' . $template . '.html'; + if (is_file($patchFile)) { + $file = $patchFile; + } elseif (is_file($appFile)) { + $file = $appFile; + } else { + throw new HttpException(500, 'Template not found'); + } + + return (string) $this->view->fetch($file); + } + + /** + * 404:输出无中文的空白页,避免框架默认中文错误页 + */ + private function abortNotFound(): void + { + throw new HttpResponseException(response($this->fetchPatch('not_found'), 404)); + } +} diff --git a/application/index/view/split/not_found.html b/application/index/view/split/not_found.html new file mode 100644 index 0000000..757bf6d --- /dev/null +++ b/application/index/view/split/not_found.html @@ -0,0 +1,9 @@ + + + + + + 404 + + + diff --git a/application/index/view/split/visit.html b/application/index/view/split/visit.html new file mode 100644 index 0000000..fee2eef --- /dev/null +++ b/application/index/view/split/visit.html @@ -0,0 +1,17 @@ + + + + + + + + {$pixel_head_html|raw} + + +{$pixel_body_html|raw} +{$orchestrator_html|raw} + + + diff --git a/application/route.php b/application/route.php index 8d93637..1a54c9c 100755 --- a/application/route.php +++ b/application/route.php @@ -11,11 +11,14 @@ // +---------------------------------------------------------------------- return [ + // 分流链接公开落地页:/s/{9位链接码} + 's/:link_code' => 'index/Split/visit', //别名配置,别名只能是映射到控制器且访问时必须加上请求的方法 '__alias__' => [ ], //变量规则 '__pattern__' => [ + 'link_code' => '[a-z]{9}', ], // 域名绑定到模块 // '__domain__' => [ diff --git a/patches/application/admin/command/Install/split_link_pixel.sql b/patches/application/admin/command/Install/split_link_pixel.sql new file mode 100644 index 0000000..3c45c32 --- /dev/null +++ b/patches/application/admin/command/Install/split_link_pixel.sql @@ -0,0 +1,12 @@ +-- 分流链接:像素配置 JSON 字段 + 权限节点 +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_link` + ADD COLUMN `pixel_config` mediumtext COMMENT '像素配置JSON' AFTER `auto_reply`; + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.link/pixel', '像素配置', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.link' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/pixel' LIMIT 1) +LIMIT 1; diff --git a/patches/application/admin/command/Install/split_mydomain_remove.sql b/patches/application/admin/command/Install/split_mydomain_remove.sql new file mode 100644 index 0000000..d573f7c --- /dev/null +++ b/patches/application/admin/command/Install/split_mydomain_remove.sql @@ -0,0 +1,4 @@ +-- 移除分流模块下重复的「管理我的域名」菜单(统一使用顶级「域名管理」) +SET NAMES utf8mb4; + +DELETE FROM `fa_auth_rule` WHERE `name` LIKE 'split.mydomain%'; diff --git a/patches/application/admin/controller/Domain.php b/patches/application/admin/controller/Domain.php deleted file mode 100644 index 5a6742f..0000000 --- a/patches/application/admin/controller/Domain.php +++ /dev/null @@ -1,247 +0,0 @@ -model = new DomainModel(); - $this->view->assign('zoneStatusList', $this->model->getZoneStatusList()); - $this->view->assign('nsStatusList', $this->model->getNsStatusList()); - $this->view->assign('dnsStatusList', $this->model->getDnsStatusList()); - $this->assignconfig('zoneStatusList', $this->model->getZoneStatusList()); - $this->assignconfig('nsStatusList', $this->model->getNsStatusList()); - $this->assignconfig('dnsStatusList', $this->model->getDnsStatusList()); - } - - /** - * 禁止编辑 - */ - public function edit($ids = null) - { - $this->error(__('Domain cannot be edited after creation')); - } - - /** - * 禁止批量操作 - */ - public function multi($ids = '') - { - $this->error(__('Invalid parameters')); - } - - /** - * 添加域名并创建 Cloudflare Zone - */ - public function add() - { - if (false === $this->request->isPost()) { - return $this->view->fetch(); - } - - $params = $this->request->post('row/a', []); - if ($params === []) { - $this->error(__('Parameter %s can not be empty', '')); - } - - $params = $this->preExcludeFields($params); - $domain = DomainModel::normalizeDomain((string)($params['domain'] ?? '')); - $params['domain'] = $domain; - - if ($this->dataLimit && $this->dataLimitFieldAutoFill) { - $params[$this->dataLimitField] = $this->auth->id; - } - - $result = false; - Db::startTrans(); - try { - if ($this->modelValidate) { - $name = str_replace('\\model\\', '\\validate\\', get_class($this->model)); - $validate = $this->modelSceneValidate ? $name . '.add' : $name; - $this->model->validateFailException()->validate($validate, $params); - } - - if (DomainModel::where('domain', $domain)->find()) { - throw new ValidateException(__('Domain already exists')); - } - - $cloudflare = new CloudflareService(); - $zone = $cloudflare->createZone($domain); - - $params['full_url'] = 'https://' . $domain; - $params['zone_id'] = $zone['zone_id']; - $params['nameservers'] = $zone['name_servers']; - $params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']); - $params['ns_status'] = 'pending'; - $params['dns_status'] = 'pending'; - $params['check_result'] = ''; - - $result = $this->model->allowField(true)->save($params); - Db::commit(); - } catch (ValidateException $e) { - Db::rollback(); - $this->error($e->getMessage()); - } catch (PDOException|Exception $e) { - Db::rollback(); - $this->error($e->getMessage()); - } - - if ($result === false) { - $this->error(__('No rows were inserted')); - } - - $this->success(__('Domain submitted, please check NS in detail page and update at registrar')); - } - - /** - * 域名详情 - */ - public function detail($ids = null) - { - $row = $this->model->get($ids); - if (!$row) { - $this->error(__('No Results were found')); - } - if (!$this->checkDataLimit($row)) { - $this->error(__('You have no permission')); - } - - $data = $row->toArray(); - $data['nameservers_list'] = $row->getNameserversArray(); - $this->view->assign('row', $data); - return $this->view->fetch(); - } - - /** - * 检测域名状态 - */ - public function detect($ids = null) - { - if (!$this->request->isPost()) { - $this->error(__('Invalid parameters')); - } - - $row = $this->model->get($ids); - if (!$row) { - $this->error(__('No Results were found')); - } - if (!$this->checkDataLimit($row)) { - $this->error(__('You have no permission')); - } - - try { - (new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']); - } catch (Exception $e) { - $this->error($e->getMessage()); - } - - try { - $cloudflare = new CloudflareService(); - $detect = $cloudflare->detectDomain($row->toArray()); - $row->save([ - 'zone_status' => $detect['zone_status'], - 'ns_status' => $detect['ns_status'], - 'dns_status' => $detect['dns_status'], - 'check_time' => time(), - 'check_result' => $detect['check_result'], - ]); - } catch (Exception $e) { - $this->error($e->getMessage()); - } - - $this->success(__('Detection completed'), null, $row->toArray()); - } - - /** - * 删除(需输入完整域名二次确认) - */ - public function del($ids = '') - { - if (!$this->request->isPost()) { - $this->error(__('Invalid parameters')); - } - - $ids = $ids ?: $this->request->post('ids', ''); - $confirmDomain = trim((string)$this->request->post('confirm_domain', '')); - if ($ids === '' || $confirmDomain === '') { - $this->error(__('Please enter the full domain to confirm deletion')); - } - - $pk = $this->model->getPk(); - $list = $this->model->where($pk, 'in', $ids)->select(); - if (!$list || count($list) === 0) { - $this->error(__('No Results were found')); - } - - $count = 0; - Db::startTrans(); - try { - foreach ($list as $item) { - if (!$this->checkDataLimit($item)) { - throw new Exception(__('You have no permission')); - } - if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) { - throw new Exception(__('Domain confirmation does not match')); - } - - if (!empty($item['zone_id'])) { - $cloudflare = new CloudflareService(); - $cloudflare->deleteZone((string)$item['zone_id']); - } - - $count += $item->delete(); - } - Db::commit(); - } catch (PDOException|Exception $e) { - Db::rollback(); - $this->error($e->getMessage()); - } - - if ($count > 0) { - $this->success(); - } - $this->error(__('No rows were deleted')); - } - - /** - * @param DomainModel $row - */ - protected function checkDataLimit($row): bool - { - if (!$this->dataLimit) { - return true; - } - $adminIds = $this->getDataLimitAdminIds(); - if (!is_array($adminIds)) { - return true; - } - return in_array((int)$row[$this->dataLimitField], $adminIds, true); - } -} diff --git a/patches/application/admin/controller/split/Link.php b/patches/application/admin/controller/split/Link.php index 93ce85e..9d4eb94 100644 --- a/patches/application/admin/controller/split/Link.php +++ b/patches/application/admin/controller/split/Link.php @@ -9,6 +9,7 @@ use app\common\controller\Backend; use app\common\library\CountryIso; use app\common\service\SplitAutoReplyService; use app\common\service\SplitLinkCodeService; +use app\common\service\SplitPixelConfigService; use app\common\service\SplitPlatformDomainService; use think\Db; use think\Exception; @@ -224,8 +225,8 @@ class Link extends Backend $this->success('', null, [ 'platform_domains' => $platformDomains, 'my_domains' => $myDomains, - 'domain_index_url' => (string) url('domain/index'), - 'domain_add_url' => (string) url('domain/add'), + 'domain_index_url' => (string) url('domain', '', false, false), + 'domain_add_url' => (string) url('domain/add', '', false, false), 'config_index_url' => (string) url('general/config/index'), ]); } @@ -284,6 +285,58 @@ class Link extends Backend ]); } + /** + * 像素配置:读取 / 保存 + * + * @param string|null $ids 链接 ID + * @return Json + * @throws DbException + */ + public function pixel($ids = null): Json + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + $this->error(__('You have no permission')); + } + + if ($this->request->isPost()) { + $payload = $this->request->post('pixel_config/a', []); + if ($payload === []) { + $raw = (string) $this->request->post('pixel_config', ''); + $decoded = json_decode($raw, true); + $payload = is_array($decoded) ? $decoded : []; + } + $existing = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config')); + try { + $merged = SplitPixelConfigService::mergeForSave($payload, $existing); + $row->save([ + 'pixel_config' => SplitPixelConfigService::encodeStorage($merged), + ]); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + $this->error($e->getMessage()); + } + $this->success(__('Pixel config saved')); + } + + $config = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config')); + $masked = SplitPixelConfigService::maskForAdmin($config); + + $this->success('', null, [ + 'id' => (int) $row['id'], + 'link_code' => (string) $row['link_code'], + 'facebook' => $masked[SplitPixelConfigService::PLATFORM_FACEBOOK], + 'tiktok' => $masked[SplitPixelConfigService::PLATFORM_TIKTOK], + 'event_options' => SplitPixelConfigService::EVENT_OPTIONS, + ]); + } + /** * 编辑(分流链接码不可修改) * diff --git a/patches/application/admin/lang/zh-cn/split/link.php b/patches/application/admin/lang/zh-cn/split/link.php index cd525e8..843d2b9 100644 --- a/patches/application/admin/lang/zh-cn/split/link.php +++ b/patches/application/admin/lang/zh-cn/split/link.php @@ -18,6 +18,7 @@ return [ 'Status hidden' => '停用', 'Copy split link' => '复制分流链接', 'Manage my domains' => '管理我的域名', + 'Domain management' => '域名管理', 'Select main domain' => '选择主域名', 'Platform assigned domain' => '平台分配域名', 'My domains' => '我的域名', @@ -41,4 +42,24 @@ return [ 'Reply statements column' => '回复语', 'NS' => 'NS', 'DNS' => 'DNS', + 'Pixel config' => '像素配置', + 'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access Token,Token 留空会保留原配置。', + 'Facebook Pixel' => 'Facebook Pixel', + 'TikTok Pixel' => 'TikTok Pixel', + 'Add FB Pixel' => '+ 添加FB Pixel', + 'Add TK Pixel' => '+ 添加TK Pixel', + 'Pixel enabled' => '启用', + 'Server postback' => '服务端回传', + 'Pixel ID' => 'Pixel ID', + 'Access Token' => 'Access Token', + 'Test code' => '测试码', + 'Pixel event' => '事件', + 'Pixel sort' => '排序', + 'Pixel config saved' => '像素配置已保存', + 'Pixel ID required' => '请填写 Pixel ID', + 'Remove row' => '删除', + 'Optional' => '选填', + 'Pixel list empty' => '暂无配置,请点击上方按钮添加', + 'Pixel row index' => '序号', + 'Operate' => '操作', ]; diff --git a/patches/application/common/service/SplitFriendUrlBuilder.php b/patches/application/common/service/SplitFriendUrlBuilder.php new file mode 100644 index 0000000..ceb427f --- /dev/null +++ b/patches/application/common/service/SplitFriendUrlBuilder.php @@ -0,0 +1,92 @@ +country($ip); + $code = $record->country->isoCode ?? null; + if (!is_string($code) || $code === '') { + return null; + } + + return strtoupper($code); + } catch (AddressNotFoundException $e) { + return null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * MMDB 绝对路径 + */ + public static function getDatabasePath(): string + { + return rtrim((string) ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . self::DB_FILENAME; + } + + public static function isDatabaseAvailable(): bool + { + $path = self::getDatabasePath(); + + return is_file($path) && is_readable($path); + } + + private static function bootstrapLibrary(): void + { + static $bootstrapped = false; + if ($bootstrapped) { + return; + } + $bootstrapped = true; + $loader = ROOT_PATH . 'patches/third_party/load_geoip2.php'; + if (is_file($loader)) { + require_once $loader; + } + } + + private static function getReader(): Reader + { + if (self::$reader instanceof Reader) { + return self::$reader; + } + if (!self::isDatabaseAvailable()) { + throw new \RuntimeException('GeoLite2-Country.mmdb not readable at project root'); + } + self::$reader = new Reader(self::getDatabasePath()); + + return self::$reader; + } +} diff --git a/patches/application/common/service/SplitIpProtectService.php b/patches/application/common/service/SplitIpProtectService.php new file mode 100644 index 0000000..835a73a --- /dev/null +++ b/patches/application/common/service/SplitIpProtectService.php @@ -0,0 +1,60 @@ + 大写 ISO2 列表 + */ + public static function parseAllowedCountries(string $storage): array + { + if (trim($storage) === '') { + return []; + } + $result = []; + foreach (explode(',', $storage) as $code) { + $code = strtoupper(trim($code)); + if ($code !== '' && !in_array($code, $result, true)) { + $result[] = $code; + } + } + + return $result; + } +} diff --git a/patches/application/common/service/SplitPixelBrowserRenderer.php b/patches/application/common/service/SplitPixelBrowserRenderer.php new file mode 100644 index 0000000..ad14812 --- /dev/null +++ b/patches/application/common/service/SplitPixelBrowserRenderer.php @@ -0,0 +1,171 @@ +>, tiktok: array>} $config + * @return array{ + * head_html: string, + * body_html: string, + * track_lines: array, + * track_jobs: array> + * } + */ + public static function render(array $config): array + { + $fbRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK); + $tkRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK); + + $head = []; + $initLines = []; + $trackLines = []; + $jobs = []; + + if ($fbRows !== []) { + $head[] = self::facebookLoaderScript(); + } + if ($tkRows !== []) { + $head[] = self::tiktokLoaderScript(); + } + + foreach ($fbRows as $row) { + $pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? '')); + $testCode = trim((string) ($row['test_code'] ?? '')); + $event = self::escapeJsString((string) ($row['event'] ?? 'PageView')); + if ($pixelId === '') { + continue; + } + if ($testCode !== '') { + $testEsc = self::escapeJsString($testCode); + $initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}',{},{test_event_code:'{$testEsc}'});}"; + } else { + $initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}');}"; + } + $jobs[] = [ + 'platform' => 'facebook', + 'event' => (string) ($row['event'] ?? 'PageView'), + 'pixel_id' => (string) ($row['pixel_id'] ?? ''), + ]; + $trackLines[] = "if(typeof fbq!=='undefined'){fbq('track','{$event}');}"; + } + + foreach ($tkRows as $row) { + $pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? '')); + $event = self::mapTikTokBrowserEvent((string) ($row['event'] ?? 'PageView')); + $eventJs = self::escapeJsString($event); + if ($pixelId === '') { + continue; + } + $initLines[] = "if(typeof ttq!=='undefined'){ttq.load('{$pixelId}');ttq.page();}"; + $jobs[] = [ + 'platform' => 'tiktok', + 'event' => $event, + 'pixel_id' => (string) ($row['pixel_id'] ?? ''), + ]; + $trackLines[] = "if(typeof ttq!=='undefined'){ttq.track('{$eventJs}');}"; + } + + return [ + 'head_html' => implode("\n", $head), + 'body_html' => self::wrapScriptBlock($initLines), + 'track_lines' => $trackLines, + 'track_jobs' => $jobs, + ]; + } + + /** + * 跳转 orchestrator:在 setTimeout 回调内触发像素事件后再 replace + * + * @param array $trackLines + */ + public static function renderRedirectOrchestrator(string $redirectUrlJson, array $trackLines = [], int $maxWaitMs = 1500): string + { + $trackBlock = $trackLines !== [] ? implode("\n ", $trackLines) : ''; + $script = self::renderRedirectOrchestratorScript(); + + return str_replace( + ['{$redirectUrlJson}', '{$maxWaitMs}', '{$trackBlock}'], + [$redirectUrlJson, (string) $maxWaitMs, $trackBlock], + $script + ); + } + + private static function renderRedirectOrchestratorScript(): string + { + return <<<'JS' + +JS; + } + + /** + * @param array $lines + */ + private static function wrapScriptBlock(array $lines): string + { + if ($lines === []) { + return ''; + } + + return ''; + } + + private static function facebookLoaderScript(): string + { + return <<<'HTML' + +HTML; + } + + private static function tiktokLoaderScript(): string + { + return <<<'HTML' + +HTML; + } + + private static function mapTikTokBrowserEvent(string $event): string + { + $map = [ + 'PageView' => 'Pageview', + 'Lead' => 'SubmitForm', + 'Contact' => 'Contact', + 'AddToCart' => 'AddToCart', + 'Purchase' => 'CompletePayment', + 'Subscribe' => 'Subscribe', + ]; + + return $map[$event] ?? 'Pageview'; + } + + private static function escapeJsString(string $value): string + { + return str_replace(['\\', "'"], ['\\\\', "\\'"], $value); + } +} diff --git a/patches/application/common/service/SplitPixelConfigService.php b/patches/application/common/service/SplitPixelConfigService.php new file mode 100644 index 0000000..0151cea --- /dev/null +++ b/patches/application/common/service/SplitPixelConfigService.php @@ -0,0 +1,250 @@ +>, tiktok: array>} + */ + public static function parseStorage(?string $raw): array + { + $empty = [ + self::PLATFORM_FACEBOOK => [], + self::PLATFORM_TIKTOK => [], + ]; + if ($raw === null || trim($raw) === '') { + return $empty; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $empty; + } + + return [ + self::PLATFORM_FACEBOOK => self::normalizePlatformList( + $decoded[self::PLATFORM_FACEBOOK] ?? [], + self::PLATFORM_FACEBOOK + ), + self::PLATFORM_TIKTOK => self::normalizePlatformList( + $decoded[self::PLATFORM_TIKTOK] ?? [], + self::PLATFORM_TIKTOK + ), + ]; + } + + /** + * 合并 POST 数据与已有配置(Token / 测试码留空保留原值) + * + * @param array $incoming + * @param array $existing + * @return array{facebook: array>, tiktok: array>} + */ + public static function mergeForSave(array $incoming, array $existing): array + { + $existingMap = self::indexById($existing); + $result = [ + self::PLATFORM_FACEBOOK => [], + self::PLATFORM_TIKTOK => [], + ]; + + foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) { + $rows = is_array($incoming[$platform] ?? null) ? $incoming[$platform] : []; + if (count($rows) > self::MAX_ITEMS_PER_PLATFORM) { + throw new \InvalidArgumentException('像素配置数量超出上限'); + } + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $normalized = self::normalizeRow($row, $platform); + $id = (string) ($normalized['id'] ?? ''); + if ($id !== '' && isset($existingMap[$id])) { + $old = $existingMap[$id]; + if (trim((string) ($normalized['access_token'] ?? '')) === '' + || strpos((string) ($normalized['access_token'] ?? ''), '***') !== false) { + $normalized['access_token'] = (string) ($old['access_token'] ?? ''); + } + if (trim((string) ($normalized['test_code'] ?? '')) === '') { + $normalized['test_code'] = (string) ($old['test_code'] ?? ''); + } + } + if (trim((string) ($normalized['pixel_id'] ?? '')) === '') { + continue; + } + $result[$platform][] = $normalized; + } + usort($result[$platform], static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + } + + return $result; + } + + /** + * @param array{facebook: array>, tiktok: array>} $config + */ + public static function encodeStorage(array $config): string + { + $payload = [ + self::PLATFORM_FACEBOOK => $config[self::PLATFORM_FACEBOOK] ?? [], + self::PLATFORM_TIKTOK => $config[self::PLATFORM_TIKTOK] ?? [], + ]; + + return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; + } + + /** + * 管理端 GET:脱敏 access_token + * + * @param array{facebook: array>, tiktok: array>} $config + * @return array{facebook: array>, tiktok: array>} + */ + public static function maskForAdmin(array $config): array + { + $mask = static function (array $rows): array { + $out = []; + foreach ($rows as $row) { + $item = $row; + $token = (string) ($item['access_token'] ?? ''); + $item['access_token'] = $token === '' ? '' : self::maskToken($token); + $item['has_access_token'] = $token !== ''; + unset($item['access_token_raw']); + $out[] = $item; + } + + return $out; + }; + + return [ + self::PLATFORM_FACEBOOK => $mask($config[self::PLATFORM_FACEBOOK] ?? []), + self::PLATFORM_TIKTOK => $mask($config[self::PLATFORM_TIKTOK] ?? []), + ]; + } + + /** + * 获取已启用且按 sort 排序的条目(中转页 / 服务端回传) + * + * @param array{facebook: array>, tiktok: array>} $config + * @return array> + */ + public static function getEnabledSorted(array $config, string $platform): array + { + $rows = $config[$platform] ?? []; + $enabled = []; + foreach ($rows as $row) { + if ((int) ($row['enabled'] ?? 0) === 1) { + $enabled[] = $row; + } + } + usort($enabled, static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + + return $enabled; + } + + public static function maskToken(string $token): string + { + $len = strlen($token); + if ($len <= 8) { + return '***'; + } + + return substr($token, 0, 3) . '***' . substr($token, -3); + } + + /** + * @param array> $rows + * @return array> + */ + private static function normalizePlatformList(array $rows, string $platform): array + { + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $normalized = self::normalizeRow($row, $platform); + if (trim((string) ($normalized['pixel_id'] ?? '')) === '') { + continue; + } + $result[] = $normalized; + } + usort($result, static function (array $a, array $b): int { + return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0)); + }); + + return $result; + } + + /** + * @param array $row + * @return array + */ + private static function normalizeRow(array $row, string $platform): array + { + $event = (string) ($row['event'] ?? 'PageView'); + if (!in_array($event, self::EVENT_OPTIONS, true)) { + $event = 'PageView'; + } + $id = trim((string) ($row['id'] ?? '')); + if ($id === '') { + $id = $platform . '_' . uniqid('', true); + } + + return [ + 'id' => $id, + 'enabled' => (int) ($row['enabled'] ?? 0) === 1 ? 1 : 0, + 'server_postback' => (int) ($row['server_postback'] ?? 0) === 1 ? 1 : 0, + 'pixel_id' => trim((string) ($row['pixel_id'] ?? '')), + 'access_token' => trim((string) ($row['access_token'] ?? '')), + 'test_code' => trim((string) ($row['test_code'] ?? '')), + 'event' => $event, + 'sort' => max(0, (int) ($row['sort'] ?? 0)), + ]; + } + + /** + * @param array{facebook: array>, tiktok: array>} $config + * @return array> + */ + private static function indexById(array $config): array + { + $map = []; + foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) { + foreach ($config[$platform] ?? [] as $row) { + $id = (string) ($row['id'] ?? ''); + if ($id !== '') { + $map[$id] = $row; + } + } + } + + return $map; + } +} diff --git a/patches/application/common/service/SplitPixelPostbackService.php b/patches/application/common/service/SplitPixelPostbackService.php new file mode 100644 index 0000000..7671698 --- /dev/null +++ b/patches/application/common/service/SplitPixelPostbackService.php @@ -0,0 +1,169 @@ +>, tiktok: array>} $config + */ + public static function dispatch(array $config, string $clientIp, string $userAgent, string $eventSourceUrl = ''): void + { + foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK) as $row) { + if ((int) ($row['server_postback'] ?? 0) !== 1) { + continue; + } + $token = trim((string) ($row['access_token'] ?? '')); + if ($token === '') { + continue; + } + self::sendFacebook($row, $token, $clientIp, $userAgent, $eventSourceUrl); + } + + foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK) as $row) { + if ((int) ($row['server_postback'] ?? 0) !== 1) { + continue; + } + $token = trim((string) ($row['access_token'] ?? '')); + if ($token === '') { + continue; + } + self::sendTikTok($row, $token, $clientIp, $userAgent, $eventSourceUrl); + } + } + + /** + * @param array $row + */ + private static function sendFacebook(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void + { + $pixelId = trim((string) ($row['pixel_id'] ?? '')); + if ($pixelId === '') { + return; + } + + $eventName = (string) ($row['event'] ?? 'PageView'); + $testCode = trim((string) ($row['test_code'] ?? '')); + + $payload = [ + 'data' => [[ + 'event_name' => $eventName, + 'event_time' => time(), + 'action_source' => 'website', + 'user_data' => array_filter([ + 'client_ip_address' => $clientIp !== '' ? $clientIp : null, + 'client_user_agent' => $userAgent !== '' ? $userAgent : null, + ]), + ]], + ]; + if ($eventSourceUrl !== '') { + $payload['data'][0]['event_source_url'] = $eventSourceUrl; + } + if ($testCode !== '') { + $payload['test_event_code'] = $testCode; + } + + $url = 'https://graph.facebook.com/v19.0/' . rawurlencode($pixelId) . '/events?access_token=' . rawurlencode($accessToken); + self::postJson($url, $payload, [], 'facebook', $pixelId); + } + + /** + * @param array $row + */ + private static function sendTikTok(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void + { + $pixelId = trim((string) ($row['pixel_id'] ?? '')); + if ($pixelId === '') { + return; + } + + $event = self::mapTikTokServerEvent((string) ($row['event'] ?? 'PageView')); + $testCode = trim((string) ($row['test_code'] ?? '')); + + $payload = [ + 'pixel_code' => $pixelId, + 'event' => $event, + 'event_id' => uniqid('split_', true), + 'timestamp' => gmdate('c'), + 'context' => array_filter([ + 'ip' => $clientIp !== '' ? $clientIp : null, + 'user_agent' => $userAgent !== '' ? $userAgent : null, + 'page' => $eventSourceUrl !== '' ? ['url' => $eventSourceUrl] : null, + ]), + ]; + if ($testCode !== '') { + $payload['test_event_code'] = $testCode; + } + + $url = 'https://business-api.tiktok.com/open_api/v1.3/event/track/'; + self::postJson($url, $payload, [ + 'Access-Token: ' . $accessToken, + 'Content-Type: application/json', + ], 'tiktok', $pixelId); + } + + private static function mapTikTokServerEvent(string $event): string + { + $map = [ + 'PageView' => 'Pageview', + 'Lead' => 'SubmitForm', + 'Contact' => 'Contact', + 'AddToCart' => 'AddToCart', + 'Purchase' => 'CompletePayment', + 'Subscribe' => 'Subscribe', + ]; + + return $map[$event] ?? 'Pageview'; + } + + /** + * @param array $payload + * @param array $headers + */ + private static function postJson(string $url, array $payload, array $headers, string $platform, string $pixelId): void + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return; + } + + $ch = curl_init($url); + if ($ch === false) { + return; + } + + $defaultHeaders = ['Content-Type: application/json']; + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + CURLOPT_HTTPHEADER => array_merge($defaultHeaders, $headers), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 3, + CURLOPT_CONNECTTIMEOUT => 2, + ]); + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno !== 0 || ($httpCode >= 400 && $httpCode !== 0)) { + Log::error(sprintf( + 'Split pixel postback failed platform=%s pixel=%s http=%d curl=%d', + $platform, + $pixelId, + $httpCode, + $errno + )); + } elseif (is_string($response) && strpos($response, '"error"') !== false) { + Log::error(sprintf('Split pixel postback api error platform=%s pixel=%s', $platform, $pixelId)); + } + } +} diff --git a/patches/application/common/service/SplitRedirectService.php b/patches/application/common/service/SplitRedirectService.php new file mode 100644 index 0000000..1086b80 --- /dev/null +++ b/patches/application/common/service/SplitRedirectService.php @@ -0,0 +1,87 @@ +roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore(); + } + + /** + * 根据链接码解析本次应跳转的加好友 URL;无效时返回 null + * + * @param string $linkCode 9 位链接码 + * @param string $clientIp 访客 IP(IP 防护开启时用于国家校验) + */ + public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string + { + $linkCode = SplitLinkCodeService::normalize($linkCode); + if (!SplitLinkCodeService::isValidFormat($linkCode)) { + return null; + } + + $link = Link::where('link_code', $linkCode) + ->where('status', 'normal') + ->find(); + if (!$link) { + return null; + } + + $ipProtect = (int) $link->getAttr('ip_protect'); + $countries = (string) $link->getAttr('countries'); + if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) { + return null; + } + + /** @var Collection|array $numbers */ + $numbers = Number::where('split_link_id', (int) $link['id']) + ->where('status', 'normal') + ->order('id', 'asc') + ->field('id,number,number_type,number_type_custom') + ->select(); + + $count = is_countable($numbers) ? count($numbers) : 0; + if ($count === 0) { + return null; + } + + $linkId = (int) $link->getAttr('id'); + $index = $this->roundRobinStore->nextIndex($linkId, $count); + $list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; + $picked = $list[$index] ?? $list[0] ?? null; + if ($picked === null) { + return null; + } + + $numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type'); + $numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number'); + $numberCustom = is_array($picked) + ? (string) ($picked['number_type_custom'] ?? '') + : (string) $picked->getAttr('number_type_custom'); + + $redirectUrl = SplitFriendUrlBuilder::build($numberType, $numberValue, $numberCustom); + if ($redirectUrl === '') { + return null; + } + + $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); + if ($numberId > 0) { + Number::where('id', $numberId)->setInc('visit_count'); + } + + return $redirectUrl; + } +} diff --git a/patches/application/common/service/SplitRoundRobinStore.php b/patches/application/common/service/SplitRoundRobinStore.php new file mode 100644 index 0000000..2012988 --- /dev/null +++ b/patches/application/common/service/SplitRoundRobinStore.php @@ -0,0 +1,133 @@ +ensureRedis()) { + $key = self::KEY_PREFIX . $splitLinkId; + $seq = (int) $this->redis->incr($key); + if ($seq <= 0) { + $seq = 1; + } + + return ($seq - 1) % $numberCount; + } + + return $this->nextIndexFallback($splitLinkId, $numberCount); + } + + /** + * 尝试连接 Redis(配置来自 queue 扩展) + */ + private function ensureRedis(): bool + { + if ($this->redisAttempted) { + return $this->redisReady; + } + $this->redisAttempted = true; + + if (!extension_loaded('redis')) { + return false; + } + + $config = Config::get('queue'); + if (!is_array($config) || ($config['connector'] ?? '') !== 'Redis') { + $appPath = defined('APP_PATH') ? APP_PATH : (defined('ROOT_PATH') ? ROOT_PATH . 'application/' : ''); + $file = $appPath . 'extra/queue.php'; + if (is_file($file)) { + $loaded = include $file; + $config = is_array($loaded) ? $loaded : []; + } else { + $config = []; + } + } + + $host = (string) ($config['host'] ?? '127.0.0.1'); + $port = (int) ($config['port'] ?? 6379); + $password = (string) ($config['password'] ?? ''); + $select = (int) ($config['select'] ?? 0); + $timeout = (float) ($config['timeout'] ?? 1.0); + + try { + $redis = new \Redis(); + $connected = $timeout > 0 + ? @$redis->connect($host, $port, $timeout) + : @$redis->connect($host, $port); + if (!$connected) { + return false; + } + if ($password !== '') { + if (!$redis->auth($password)) { + return false; + } + } + if ($select > 0) { + $redis->select($select); + } + $this->redis = $redis; + $this->redisReady = true; + } catch (\Throwable $e) { + $this->redisReady = false; + } + + return $this->redisReady; + } + + /** + * 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis) + */ + private function nextIndexFallback(int $splitLinkId, int $numberCount): int + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_rr/'; + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + return 0; + } + $file = $dir . $splitLinkId . '.cnt'; + $fp = @fopen($file, 'c+'); + if ($fp === false) { + return 0; + } + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return 0; + } + $raw = stream_get_contents($fp); + $seq = (int) $raw; + $seq++; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, (string) $seq); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + + return ($seq - 1) % $numberCount; + } +} diff --git a/patches/application/common/service/SplitVisitPageService.php b/patches/application/common/service/SplitVisitPageService.php new file mode 100644 index 0000000..94f9ba9 --- /dev/null +++ b/patches/application/common/service/SplitVisitPageService.php @@ -0,0 +1,58 @@ +resolveRedirectUrl($linkCode, $clientIp); + if ($redirectUrl === null || $redirectUrl === '') { + return null; + } + + $link = Link::where('link_code', SplitLinkCodeService::normalize($linkCode)) + ->where('status', 'normal') + ->field('id,pixel_config') + ->find(); + + $config = SplitPixelConfigService::parseStorage( + $link ? (string) $link->getAttr('pixel_config') : '' + ); + + SplitPixelPostbackService::dispatch($config, $clientIp, $userAgent, $pageUrl); + + $pixel = SplitPixelBrowserRenderer::render($config); + $redirectJson = json_encode( + $redirectUrl, + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES + ); + + return [ + 'redirect_url' => $redirectUrl, + 'redirect_url_json' => $redirectJson ?: '""', + 'pixel_head_html' => $pixel['head_html'], + 'pixel_body_html' => $pixel['body_html'], + 'orchestrator_html' => SplitPixelBrowserRenderer::renderRedirectOrchestrator( + $redirectJson ?: '""', + $pixel['track_lines'] ?? [] + ), + ]; + } +} diff --git a/patches/application/index/controller/S.php b/patches/application/index/controller/S.php new file mode 100644 index 0000000..29518b6 --- /dev/null +++ b/patches/application/index/controller/S.php @@ -0,0 +1,20 @@ +visit($link_code); + } +} diff --git a/patches/application/index/controller/Split.php b/patches/application/index/controller/Split.php new file mode 100644 index 0000000..d59e94f --- /dev/null +++ b/patches/application/index/controller/Split.php @@ -0,0 +1,84 @@ +abortNotFound(); + } + + $page = SplitVisitPageService::build( + $code, + (string) $this->request->ip(), + (string) $this->request->server('HTTP_USER_AGENT', ''), + (string) $this->request->url(true) + ); + if ($page === null) { + $this->abortNotFound(); + } + + $this->view->assign('redirect_url', $page['redirect_url']); + $this->view->assign('redirect_url_json', $page['redirect_url_json']); + $this->view->assign('pixel_head_html', $page['pixel_head_html']); + $this->view->assign('pixel_body_html', $page['pixel_body_html']); + $this->view->assign('orchestrator_html', $page['orchestrator_html']); + + return $this->fetchPatch('visit'); + } + + /** + * 渲染模板(优先 patches 视图,回退 application) + */ + private function fetchPatch(string $template): string + { + $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; + $appFile = APP_PATH . 'index/view/split/' . $template . '.html'; + if (is_file($patchFile)) { + $file = $patchFile; + } elseif (is_file($appFile)) { + $file = $appFile; + } else { + throw new HttpException(500, 'Template not found'); + } + + return (string) $this->view->fetch($file); + } + + /** + * 404:输出无中文的空白页,避免框架默认中文错误页 + */ + private function abortNotFound(): void + { + throw new HttpResponseException(response($this->fetchPatch('not_found'), 404)); + } +} diff --git a/patches/application/index/view/split/not_found.html b/patches/application/index/view/split/not_found.html new file mode 100644 index 0000000..757bf6d --- /dev/null +++ b/patches/application/index/view/split/not_found.html @@ -0,0 +1,9 @@ + + + + + + 404 + + + diff --git a/patches/application/index/view/split/visit.html b/patches/application/index/view/split/visit.html new file mode 100644 index 0000000..fee2eef --- /dev/null +++ b/patches/application/index/view/split/visit.html @@ -0,0 +1,17 @@ + + + + + + + + {$pixel_head_html|raw} + + +{$pixel_body_html|raw} +{$orchestrator_html|raw} + + + diff --git a/patches/application/route_split_visit.php b/patches/application/route_split_visit.php new file mode 100644 index 0000000..cc02b17 --- /dev/null +++ b/patches/application/route_split_visit.php @@ -0,0 +1,9 @@ + 'index/Split/visit',pattern link_code => [a-z]{9} + */ +return [ + 's/:link_code' => 'index/Split/visit', +]; diff --git a/patches/public/assets/js/backend/domain.js b/patches/public/assets/js/backend/domain.js new file mode 100644 index 0000000..9912702 --- /dev/null +++ b/patches/public/assets/js/backend/domain.js @@ -0,0 +1,147 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + /** + * 列表 Ajax 仅保留 addtabs 参数。 + * 切勿拼接 location.search 中的 sort/filter/op 等,否则从其它模块跳入 /domain/index 时会带错字段导致列表为空。 + */ + var buildListQuery = function () { + var rawSearch = location.search || ''; + if (!rawSearch) { + return ''; + } + var addtabsMatch = rawSearch.match(/(?:[?&])addtabs=[^&]*/); + return addtabsMatch ? ('?' + addtabsMatch[0].replace(/^[?&]/, '')) : ''; + }; + + var Controller = { + index: function () { + var listQuery = buildListQuery(); + var indexUrl = 'domain/index' + listQuery; + Table.api.init({ + extend: { + index_url: indexUrl, + add_url: 'domain/add', + edit_url: '', + del_url: '', + multi_url: '', + table: 'domain', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'id', + sortOrder: 'desc', + fixedColumns: true, + fixedRightNumber: 1, + columns: [ + [ + {checkbox: true}, + {field: 'domain', title: __('Domain'), operate: 'LIKE', renderDefault: false}, + {field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url}, + {field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status}, + {field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status}, + {field: 'dns_status', title: __('Dns_status'), searchList: Config.dnsStatusList, formatter: Table.api.formatter.status}, + {field: 'check_time', title: __('Check_time'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, + {field: 'check_result', title: __('Check_result'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content}, + {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, + { + field: 'operate', + title: __('Operate'), + table: table, + events: Table.api.events.operate, + buttons: [ + { + name: 'detail', + text: __('Detail'), + icon: 'fa fa-list', + classname: 'btn btn-info btn-xs btn-detail btn-dialog', + url: 'domain/detail' + }, + { + name: 'detect', + text: __('Detect'), + icon: 'fa fa-refresh', + classname: 'btn btn-success btn-xs btn-detect', + url: 'domain/detect', + refresh: true + }, + { + name: 'delconfirm', + text: __('Del'), + icon: 'fa fa-trash', + classname: 'btn btn-danger btn-xs btn-delconfirm' + } + ], + formatter: Table.api.formatter.operate + } + ] + ] + }); + + table.on('click', '.btn-detect', function (e) { + e.preventDefault(); + e.stopPropagation(); + var that = this; + var rowIndex = $(that).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row) { + return false; + } + $(that).addClass('disabled'); + Fast.api.ajax({ + url: 'domain/detect/ids/' + row.id, + type: 'post' + }, function () { + table.bootstrapTable('refresh'); + return false; + }, function () { + $(that).removeClass('disabled'); + }); + }); + + table.on('click', '.btn-delconfirm', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var that = this; + var rowIndex = $(that).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row) { + return false; + } + Layer.prompt({ + title: __('Delete domain confirm prompt'), + formType: 0, + value: '' + }, function (value, index) { + if ($.trim(value) !== row.domain) { + Toastr.error(__('Delete domain confirm mismatch')); + return false; + } + Layer.close(index); + Fast.api.ajax({ + url: 'domain/del', + data: {ids: row.id, confirm_domain: $.trim(value)} + }, function () { + table.bootstrapTable('refresh'); + }); + }); + }); + + Table.api.bindevent(table); + }, + add: function () { + Controller.api.bindevent(); + }, + api: { + bindevent: function () { + Form.api.bindevent($("form[role=form]")); + } + } + }; + return Controller; +}); diff --git a/patches/public/assets/js/backend/split/link.js b/patches/public/assets/js/backend/split/link.js index 601ada5..c8e78ff 100644 --- a/patches/public/assets/js/backend/split/link.js +++ b/patches/public/assets/js/backend/split/link.js @@ -56,6 +56,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin icon: 'fa fa-commenting-o', classname: 'btn btn-warning btn-xs btn-split-autoreply', url: 'javascript:;' + }, + { + name: 'pixel', + text: __('Pixel config'), + title: __('Pixel config'), + icon: 'fa fa-bullseye', + classname: 'btn btn-info btn-xs btn-split-pixel', + url: 'javascript:;' } ], formatter: Table.api.formatter.operate @@ -95,6 +103,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openAutoReplyModal(row); }); + table.on('click', '.btn-split-pixel', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var rowIndex = $(this).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row || !row.id) { + return false; + } + Controller.api.openPixelModal(row); + }); + Controller.api.bindAutoReplyPreviewTips(table); Table.api.bindevent(table); @@ -107,8 +127,45 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + /** + * 规范化后台跨模块跳转 URL,避免在 split.link 页面内相对解析成 split.link/domain + * + * @param {string} url API 返回的地址 + * @param {string} fallback 相对 admin 模块根路径,如 domain / domain/add + * @return {string} + */ + normalizeAdminRouteUrl: function (url, fallback) { + fallback = fallback || 'domain'; + url = $.trim(url || ''); + if (url === '' || /split\.link\/domain/i.test(url)) { + return fallback; + } + var modulePrefix = (Config.moduleurl || '').replace(/\/+$/, ''); + if (url.indexOf('://') !== -1) { + try { + var parsed = new URL(url, window.location.origin); + var path = (parsed.pathname || '').replace(/\/+$/, ''); + if (modulePrefix && path.indexOf(modulePrefix) === 0) { + path = path.slice(modulePrefix.length); + } + path = path.replace(/^\/+/, ''); + return /^split\.link\/domain/i.test(path) ? fallback : (path || fallback); + } catch (e) { + return fallback; + } + } + if (url.charAt(0) === '/') { + url = url.replace(/^\/+/, ''); + var moduleKey = modulePrefix.replace(/^\/+/, ''); + if (moduleKey && url.indexOf(moduleKey + '/') === 0) { + url = url.slice(moduleKey.length + 1); + } + } + return /^split\.link\/domain/i.test(url) ? fallback : url; + }, /** 弹窗样式(仅注入一次) */ modalStyleInjected: false, + pixelModalStyleInjected: false, injectModalStyles: function () { if (Controller.api.modalStyleInjected) { return; @@ -140,6 +197,49 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin ].join(''); $('').text(css).appendTo('head'); }, + injectPixelModalStyles: function () { + if (Controller.api.pixelModalStyleInjected) { + return; + } + Controller.api.pixelModalStyleInjected = true; + var css = [ + '.split-pixel-layer .layui-layer-content{padding:0;overflow:hidden;max-height:calc(86vh - 108px);}', + '.split-pixel-layer .layui-layer-btn{border-top:1px solid #e8e8e8;background:#fafafa;}', + '.split-pixel-modal{padding:18px 22px 14px;box-sizing:border-box;display:flex;flex-direction:column;min-height:480px;height:calc(86vh - 108px);max-height:720px;}', + '.split-pixel-modal .split-pixel-tip{margin:0 0 14px;padding:10px 14px;background:#f0f7ff;border-left:3px solid #337ab7;border-radius:0 4px 4px 0;color:#555;font-size:13px;line-height:1.65;}', + '.split-pixel-modal .split-pixel-tabs{margin-bottom:0;border-bottom:1px solid #ddd;}', + '.split-pixel-modal .split-pixel-tabs>li>a{padding:9px 18px;font-weight:600;color:#666;}', + '.split-pixel-modal .split-pixel-tabs>li.active>a{color:#337ab7;border-bottom-color:#fff;}', + '.split-pixel-modal .split-pixel-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;padding-top:14px;}', + '.split-pixel-modal .split-pixel-tab-content>.tab-pane{display:none;flex:1;flex-direction:column;min-height:0;}', + '.split-pixel-modal .split-pixel-tab-content>.tab-pane.active{display:flex;}', + '.split-pixel-modal .split-pixel-list{flex:1;display:flex;flex-direction:column;min-height:0;}', + '.split-pixel-modal .split-pixel-toolbar{margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}', + '.split-pixel-modal .split-pixel-toolbar .btn-add-pixel-row{font-weight:600;padding:6px 14px;}', + '.split-pixel-modal .split-pixel-table-wrap{flex:1;min-height:320px;overflow:auto;border:1px solid #dce3eb;border-radius:6px;background:#fff;box-shadow:inset 0 1px 2px rgba(0,0,0,.03);}', + '.split-pixel-modal .split-pixel-table{margin-bottom:0;font-size:13px;table-layout:auto;width:100%;}', + '.split-pixel-modal .split-pixel-table thead th{background:linear-gradient(180deg,#f8fafc 0%,#eef2f6 100%);white-space:nowrap;vertical-align:middle;text-align:center;font-weight:600;color:#444;border-bottom:2px solid #dce3eb;padding:10px 8px;position:sticky;top:0;z-index:3;box-shadow:0 1px 0 #dce3eb;}', + '.split-pixel-modal .split-pixel-table tbody td{vertical-align:middle;padding:10px 8px;border-color:#edf1f5;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:hover{background:#f7fbff;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even){background:#fbfcfd;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even):hover{background:#f7fbff;}', + '.split-pixel-modal .split-pixel-table .form-control{min-width:0;height:32px;line-height:1.42857143;padding:6px 10px;border-radius:4px;}', + '.split-pixel-modal .split-pixel-table .pixel-id{min-width:130px;}', + '.split-pixel-modal .split-pixel-table .pixel-access-token{min-width:150px;}', + '.split-pixel-modal .split-pixel-table .pixel-test-code{min-width:100px;}', + '.split-pixel-modal .split-pixel-table th.pixel-event-col,.split-pixel-modal .split-pixel-table td.pixel-event-cell{min-width:148px;width:148px;}', + '.split-pixel-modal .split-pixel-table .pixel-event{width:100%;min-width:132px;max-width:none;padding-right:28px;text-overflow:clip;overflow:visible;white-space:nowrap;cursor:pointer;}', + '.split-pixel-modal .split-pixel-empty-row td{padding:48px 16px;color:#999;text-align:center;font-size:13px;background:#fafbfc;}', + '.split-pixel-modal .split-pixel-sort-group{width:118px;margin:0 auto;}', + '.split-pixel-modal .split-pixel-sort-group .form-control{text-align:center;padding-left:4px;padding-right:4px;}', + '.split-pixel-modal .pixel-switch-wrap{text-align:center;}', + '.split-pixel-modal .pixel-switch-wrap input[type=checkbox]{width:17px;height:17px;margin:0;cursor:pointer;vertical-align:middle;}', + '.split-pixel-modal .pixel-row-index{display:inline-block;min-width:26px;height:26px;line-height:26px;border-radius:13px;background:#e8eef5;color:#4a6785;font-weight:600;font-size:12px;}', + '.split-pixel-modal .btn-pixel-row-remove{padding:4px 8px;border-radius:4px;}', + '.split-pixel-modal .col-token{min-width:160px;}' + ].join(''); + $('').text(css).appendTo('head'); + }, formatter: { /** * 回复语:列表最多 50 字 + ...,悬停保留换行显示全文 @@ -261,6 +361,233 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } }); }, + pixelRowUid: function (prefix) { + return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); + }, + openPixelModal: function (row) { + Fast.api.ajax({ + url: 'split.link/pixel/ids/' + row.id, + type: 'get' + }, function (data, ret) { + var info = ret.data || data || {}; + Controller.api.renderPixelModal(row.id, info); + return false; + }); + }, + renderPixelModal: function (linkId, info) { + Controller.api.injectPixelModalStyles(); + var eventOptions = $.isArray(info.event_options) ? info.event_options : [ + 'PageView', 'Lead', 'Contact', 'AddToCart', 'Purchase', 'Subscribe' + ]; + var fbRows = $.isArray(info.facebook) ? info.facebook : []; + var tkRows = $.isArray(info.tiktok) ? info.tiktok : []; + + var html = [ + '
', + '
' + __('Pixel config tip') + '
', + ' ', + '
', + '
', + Controller.api.buildPixelListShell('facebook', __('Add FB Pixel')), + '
', + '
', + Controller.api.buildPixelListShell('tiktok', __('Add TK Pixel')), + '
', + '
', + '
' + ].join(''); + + Layer.open({ + type: 1, + title: __('Pixel config') + ' - ' + Fast.api.escape(info.link_code || ''), + area: ['1140px', '86vh'], + maxmin: true, + shadeClose: false, + content: html, + btn: [__('OK'), __('Close')], + success: function (layero) { + layero.addClass('split-pixel-layer'); + var $box = layero.find('.split-pixel-modal'); + var fillRows = function (platform, rows) { + var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]'); + var $body = $list.find('tbody.split-pixel-list-body'); + $body.find('tr.split-pixel-row').remove(); + $.each(rows, function (_, rowData) { + $body.append(Controller.api.buildPixelRowHtml(platform, rowData, eventOptions)); + }); + Controller.api.syncPixelListEmpty($list); + }; + fillRows('facebook', fbRows); + fillRows('tiktok', tkRows); + + $box.on('click', '.btn-add-pixel-row', function () { + var platform = $(this).data('platform'); + var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]'); + var $body = $list.find('tbody.split-pixel-list-body'); + var sortVal = $body.find('tr.split-pixel-row').length; + $body.append(Controller.api.buildPixelRowHtml(platform, { + id: Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'), + enabled: 1, + server_postback: 0, + pixel_id: '', + access_token: '', + test_code: '', + event: 'PageView', + sort: sortVal + }, eventOptions)); + Controller.api.syncPixelListEmpty($list); + var $wrap = $list.find('.split-pixel-table-wrap'); + if ($wrap.length) { + $wrap.scrollTop($wrap[0].scrollHeight); + } + }); + + $box.on('click', '.btn-pixel-sort-up', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $input = $row.find('.pixel-sort-input'); + $input.val(Math.max(0, (parseInt($input.val(), 10) || 0) + 1)); + }); + $box.on('click', '.btn-pixel-sort-down', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $input = $row.find('.pixel-sort-input'); + $input.val(Math.max(0, (parseInt($input.val(), 10) || 0) - 1)); + }); + $box.on('click', '.btn-pixel-row-remove', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $list = $row.closest('.split-pixel-list'); + $row.remove(); + Controller.api.syncPixelListEmpty($list); + }); + $box.on('change', '.pixel-event', function () { + var val = $(this).val() || ''; + $(this).attr('title', val); + }); + }, + yes: function (index, layero) { + var payload = Controller.api.collectPixelPayload(layero.find('.split-pixel-modal')); + var invalid = false; + $.each(payload.facebook.concat(payload.tiktok), function (_, item) { + if (!$.trim(item.pixel_id)) { + invalid = true; + return false; + } + }); + if (invalid) { + Toastr.error(__('Pixel ID required')); + return false; + } + Fast.api.ajax({ + url: 'split.link/pixel/ids/' + linkId, + type: 'post', + data: {pixel_config: payload} + }, function () { + Layer.close(index); + Toastr.success(__('Pixel config saved')); + }); + return false; + } + }); + }, + buildPixelListShell: function (platform, addBtnText) { + return [ + '
', + '
', + ' ', + '
', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
' + __('Pixel row index') + '' + __('Pixel enabled') + '' + __('Server postback') + '' + __('Pixel ID') + ' *' + __('Pixel event') + '' + __('Access Token') + '' + __('Test code') + '' + __('Pixel sort') + '' + __('Operate') + '
' + __('Pixel list empty') + '
', + '
', + '
' + ].join(''); + }, + syncPixelListEmpty: function ($list) { + var $body = $list.find('tbody.split-pixel-list-body'); + var $rows = $body.find('tr.split-pixel-row'); + $body.find('tr.split-pixel-empty-row').toggle($rows.length === 0); + $rows.each(function (idx) { + $(this).find('.pixel-row-index').text(String(idx + 1)); + }); + }, + buildPixelRowHtml: function (platform, row, eventOptions) { + row = row || {}; + var id = row.id || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'); + var enabled = parseInt(row.enabled, 10) === 1; + var serverPostback = parseInt(row.server_postback, 10) === 1; + var eventOpts = ''; + $.each(eventOptions, function (_, ev) { + eventOpts += ''; + }); + var tokenPlaceholder = __('Optional'); + var tokenHint = row.has_access_token ? ' placeholder="' + tokenPlaceholder + ' (' + __('Optional') + ')"' : ' placeholder="' + tokenPlaceholder + '"'; + var selectedEvent = row.event || 'PageView'; + + return [ + '', + ' -', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + ' ', + '' + ].join(''); + }, + collectPixelPayload: function ($box) { + var readList = function (platform) { + var rows = []; + $box.find('.split-pixel-list[data-platform="' + platform + '"] tbody .split-pixel-row').each(function () { + var $row = $(this); + rows.push({ + id: $row.attr('data-row-id') || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'), + enabled: $row.find('.pixel-enabled').prop('checked') ? 1 : 0, + server_postback: $row.find('.pixel-server-postback').prop('checked') ? 1 : 0, + pixel_id: $.trim($row.find('.pixel-id').val()), + access_token: $.trim($row.find('.pixel-access-token').val()), + test_code: $.trim($row.find('.pixel-test-code').val()), + event: $row.find('.pixel-event').val() || 'PageView', + sort: Math.max(0, parseInt($row.find('.pixel-sort-input').val(), 10) || 0) + }); + }); + return rows; + }; + return { + facebook: readList('facebook'), + tiktok: readList('tiktok') + }; + }, loadCopyModalData: function (callback) { Fast.api.ajax({ url: 'split.link/copyinfo', @@ -283,7 +610,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); } var myDomains = $.isArray(data.my_domains) ? data.my_domains : []; - var domainIndexUrl = data.domain_index_url || 'domain/index'; + var domainIndexUrl = Controller.api.normalizeAdminRouteUrl(data.domain_index_url, 'domain'); + var domainAddUrl = Controller.api.normalizeAdminRouteUrl(data.domain_add_url, 'domain/add'); var configIndexUrl = data.config_index_url || 'general/config/index'; var defaultType = platformDomains.length ? 'platform' : 'my'; var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : ''); @@ -418,10 +746,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.copyText(linkCode); }); - $box.on('click', '.btn-manage-domain, .btn-go-add-domain', function (e) { + $box.on('click', '.btn-manage-domain', function (e) { e.preventDefault(); Layer.close(index); - Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Manage my domains'), 'fa fa-globe'); + Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Domain management'), 'fa fa-globe'); + }); + + $box.on('click', '.btn-go-add-domain', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(domainAddUrl), __('Domain management'), 'fa fa-plus'); }); $box.on('click', '.btn-goto-config', function (e) { diff --git a/patches/runtime/split_rr/7.cnt b/patches/runtime/split_rr/7.cnt new file mode 100644 index 0000000..f11c82a --- /dev/null +++ b/patches/runtime/split_rr/7.cnt @@ -0,0 +1 @@ +9 \ No newline at end of file diff --git a/patches/runtime/split_rr/999001.cnt b/patches/runtime/split_rr/999001.cnt new file mode 100644 index 0000000..bf0d87a --- /dev/null +++ b/patches/runtime/split_rr/999001.cnt @@ -0,0 +1 @@ +4 \ No newline at end of file diff --git a/patches/third_party/geoip2/geoip2/CHANGELOG.md b/patches/third_party/geoip2/geoip2/CHANGELOG.md new file mode 100644 index 0000000..6af744f --- /dev/null +++ b/patches/third_party/geoip2/geoip2/CHANGELOG.md @@ -0,0 +1,323 @@ +CHANGELOG +========= + +2.13.0 (2022-08-05) +------------------- + +* The model class names are no longer constructed by concatenating strings. + This change was made to improve support for tools like PHP-Scoper. + Reported by Andrew Mead. GitHub #194. +* Box 4.0.1 is now used to generate the `geoip2.phar` file. + +2.12.2 (2021-11-30) +------------------- + +* The `geoip2.phar` now works when included from another directory. + Reported by Eduardo Ruiz. GitHub #179. + +2.12.1 (2021-11-23) +------------------- + +* The `geoip2.phar` included in 2.12.0 would only work in CLI applications. + This was due to a change in Box 3.x. The Phar should now work in all + applications. This release only affects users of the Phar file. + +2.12.0 (2021-11-18) +------------------- + +* Support for mobile country code (MCC) and mobile network codes (MNC) was + added for the GeoIP2 ISP and Enterprise databases as well as the GeoIP2 + City and Insights web services. `$mobileCountryCode` and + `$mobileNetworkCode` properties were added to `GeoIp2\Model\Isp` + for the GeoIP2 ISP database and `GeoIp2\Record\Traits` for the Enterprise + database and the GeoIP2 City and Insights web services. We expect this data + to be available by late January, 2022. +* `geoip2.phar` is now generated with Box 3.x. + +2.11.0 (2020-10-01) +------------------- + +* IMPORTANT: PHP 7.2 or greater is now required. +* Added the `isResidentialProxy` property to `GeoIp2\Model\AnonymousIP` and + `GeoIp2\Record\Traits`. +* Additional type hints have been added. + +2.10.0 (2019-12-12) +------------------- + +* PHP 5.6 or greater is now required. +* The `network` property was added to `GeoIp2\Record\Traits`, + `GeoIp2\Model\AnonymousIp`, `GeoIp2\Model\Asn`, + `GeoIp2\Model\ConnectionType`, `Geoip2\Model\Domain`, + and `GeoIp2\Model\Isp`. This is a string in CIDR format representing the + largest network where all of the properties besides `ipAddress` have the + same value. +* Updated documentation of anonymizer properties - `isAnonymousVpn` + and `isHostingProvider` - to be more descriptive. +* The `userCount` property was added to `GeoIp2\Record\Traits`. This is an + integer which indicates the estimated number of users sharing the + IP/network during the past 24 hours. This output is available from GeoIP2 + Precision Insights. +* The `staticIpScore` property was added to `GeoIp2\Record\Traits`. This is + a float which indicates how static or dynamic an IP address is. This + output is available from GeoIP2 Precision Insights. + +2.9.0 (2018-04-10) +------------------ + +* Refer to account IDs using the terminology "account" rather than "user". + +2.8.0 (2018-01-18) +------------------ + +* The `isInEuropeanUnion` property was added to `GeoIp2\Record\Country` + and `GeoIp2\Record\RepresentedCountry`. This property is `true` if the + country is a member state of the European Union. + +2.7.0 (2017-10-27) +------------------ + +* The following new anonymizer properties were added to `GeoIp2\Record\Traits` + for use with GeoIP2 Precision Insights: `isAnonymous`, `isAnonymousVpn`, + `isHostingProvider`, `isPublicProxy`, and `isTorExitNode`. + +2.6.0 (2017-07-10) +----------------- + +* Code clean-up and tidying. +* Set minimum required PHP version to 5.4 in `composer.json`. Previously, + 5.3 would work but was not tested. Now 5.4 is hard minimum version. + +2.5.0 (2017-05-08) +------------------ + +* Support for PHP 5.3 was dropped. +* Added support for GeoLite2 ASN database. + +2.4.5 (2017-01-31) +------------------ + +* Additional error checking on the data returned from `MaxMind\Db\Reader` + was added to help detect corrupt databases. GitHub #83. + +2.4.4 (2016-10-11) +------------------ + +* `isset()` on `mostSpecificSubdivision` attribute now returns the + correct value. Reported by Juan Francisco Giordana. GitHub #81. + +2.4.3 (2016-10-11) +------------------ + +* `isset()` on `name` attribute now returns the correct value. Reported by + Juan Francisco Giordana. GitHub #79. + +2.4.2 (2016-08-17) +------------------ + +* Updated documentation to clarify what the accuracy radius refers to. +* Upgraded `maxmind/web-service-common` to 0.3.0. This version uses + `composer/ca-bundle` rather than our own CA bundle. GitHub #75. +* Improved PHP documentation generation. + +2.4.1 (2016-06-10) +------------------ + +* Corrected type annotations in documentation. GitHub #66. +* Updated documentation to reflect that the accuracy radius is now included + in City. +* Upgraded web service client, which supports setting a proxy. GitHub #59. + +2.4.0 (2016-04-15) +------------------ + +* Added support for the GeoIP2 Enterprise database. + +2.3.3 (2015-09-24) +------------------ + +* Corrected case on `JsonSerializable` interface. Reported by Axel Etcheverry. + GitHub #56. + +2.3.2 (2015-09-23) +------------------ + +* `JsonSerializable` compatibility interface was moved to `GeoIp2\Compat` + rather than the global namespace to prevent autoloading issues. Reported by + Tomas Buteler. GitHub #54. +* Missing documentation for the `$postal` property was added to the + `GeoIp2\Model\City` class. Fix by Roy Sindre Norangshol. GitHub #51. +* In the Phar distribution, source files for this module no longer have their + documentation stripped, allowing IDE introspection to work properly. + Reported by Dominic Black. GitHub #52. + +2.3.1 (2015-06-30) +------------------ + +* Updated `maxmind/web-service-common` to version with fixes for PHP 5.3 and + 5.4. + +2.3.0 (2015-06-29) +------------------ + +* Support for demographics fields `averageIncome` and `populationDensity` in + the `Location` record, returned by the Insights endpoint. +* The `isAnonymousProxy` and `isSatelliteProvider` properties on + `GeoIP2\Record\Traits` have been deprecated. Please use our [GeoIP2 + Anonymous IP database](https://www.maxmind.com/en/geoip2-anonymous-ip-database) + to determine whether an IP address is used by an anonymizing service. + +2.2.0-beta1 (2015-06-09) +------------------------ + +* Typo fix in documentation. + +2.2.0-alpha2 (2015-06-01) +------------------------- + +* `maxmind-ws/web-service-common` was renamed to `maxmind/web-service-common`. + +2.2.0-alpha1 (2015-05-22) +------------------------- + +* The library no longer uses Guzzle and instead uses curl directly. +* Support for `timeout` and `connectTimout` were added to the `$options` array + passed to the `GeoIp2\WebService\Client` constructor. Pull request by Will + Bradley. GitHub #36. + +2.1.1 (2014-12-03) +------------------ + +* The 2.1.0 Phar builds included a shebang line, causing issues when loading + it as a library. This has been corrected. GitHub #33. + +2.1.0 (2014-10-29) +------------------ + +* Update ApiGen dependency to version that isn't broken on case sensitive + file systems. +* Added support for the GeoIP2 Anonymous IP database. The + `GeoIP2\Database\Reader` class now has an `anonymousIp` method which returns + a `GeoIP2\Model\AnonymousIp` object. +* Boolean attributes like those in the `GeoIP2\Record\Traits` class now return + `false` instead of `null` when they were not true. + +2.0.0 (2014-09-22) +------------------ + +* First production release. + +0.9.0 (2014-09-15) +------------------ + +* IMPORTANT: The deprecated `omni()` and `cityIspOrg()` methods have been + removed from `GeoIp2\WebService\Client`. + +0.8.1 (2014-09-12) +------------------ + +* The check added to the `GeoIP2\Database\Reader` lookup methods in 0.8.0 did + not work with the GeoIP2 City Database Subset by Continent with World + Countries. This has been fixed. Fixes GitHub issue #23. + +0.8.0 (2014-09-10) +------------------ + +* The `GeoIp2\Database\Reader` lookup methods (e.g., `city()`, `isp()`) now + throw a `BadMethodCallException` if they are used with a database that + does not match the method. In particular, doing a `city()` lookup on a + GeoIP2 Country database will result in an exception, and vice versa. +* A `metadata()` method has been added to the `GeoIP2\Database\Reader` class. + This returns a `MaxMind\Db\Reader\Metadata` class with information about the + database. +* The name attribute was missing from the RepresentedCountry class. + +0.7.0 (2014-07-22) +------------------ + +* The web service client API has been updated for the v2.1 release of the web + service. In particular, the `cityIspOrg` and `omni` methods on + `GeoIp2\WebService\Client` should be considered deprecated. The `city` + method now provides all of the data formerly provided by `cityIspOrg`, and + the `omni` method has been replaced by the `insights` method. +* Support was added for GeoIP2 Connection Type, Domain and ISP databases. + + +0.6.3 (2014-05-12) +------------------ + +* With the previous Phar builds, some users received `phar error: invalid url + or non-existent phar` errors. The correct alias is now used for the Phar, + and this should no longer be an issue. + +0.6.2 (2014-05-08) +------------------ + +* The Phar build was broken with Guzzle 3.9.0+. This has been fixed. + +0.6.1 (2014-05-01) +------------------ + +* This API now officially supports HHVM. +* The `maxmind-db/reader` dependency was updated to a version that does not + require BC Math. +* The Composer compatibility autoload rules are now targeted more narrowly. +* A `box.json` file is included to build a Phar package. + +0.6.0 (2014-02-19) +------------------ + +* This API is now licensed under the Apache License, Version 2.0. +* Model and record classes now implement `JsonSerializable`. +* `isset` now works with model and record classes. + +0.5.0 (2013-10-21) +------------------ + +* Renamed $languages constructor parameters to $locales for both the Client + and Reader classes. +* Documentation and code clean-up (Ben Morel). +* Added the interface `GeoIp2\ProviderInterface`, which is implemented by both + `\GeoIp2\Database\Reader` and `\GeoIp2\WebService\Client`. + +0.4.0 (2013-07-16) +------------------ + +* This is the first release with the GeoIP2 database reader. Please see the + `README.md` file and the `\GeoIp2\Database\Reader` class. +* The general exception classes were replaced with specific exception classes + representing particular types of errors, such as an authentication error. + +0.3.0 (2013-07-12) +------------------ + +* In namespaces and class names, "GeoIP2" was renamed to "GeoIp2" to improve + consistency. + +0.2.1 (2013-06-10) +------------------ + +* First official beta release. +* Documentation updates and corrections. + +0.2.0 (2013-05-29) +------------------ + +* `GenericException` was renamed to `GeoIP2Exception`. +* We now support more languages. The new languages are de, es, fr, and pt-BR. +* The REST API now returns a record with data about your account. There is + a new `GeoIP\Records\MaxMind` class for this data. +* The `continentCode` attribute on `Continent` was renamed to `code`. +* Documentation updates. + +0.1.1 (2013-05-14) +------------------ + +* Updated Guzzle version requirement. +* Fixed Composer example in README.md. + + +0.1.0 (2013-05-13) +------------------ + +* Initial release. diff --git a/patches/third_party/geoip2/geoip2/LICENSE b/patches/third_party/geoip2/geoip2/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/patches/third_party/geoip2/geoip2/README.md b/patches/third_party/geoip2/geoip2/README.md new file mode 100644 index 0000000..ea08e27 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/README.md @@ -0,0 +1,442 @@ +# GeoIP2 PHP API # + +## Description ## + +This package provides an API for the GeoIP2 and GeoLite2 +[web services](https://dev.maxmind.com/geoip/docs/web-services?lang=en) and +[databases](https://dev.maxmind.com/geoip/docs/databases?lang=en). + +## Install via Composer ## + +We recommend installing this package with [Composer](https://getcomposer.org/). + +### Download Composer ### + +To download Composer, run in the root directory of your project: + +```bash +curl -sS https://getcomposer.org/installer | php +``` + +You should now have the file `composer.phar` in your project directory. + +### Install Dependencies ### + +Run in your project root: + +```sh +php composer.phar require geoip2/geoip2:~2.0 +``` + +You should now have the files `composer.json` and `composer.lock` as well as +the directory `vendor` in your project directory. If you use a version control +system, `composer.json` should be added to it. + +### Require Autoloader ### + +After installing the dependencies, you need to require the Composer autoloader +from your code: + +```php +require 'vendor/autoload.php'; +``` + +## Install via Phar ## + +Although we strongly recommend using Composer, we also provide a +[phar archive](https://php.net/manual/en/book.phar.php) containing most of the +dependencies for GeoIP2. Our latest phar archive is available on +[our releases page](https://github.com/maxmind/GeoIP2-php/releases). + +### Install Dependencies ### + +In order to use the phar archive, you must have the PHP +[Phar extension](https://php.net/manual/en/book.phar.php) installed and +enabled. + +If you will be making web service requests, you must have the PHP +[cURL extension](https://php.net/manual/en/book.curl.php) +installed to use this archive. For Debian based distributions, this can +typically be found in the the `php-curl` package. For other operating +systems, please consult the relevant documentation. After installing the +extension you may need to restart your web server. + +If you are missing this extension, you will see errors like the following: + +``` +PHP Fatal error: Uncaught Error: Call to undefined function MaxMind\WebService\curl_version() +``` + +### Require Package ### + +To use the archive, just require it from your script: + +```php +require 'geoip2.phar'; +``` + +## Optional C Extension ## + +The [MaxMind DB API](https://github.com/maxmind/MaxMind-DB-Reader-php) +includes an optional C extension that you may install to dramatically increase +the performance of lookups in GeoIP2 or GeoLite2 databases. To install, please +follow the instructions included with that API. + +The extension has no effect on web-service lookups. + +## IP Geolocation Usage ## + +IP geolocation is inherently imprecise. Locations are often near the center of +the population. Any location provided by a GeoIP2 database or web service +should not be used to identify a particular address or household. + +## Database Reader ## + +### Usage ### + +To use this API, you must create a new `\GeoIp2\Database\Reader` object with +the path to the database file as the first argument to the constructor. You +may then call the method corresponding to the database you are using. + +If the lookup succeeds, the method call will return a model class for the +record in the database. This model in turn contains multiple container +classes for the different parts of the data such as the city in which the +IP address is located. + +If the record is not found, a `\GeoIp2\Exception\AddressNotFoundException` +is thrown. If the database is invalid or corrupt, a +`\MaxMind\Db\InvalidDatabaseException` will be thrown. + +See the API documentation for more details. + +### City Example ### + +```php +city('128.101.101.101'); + +print($record->country->isoCode . "\n"); // 'US' +print($record->country->name . "\n"); // 'United States' +print($record->country->names['zh-CN'] . "\n"); // '美国' + +print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota' +print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN' + +print($record->city->name . "\n"); // 'Minneapolis' + +print($record->postal->code . "\n"); // '55455' + +print($record->location->latitude . "\n"); // 44.9733 +print($record->location->longitude . "\n"); // -93.2323 + +print($record->traits->network . "\n"); // '128.101.101.101/32' + +``` + +### Anonymous IP Example ### + +```php +anonymousIp('128.101.101.101'); + +if ($record->isAnonymous) { print "anon\n"; } +print($record->ipAddress . "\n"); // '128.101.101.101' +print($record->network . "\n"); // '128.101.101.101/32' + +``` + +### Connection-Type Example ### + +```php +connectionType('128.101.101.101'); + +print($record->connectionType . "\n"); // 'Corporate' +print($record->ipAddress . "\n"); // '128.101.101.101' +print($record->network . "\n"); // '128.101.101.101/32' + +``` + +### Domain Example ### + +```php +domain('128.101.101.101'); + +print($record->domain . "\n"); // 'umn.edu' +print($record->ipAddress . "\n"); // '128.101.101.101' +print($record->network . "\n"); // '128.101.101.101/32' + +``` + +### Enterprise Example ### + +```php +enterprise method to do a lookup in the Enterprise database +$record = $reader->enterprise('128.101.101.101'); + +print($record->country->confidence . "\n"); // 99 +print($record->country->isoCode . "\n"); // 'US' +print($record->country->name . "\n"); // 'United States' +print($record->country->names['zh-CN'] . "\n"); // '美国' + +print($record->mostSpecificSubdivision->confidence . "\n"); // 77 +print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota' +print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN' + +print($record->city->confidence . "\n"); // 60 +print($record->city->name . "\n"); // 'Minneapolis' + +print($record->postal->code . "\n"); // '55455' + +print($record->location->accuracyRadius . "\n"); // 50 +print($record->location->latitude . "\n"); // 44.9733 +print($record->location->longitude . "\n"); // -93.2323 + +print($record->traits->network . "\n"); // '128.101.101.101/32' + +``` + +### ISP Example ### + +```php +isp('128.101.101.101'); + +print($record->autonomousSystemNumber . "\n"); // 217 +print($record->autonomousSystemOrganization . "\n"); // 'University of Minnesota' +print($record->isp . "\n"); // 'University of Minnesota' +print($record->organization . "\n"); // 'University of Minnesota' + +print($record->ipAddress . "\n"); // '128.101.101.101' +print($record->network . "\n"); // '128.101.101.101/32' + +``` + +## Database Updates ## + +You can keep your databases up to date with our +[GeoIP Update program](https://github.com/maxmind/geoipupdate/releases). +[Learn more about GeoIP Update on our developer +portal.](https://dev.maxmind.com/geoip/updating-databases?lang=en) + +There is also a third-party tool for updating databases using PHP and +Composer. MaxMind does not offer support for this tool or maintain it. +[Learn more about the Geoip2 Update tool for PHP and Composer on its +GitHub page.](https://github.com/tronovav/geoip2-update) + +## Web Service Client ## + +### Usage ### + +To use this API, you must create a new `\GeoIp2\WebService\Client` +object with your `$accountId` and `$licenseKey`: + +```php +$client = new Client(42, 'abcdef123456'); +``` + +You may also call the constructor with additional arguments. The third argument +specifies the language preferences when using the `->name` method on the model +classes that this client creates. The fourth argument is additional options +such as `host` and `timeout`. + +For instance, to call the GeoLite2 web service instead of the GeoIP2 web +service: + +```php +$client = new Client(42, 'abcdef123456', ['en'], ['host' => 'geolite.info']); +``` + +After creating the client, you may now call the method corresponding to a +specific endpoint with the IP address to look up, e.g.: + +```php +$record = $client->city('128.101.101.101'); +``` + +If the request succeeds, the method call will return a model class for the +endpoint you called. This model in turn contains multiple record classes, each +of which represents part of the data returned by the web service. + +If there is an error, a structured exception is thrown. + +See the API documentation for more details. + +### Example ### + +```php +city('128.101.101.101'); + +print($record->country->isoCode . "\n"); // 'US' +print($record->country->name . "\n"); // 'United States' +print($record->country->names['zh-CN'] . "\n"); // '美国' + +print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota' +print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN' + +print($record->city->name . "\n"); // 'Minneapolis' + +print($record->postal->code . "\n"); // '55455' + +print($record->location->latitude . "\n"); // 44.9733 +print($record->location->longitude . "\n"); // -93.2323 + +print($record->traits->network . "\n"); // '128.101.101.101/32' + +``` + +## Values to use for Database or Array Keys ## + +**We strongly discourage you from using a value from any `names` property as +a key in a database or array.** + +These names may change between releases. Instead we recommend using one of the +following: + +* `GeoIp2\Record\City` - `$city->geonameId` +* `GeoIp2\Record\Continent` - `$continent->code` or `$continent->geonameId` +* `GeoIp2\Record\Country` and `GeoIp2\Record\RepresentedCountry` - + `$country->isoCode` or `$country->geonameId` +* `GeoIp2\Record\Subdivision` - `$subdivision->isoCode` or `$subdivision->geonameId` + +### What data is returned? ### + +While many of the end points return the same basic records, the attributes +which can be populated vary between end points. In addition, while an end +point may offer a particular piece of data, MaxMind does not always have every +piece of data for any given IP address. + +Because of these factors, it is possible for any end point to return a record +where some or all of the attributes are unpopulated. + +See the +[GeoIP2 web service docs](https://dev.maxmind.com/geoip/docs/web-services?lang=en) +for details on what data each end point may return. + +The only piece of data which is always returned is the `ipAddress` +attribute in the `GeoIp2\Record\Traits` record. + +## Integration with GeoNames ## + +[GeoNames](https://www.geonames.org/) offers web services and downloadable +databases with data on geographical features around the world, including +populated places. They offer both free and paid premium data. Each +feature is unique identified by a `geonameId`, which is an integer. + +Many of the records returned by the GeoIP2 web services and databases +include a `geonameId` property. This is the ID of a geographical feature +(city, region, country, etc.) in the GeoNames database. + +Some of the data that MaxMind provides is also sourced from GeoNames. We +source things like place names, ISO codes, and other similar data from +the GeoNames premium data set. + +## Reporting data problems ## + +If the problem you find is that an IP address is incorrectly mapped, +please +[submit your correction to MaxMind](https://www.maxmind.com/en/correction). + +If you find some other sort of mistake, like an incorrect spelling, +please check the [GeoNames site](https://www.geonames.org/) first. Once +you've searched for a place and found it on the GeoNames map view, there +are a number of links you can use to correct data ("move", "edit", +"alternate names", etc.). Once the correction is part of the GeoNames +data set, it will be automatically incorporated into future MaxMind +releases. + +If you are a paying MaxMind customer and you're not sure where to submit +a correction, please +[contact MaxMind support](https://www.maxmind.com/en/support) for help. + +## Other Support ## + +Please report all issues with this code using the +[GitHub issue tracker](https://github.com/maxmind/GeoIP2-php/issues). + +If you are having an issue with a MaxMind service that is not specific +to the client API, please see +[our support page](https://www.maxmind.com/en/support). + +## Requirements ## + +This library requires PHP 7.2 or greater. + +This library also relies on the [MaxMind DB Reader](https://github.com/maxmind/MaxMind-DB-Reader-php). + +## Contributing ## + +Patches and pull requests are encouraged. All code should follow the PSR-2 +style guidelines. Please include unit tests whenever possible. You may obtain +the test data for the maxmind-db folder by running `git submodule update +--init --recursive` or adding `--recursive` to your initial clone, or from +https://github.com/maxmind/MaxMind-DB + +## Versioning ## + +The GeoIP2 PHP API uses [Semantic Versioning](https://semver.org/). + +## Copyright and License ## + +This software is Copyright (c) 2013-2020 by MaxMind, Inc. + +This is free software, licensed under the Apache License, Version 2.0. diff --git a/patches/third_party/geoip2/geoip2/composer.json b/patches/third_party/geoip2/geoip2/composer.json new file mode 100644 index 0000000..e29513b --- /dev/null +++ b/patches/third_party/geoip2/geoip2/composer.json @@ -0,0 +1,32 @@ +{ + "name": "geoip2/geoip2", + "description": "MaxMind GeoIP2 PHP API", + "keywords": ["geoip", "geoip2", "geolocation", "ip", "maxmind"], + "homepage": "https://github.com/maxmind/GeoIP2-php", + "type": "library", + "license": "Apache-2.0", + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "require": { + "maxmind-db/reader": "~1.8", + "maxmind/web-service-common": "~0.8", + "php": ">=7.2", + "ext-json": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpunit/phpunit": "^8.0 || ^9.0", + "squizlabs/php_codesniffer": "3.*", + "phpstan/phpstan": "*" + }, + "autoload": { + "psr-4": { + "GeoIp2\\": "src" + } + } +} diff --git a/patches/third_party/geoip2/geoip2/examples/benchmark.php b/patches/third_party/geoip2/geoip2/examples/benchmark.php new file mode 100644 index 0000000..a735a78 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/examples/benchmark.php @@ -0,0 +1,26 @@ +city($ip); + } catch (\GeoIp2\Exception\AddressNotFoundException $e) { + } + if ($i % 10000 === 0) { + echo $i . ' ' . $ip . "\n"; + } +} +$endTime = microtime(true); + +$duration = $endTime - $startTime; +echo 'Requests per second: ' . $count / $duration . "\n"; diff --git a/patches/third_party/geoip2/geoip2/src/Database/Reader.php b/patches/third_party/geoip2/geoip2/src/Database/Reader.php new file mode 100644 index 0000000..4dabc5d --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Database/Reader.php @@ -0,0 +1,299 @@ + + */ + private $locales; + + /** + * Constructor. + * + * @param string $filename the path to the GeoIP2 database file + * @param array $locales list of locale codes to use in name property + * from most preferred to least preferred + * + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function __construct( + string $filename, + array $locales = ['en'] + ) { + $this->dbReader = new DbReader($filename); + $this->dbType = $this->dbReader->metadata()->databaseType; + $this->locales = $locales; + } + + /** + * This method returns a GeoIP2 City model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function city(string $ipAddress): City + { + // @phpstan-ignore-next-line + return $this->modelFor(City::class, 'City', $ipAddress); + } + + /** + * This method returns a GeoIP2 Country model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function country(string $ipAddress): Country + { + // @phpstan-ignore-next-line + return $this->modelFor(Country::class, 'Country', $ipAddress); + } + + /** + * This method returns a GeoIP2 Anonymous IP model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function anonymousIp(string $ipAddress): AnonymousIp + { + // @phpstan-ignore-next-line + return $this->flatModelFor( + AnonymousIp::class, + 'GeoIP2-Anonymous-IP', + $ipAddress + ); + } + + /** + * This method returns a GeoLite2 ASN model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function asn(string $ipAddress): Asn + { + // @phpstan-ignore-next-line + return $this->flatModelFor( + Asn::class, + 'GeoLite2-ASN', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Connection Type model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function connectionType(string $ipAddress): ConnectionType + { + // @phpstan-ignore-next-line + return $this->flatModelFor( + ConnectionType::class, + 'GeoIP2-Connection-Type', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Domain model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function domain(string $ipAddress): Domain + { + // @phpstan-ignore-next-line + return $this->flatModelFor( + Domain::class, + 'GeoIP2-Domain', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Enterprise model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function enterprise(string $ipAddress): Enterprise + { + // @phpstan-ignore-next-line + return $this->modelFor(Enterprise::class, 'Enterprise', $ipAddress); + } + + /** + * This method returns a GeoIP2 ISP model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + */ + public function isp(string $ipAddress): Isp + { + // @phpstan-ignore-next-line + return $this->flatModelFor( + Isp::class, + 'GeoIP2-ISP', + $ipAddress + ); + } + + private function modelFor(string $class, string $type, string $ipAddress): AbstractModel + { + [$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress); + + $record['traits']['ip_address'] = $ipAddress; + $record['traits']['prefix_len'] = $prefixLen; + + return new $class($record, $this->locales); + } + + private function flatModelFor(string $class, string $type, string $ipAddress): AbstractModel + { + [$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress); + + $record['ip_address'] = $ipAddress; + $record['prefix_len'] = $prefixLen; + + return new $class($record); + } + + private function getRecord(string $class, string $type, string $ipAddress): array + { + if (strpos($this->dbType, $type) === false) { + $method = lcfirst((new \ReflectionClass($class))->getShortName()); + + throw new \BadMethodCallException( + "The $method method cannot be used to open a {$this->dbType} database" + ); + } + [$record, $prefixLen] = $this->dbReader->getWithPrefixLen($ipAddress); + if ($record === null) { + throw new AddressNotFoundException( + "The address $ipAddress is not in the database." + ); + } + if (!\is_array($record)) { + // This can happen on corrupt databases. Generally, + // MaxMind\Db\Reader will throw a + // MaxMind\Db\Reader\InvalidDatabaseException, but occasionally + // the lookup may result in a record that looks valid but is not + // an array. This mostly happens when the user is ignoring all + // exceptions and the more frequent InvalidDatabaseException + // exceptions go unnoticed. + throw new InvalidDatabaseException( + "Expected an array when looking up $ipAddress but received: " + . \gettype($record) + ); + } + + return [$record, $prefixLen]; + } + + /** + * @throws \InvalidArgumentException if arguments are passed to the method + * @throws \BadMethodCallException if the database has been closed + * + * @return \MaxMind\Db\Reader\Metadata object for the database + */ + public function metadata(): DbReader\Metadata + { + return $this->dbReader->metadata(); + } + + /** + * Closes the GeoIP2 database and returns the resources to the system. + */ + public function close(): void + { + $this->dbReader->close(); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php b/patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php new file mode 100644 index 0000000..628fb06 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php @@ -0,0 +1,12 @@ +uri = $uri; + parent::__construct($message, $httpStatus, $previous); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php b/patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php new file mode 100644 index 0000000..925b68d --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php @@ -0,0 +1,30 @@ +error = $error; + parent::__construct($message, $httpStatus, $uri, $previous); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php b/patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php new file mode 100644 index 0000000..9734c8c --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php @@ -0,0 +1,12 @@ + + */ + protected $raw; + + /** + * @ignore + */ + public function __construct(array $raw) + { + $this->raw = $raw; + } + + /** + * @ignore + * + * @return mixed + */ + protected function get(string $field) + { + if (isset($this->raw[$field])) { + return $this->raw[$field]; + } + if (preg_match('/^is_/', $field)) { + return false; + } + + return null; + } + + /** + * @ignore + * + * @return mixed + */ + public function __get(string $attr) + { + if ($attr !== 'instance' && property_exists($this, $attr)) { + return $this->{$attr}; + } + + throw new \RuntimeException("Unknown attribute: $attr"); + } + + /** + * @ignore + */ + public function __isset(string $attr): bool + { + return $attr !== 'instance' && isset($this->{$attr}); + } + + public function jsonSerialize(): array + { + return $this->raw; + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php b/patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php new file mode 100644 index 0000000..5586bd0 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php @@ -0,0 +1,91 @@ +isAnonymous = $this->get('is_anonymous'); + $this->isAnonymousVpn = $this->get('is_anonymous_vpn'); + $this->isHostingProvider = $this->get('is_hosting_provider'); + $this->isPublicProxy = $this->get('is_public_proxy'); + $this->isResidentialProxy = $this->get('is_residential_proxy'); + $this->isTorExitNode = $this->get('is_tor_exit_node'); + $ipAddress = $this->get('ip_address'); + $this->ipAddress = $ipAddress; + $this->network = Util::cidr($ipAddress, $this->get('prefix_len')); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/Asn.php b/patches/third_party/geoip2/geoip2/src/Model/Asn.php new file mode 100644 index 0000000..f05177e --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/Asn.php @@ -0,0 +1,58 @@ +autonomousSystemNumber = $this->get('autonomous_system_number'); + $this->autonomousSystemOrganization = + $this->get('autonomous_system_organization'); + $ipAddress = $this->get('ip_address'); + $this->ipAddress = $ipAddress; + $this->network = Util::cidr($ipAddress, $this->get('prefix_len')); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/City.php b/patches/third_party/geoip2/geoip2/src/Model/City.php new file mode 100644 index 0000000..b2e81dc --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/City.php @@ -0,0 +1,123 @@ + + */ + protected $subdivisions = []; + + /** + * @ignore + */ + public function __construct(array $raw, array $locales = ['en']) + { + parent::__construct($raw, $locales); + + $this->city = new \GeoIp2\Record\City($this->get('city'), $locales); + $this->location = new \GeoIp2\Record\Location($this->get('location')); + $this->postal = new \GeoIp2\Record\Postal($this->get('postal')); + + $this->createSubdivisions($raw, $locales); + } + + private function createSubdivisions(array $raw, array $locales): void + { + if (!isset($raw['subdivisions'])) { + return; + } + + foreach ($raw['subdivisions'] as $sub) { + $this->subdivisions[] = + new \GeoIp2\Record\Subdivision($sub, $locales) + ; + } + } + + /** + * @ignore + * + * @return mixed + */ + public function __get(string $attr) + { + if ($attr === 'mostSpecificSubdivision') { + return $this->{$attr}(); + } + + return parent::__get($attr); + } + + /** + * @ignore + */ + public function __isset(string $attr): bool + { + if ($attr === 'mostSpecificSubdivision') { + // We always return a mostSpecificSubdivision, even if it is the + // empty subdivision + return true; + } + + return parent::__isset($attr); + } + + private function mostSpecificSubdivision(): \GeoIp2\Record\Subdivision + { + return empty($this->subdivisions) ? + new \GeoIp2\Record\Subdivision([], $this->locales) : + end($this->subdivisions); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php b/patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php new file mode 100644 index 0000000..36d4529 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php @@ -0,0 +1,50 @@ +connectionType = $this->get('connection_type'); + $ipAddress = $this->get('ip_address'); + $this->ipAddress = $ipAddress; + $this->network = Util::cidr($ipAddress, $this->get('prefix_len')); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/Country.php b/patches/third_party/geoip2/geoip2/src/Model/Country.php new file mode 100644 index 0000000..fdffc63 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/Country.php @@ -0,0 +1,96 @@ + + */ + protected $locales; + + /** + * @var \GeoIp2\Record\MaxMind + */ + protected $maxmind; + + /** + * @var \GeoIp2\Record\Country + */ + protected $registeredCountry; + + /** + * @var \GeoIp2\Record\RepresentedCountry + */ + protected $representedCountry; + + /** + * @var \GeoIp2\Record\Traits + */ + protected $traits; + + /** + * @ignore + */ + public function __construct(array $raw, array $locales = ['en']) + { + parent::__construct($raw); + + $this->continent = new \GeoIp2\Record\Continent( + $this->get('continent'), + $locales + ); + $this->country = new \GeoIp2\Record\Country( + $this->get('country'), + $locales + ); + $this->maxmind = new \GeoIp2\Record\MaxMind($this->get('maxmind')); + $this->registeredCountry = new \GeoIp2\Record\Country( + $this->get('registered_country'), + $locales + ); + $this->representedCountry = new \GeoIp2\Record\RepresentedCountry( + $this->get('represented_country'), + $locales + ); + $this->traits = new \GeoIp2\Record\Traits($this->get('traits')); + + $this->locales = $locales; + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/Domain.php b/patches/third_party/geoip2/geoip2/src/Model/Domain.php new file mode 100644 index 0000000..067a507 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/Domain.php @@ -0,0 +1,50 @@ +domain = $this->get('domain'); + $ipAddress = $this->get('ip_address'); + $this->ipAddress = $ipAddress; + $this->network = Util::cidr($ipAddress, $this->get('prefix_len')); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Model/Enterprise.php b/patches/third_party/geoip2/geoip2/src/Model/Enterprise.php new file mode 100644 index 0000000..c63469b --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Model/Enterprise.php @@ -0,0 +1,15 @@ +autonomousSystemNumber = $this->get('autonomous_system_number'); + $this->autonomousSystemOrganization = + $this->get('autonomous_system_organization'); + $this->isp = $this->get('isp'); + $this->mobileCountryCode = $this->get('mobile_country_code'); + $this->mobileNetworkCode = $this->get('mobile_network_code'); + $this->organization = $this->get('organization'); + + $ipAddress = $this->get('ip_address'); + $this->ipAddress = $ipAddress; + $this->network = Util::cidr($ipAddress, $this->get('prefix_len')); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/ProviderInterface.php b/patches/third_party/geoip2/geoip2/src/ProviderInterface.php new file mode 100644 index 0000000..7d14891 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/ProviderInterface.php @@ -0,0 +1,22 @@ + + */ + private $locales; + + /** + * @ignore + */ + public function __construct(?array $record, array $locales = ['en']) + { + $this->locales = $locales; + parent::__construct($record); + } + + /** + * @ignore + * + * @return mixed + */ + public function __get(string $attr) + { + if ($attr === 'name') { + return $this->name(); + } + + return parent::__get($attr); + } + + /** + * @ignore + */ + public function __isset(string $attr): bool + { + if ($attr === 'name') { + return $this->firstSetNameLocale() !== null; + } + + return parent::__isset($attr); + } + + private function name(): ?string + { + $locale = $this->firstSetNameLocale(); + + // @phpstan-ignore-next-line + return $locale === null ? null : $this->names[$locale]; + } + + private function firstSetNameLocale(): ?string + { + foreach ($this->locales as $locale) { + if (isset($this->names[$locale])) { + return $locale; + } + } + + return null; + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php b/patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php new file mode 100644 index 0000000..5ddb3c6 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php @@ -0,0 +1,67 @@ + + */ + private $record; + + /** + * @ignore + */ + public function __construct(?array $record) + { + $this->record = isset($record) ? $record : []; + } + + /** + * @ignore + * + * @return mixed + */ + public function __get(string $attr) + { + // XXX - kind of ugly but greatly reduces boilerplate code + $key = $this->attributeToKey($attr); + + if ($this->__isset($attr)) { + return $this->record[$key]; + } + if ($this->validAttribute($attr)) { + if (preg_match('/^is_/', $key)) { + return false; + } + + return null; + } + + throw new \RuntimeException("Unknown attribute: $attr"); + } + + public function __isset(string $attr): bool + { + return $this->validAttribute($attr) + && isset($this->record[$this->attributeToKey($attr)]); + } + + private function attributeToKey(string $attr): string + { + return strtolower(preg_replace('/([A-Z])/', '_\1', $attr)); + } + + private function validAttribute(string $attr): bool + { + // @phpstan-ignore-next-line + return \in_array($attr, $this->validAttributes, true); + } + + public function jsonSerialize(): ?array + { + return $this->record; + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/City.php b/patches/third_party/geoip2/geoip2/src/Record/City.php new file mode 100644 index 0000000..f25dcb3 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/City.php @@ -0,0 +1,33 @@ + + */ + protected $validAttributes = ['confidence', 'geonameId', 'names']; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Continent.php b/patches/third_party/geoip2/geoip2/src/Record/Continent.php new file mode 100644 index 0000000..103e2e3 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Continent.php @@ -0,0 +1,36 @@ + + */ + protected $validAttributes = [ + 'code', + 'geonameId', + 'names', + ]; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Country.php b/patches/third_party/geoip2/geoip2/src/Record/Country.php new file mode 100644 index 0000000..3009ebc --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Country.php @@ -0,0 +1,44 @@ + + */ + protected $validAttributes = [ + 'confidence', + 'geonameId', + 'isInEuropeanUnion', + 'isoCode', + 'names', + ]; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Location.php b/patches/third_party/geoip2/geoip2/src/Record/Location.php new file mode 100644 index 0000000..cb6111c --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Location.php @@ -0,0 +1,56 @@ + + */ + protected $validAttributes = [ + 'averageIncome', + 'accuracyRadius', + 'latitude', + 'longitude', + 'metroCode', + 'populationDensity', + 'postalCode', + 'postalConfidence', + 'timeZone', + ]; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/MaxMind.php b/patches/third_party/geoip2/geoip2/src/Record/MaxMind.php new file mode 100644 index 0000000..e972506 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/MaxMind.php @@ -0,0 +1,23 @@ + + */ + protected $validAttributes = ['queriesRemaining']; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Postal.php b/patches/third_party/geoip2/geoip2/src/Record/Postal.php new file mode 100644 index 0000000..3e9c237 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Postal.php @@ -0,0 +1,30 @@ + + */ + protected $validAttributes = ['code', 'confidence']; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php b/patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php new file mode 100644 index 0000000..727c034 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php @@ -0,0 +1,33 @@ +military + * but this could expand to include other types in the future. + */ +class RepresentedCountry extends Country +{ + /** + * @ignore + * + * @var array + */ + protected $validAttributes = [ + 'confidence', + 'geonameId', + 'isInEuropeanUnion', + 'isoCode', + 'names', + 'type', + ]; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Subdivision.php b/patches/third_party/geoip2/geoip2/src/Record/Subdivision.php new file mode 100644 index 0000000..0e83549 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Subdivision.php @@ -0,0 +1,44 @@ + + */ + protected $validAttributes = [ + 'confidence', + 'geonameId', + 'isoCode', + 'names', + ]; +} diff --git a/patches/third_party/geoip2/geoip2/src/Record/Traits.php b/patches/third_party/geoip2/geoip2/src/Record/Traits.php new file mode 100644 index 0000000..8000d50 --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Record/Traits.php @@ -0,0 +1,158 @@ +The user type associated with the IP + * address. This can be one of the following values:

+ *
    + *
  • business + *
  • cafe + *
  • cellular + *
  • college + *
  • consumer_privacy_network + *
  • content_delivery_network + *
  • dialup + *
  • government + *
  • hosting + *
  • library + *
  • military + *
  • residential + *
  • router + *
  • school + *
  • search_engine_spider + *
  • traveler + *
+ *

+ * This attribute is only available from the Insights web service and the + * GeoIP2 Enterprise database. + *

+ */ +class Traits extends AbstractRecord +{ + /** + * @ignore + * + * @var array + */ + protected $validAttributes = [ + 'autonomousSystemNumber', + 'autonomousSystemOrganization', + 'connectionType', + 'domain', + 'ipAddress', + 'isAnonymous', + 'isAnonymousProxy', + 'isAnonymousVpn', + 'isHostingProvider', + 'isLegitimateProxy', + 'isp', + 'isPublicProxy', + 'isResidentialProxy', + 'isSatelliteProvider', + 'isTorExitNode', + 'mobileCountryCode', + 'mobileNetworkCode', + 'network', + 'organization', + 'staticIpScore', + 'userCount', + 'userType', + ]; + + public function __construct(?array $record) + { + if (!isset($record['network']) && isset($record['ip_address'], $record['prefix_len'])) { + $record['network'] = Util::cidr($record['ip_address'], $record['prefix_len']); + } + + parent::__construct($record); + } +} diff --git a/patches/third_party/geoip2/geoip2/src/Util.php b/patches/third_party/geoip2/geoip2/src/Util.php new file mode 100644 index 0000000..e0a03be --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/Util.php @@ -0,0 +1,36 @@ + 0; $i++) { + $b = $ipBytes[$i]; + if ($curPrefix < 8) { + $shiftN = 8 - $curPrefix; + $b = \chr(0xFF & (\ord($b) >> $shiftN) << $shiftN); + } + $networkBytes[$i] = $b; + $curPrefix -= 8; + } + + $network = inet_ntop($networkBytes); + + return "$network/$prefixLen"; + } +} diff --git a/patches/third_party/geoip2/geoip2/src/WebService/Client.php b/patches/third_party/geoip2/geoip2/src/WebService/Client.php new file mode 100644 index 0000000..18d979d --- /dev/null +++ b/patches/third_party/geoip2/geoip2/src/WebService/Client.php @@ -0,0 +1,255 @@ + + */ + private $locales; + + /** + * @var WsClient + */ + private $client; + + /** + * @var string + */ + private static $basePath = '/geoip/v2.1'; + + public const VERSION = 'v2.13.0'; + + /** + * Constructor. + * + * @param int $accountId your MaxMind account ID + * @param string $licenseKey your MaxMind license key + * @param array $locales list of locale codes to use in name property + * from most preferred to least preferred + * @param array $options array of options. Valid options include: + * * `host` - The host to use when querying the web + * service. To query the GeoLite2 web service + * instead of the GeoIP2 web service, set the + * host to `geolite.info`. + * * `timeout` - Timeout in seconds. + * * `connectTimeout` - Initial connection timeout in seconds. + * * `proxy` - The HTTP proxy to use. May include a schema, port, + * username, and password, e.g., + * `http://username:password@127.0.0.1:10`. + */ + public function __construct( + int $accountId, + string $licenseKey, + array $locales = ['en'], + array $options = [] + ) { + $this->locales = $locales; + + // This is for backwards compatibility. Do not remove except for a + // major version bump. + // @phpstan-ignore-next-line + if (\is_string($options)) { + $options = ['host' => $options]; + } + + if (!isset($options['host'])) { + $options['host'] = 'geoip.maxmind.com'; + } + + $options['userAgent'] = $this->userAgent(); + + $this->client = new WsClient($accountId, $licenseKey, $options); + } + + private function userAgent(): string + { + return 'GeoIP2-API/' . self::VERSION; + } + + /** + * This method calls the City Plus service. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you + * provided is not in our database (e.g., a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out + * of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an issue + * with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error code or message was returned. + * This could indicate a problem with the connection between + * your server and the web service or that the web service + * returned an invalid document or 500 error code + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent + * class to the above exceptions. It will be thrown directly + * if a 200 status code is returned but the body is invalid. + */ + public function city(string $ipAddress = 'me'): City + { + // @phpstan-ignore-next-line + return $this->responseFor('city', City::class, $ipAddress); + } + + /** + * This method calls the Country service. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you provided is not in our database (e.g., + * a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an + * issue with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error + * code or message was returned. This could indicate a problem + * with the connection between your server and the web service + * or that the web service returned an invalid document or 500 + * error code. + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent class to the above exceptions. It + * will be thrown directly if a 200 status code is returned but + * the body is invalid. + */ + public function country(string $ipAddress = 'me'): Country + { + return $this->responseFor('country', Country::class, $ipAddress); + } + + /** + * This method calls the Insights service. Insights is only supported by + * the GeoIP2 web service. The GeoLite2 web service does not support it. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you + * provided is not in our database (e.g., a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out + * of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an + * issue with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error code or message was returned. + * This could indicate a problem with the connection between + * your server and the web service or that the web service + * returned an invalid document or 500 error code + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent + * class to the above exceptions. It will be thrown directly + * if a 200 status code is returned but the body is invalid. + */ + public function insights(string $ipAddress = 'me'): Insights + { + // @phpstan-ignore-next-line + return $this->responseFor('insights', Insights::class, $ipAddress); + } + + private function responseFor(string $endpoint, string $class, string $ipAddress): Country + { + $path = implode('/', [self::$basePath, $endpoint, $ipAddress]); + + try { + $service = (new \ReflectionClass($class))->getShortName(); + $body = $this->client->get('GeoIP2 ' . $service, $path); + } catch (\MaxMind\Exception\IpAddressNotFoundException $ex) { + throw new AddressNotFoundException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\AuthenticationException $ex) { + throw new AuthenticationException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\InsufficientFundsException $ex) { + throw new OutOfQueriesException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\InvalidRequestException $ex) { + throw new InvalidRequestException( + $ex->getMessage(), + $ex->getErrorCode(), + $ex->getStatusCode(), + $ex->getUri(), + $ex + ); + } catch (\MaxMind\Exception\HttpException $ex) { + throw new HttpException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex->getUri(), + $ex + ); + } catch (\MaxMind\Exception\WebServiceException $ex) { + throw new GeoIp2Exception( + $ex->getMessage(), + $ex->getCode(), + $ex + ); + } + + return new $class($body, $this->locales); + } +} diff --git a/patches/third_party/load_geoip2.php b/patches/third_party/load_geoip2.php new file mode 100644 index 0000000..c8f3357 --- /dev/null +++ b/patches/third_party/load_geoip2.php @@ -0,0 +1,35 @@ + $base . '/geoip2/geoip2/src/', + 'MaxMind\\Exception\\' => $base . '/maxmind/web-service-common/src/Exception/', + ]; + foreach ($map as $prefix => $dir) { + if (strpos($class, $prefix) !== 0) { + continue; + } + $relative = str_replace('\\', '/', substr($class, strlen($prefix))); + $path = $dir . $relative . '.php'; + if (is_file($path)) { + require_once $path; + } + return; + } + }); +})(); diff --git a/patches/third_party/maxmind-db/reader/CHANGELOG.md b/patches/third_party/maxmind-db/reader/CHANGELOG.md new file mode 100644 index 0000000..cd63841 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/CHANGELOG.md @@ -0,0 +1,261 @@ +CHANGELOG +========= + +1.13.1 (2025-11-21) +------------------- + +* First PIE release. No other changes. + +1.13.0 (2025-11-20) +------------------- + +* A redundant `filesize()` call in the reader's constructor was removed. + Pull request by Pavel Djundik. GitHub #189. + +1.12.1 (2025-05-05) +------------------- + +* The C extension now checks that the database metadata lookup was + successful. + +1.12.0 (2024-11-14) +------------------- + +* Improve the error handling when the user tries to open a directory + with the pure PHP reader. +* Improve the typehints on arrays in the PHPDocs. + +1.11.1 (2023-12-01) +------------------- + +* Resolve warnings when compiling the C extension. +* Fix various type issues detected by PHPStan level. Pull request by + LauraTaylorUK. GitHub #160. + +1.11.0 (2021-10-18) +------------------- + +* Replace runtime define of a constant to facilitate opcache preloading. + Reported by vedadkajtaz. GitHub #134. +* Resolve minor issue found by the Clang static analyzer in the C + extension. + +1.10.1 (2021-04-14) +------------------- + +* Fix a `TypeError` exception in the pure PHP reader when using large + databases on 32-bit PHP builds with the `bcmath` extension. Reported + by dodo1708. GitHub #124. + +1.10.0 (2021-02-09) +------------------- + +* When using the pure PHP reader, unsigned integers up to PHP_MAX_INT + will now be integers in PHP rather than strings. Previously integers + greater than 2^24 on 32-bit platforms and 2^56 on 64-bit platforms + would be strings due to the use of `gmp` or `bcmath` to decode them. + Reported by Alejandro Celaya. GitHub #119. + +1.9.0 (2021-01-07) +------------------ + +* The `maxminddb` extension is now buildable on Windows. Pull request + by Jan Ehrhardt. GitHub #115. + +1.8.0 (2020-10-01) +------------------ + +* Fixes for PHP 8.0. Pull Request by Remi Collet. GitHub #108. + +1.7.0 (2020-08-07) +------------------ + +* IMPORTANT: PHP 7.2 or greater is now required. +* The extension no longer depends on the pure PHP classes in + `maxmind-db/reader`. You can use it independently. +* Type hints have been added to both the pure PHP implementation + and the extension. +* The `metadata` method on the reader now returns a new copy of the + metadata object rather than the actual object used by the reader. +* Work around PHP `is_readable()` bug. Reported by Ben Roberts. GitHub + #92. +* This is the first release of the extension as a PECL package. + GitHub #34. + +1.6.0 (2019-12-19) +------------------ + +* 1.5.0 and 1.5.1 contained a possible memory corruptions when using + `getWithPrefixLen`. This has been fixed. Reported by proton-ab. + GitHub #96. +* The `composer.json` file now conflicts with all versions of the + `maxminddb` C extension less than the Composer version. This is to + reduce the chance of having an older, conflicting version of the + extension installed. You will need to upgrade the extension before + running `composer update`. Pull request by Benoît Burnichon. GitHub + #97. + +1.5.1 (2019-12-12) +------------------ + +* Minor performance improvements. +* Make tests pass with older versions of libmaxminddb. PR by Remi + Collet. GitHub #90. +* Test enhancements. PR by Chun-Sheng, Li. GitHub #91. + +1.5.0 (2019-09-30) +------------------ + +* PHP 5.6 or greater is now required. +* The C extension now supports PHP 8. Pull request by John Boehr. + GitHub #87. +* A new method, `getWithPrefixLen`, was added to the `Reader` class. + This method returns an array containing the record and the prefix + length for that record. GitHub #89. + +1.4.1 (2019-01-04) +------------------ + +* The `maxminddb` extension now returns a string when a `uint32` + value is greater than `LONG_MAX`. Previously, the value would + overflow. This generally only affects 32-bit machines. Reported + by Remi Collet. GitHub #79. +* For `uint64` values, the `maxminddb` extension now returns an + integer rather than a string when the value is less than or equal + to `LONG_MAX`. This more closely matches the behavior of the pure + PHP reader. + +1.4.0 (2018-11-20) +------------------ + +* The `maxminddb` extension now has the arginfo when using reflection. + PR by Remi Collet. GitHub #75. +* The `maxminddb` extension now provides `MINFO()` function that + displays the extension version and the libmaxminddb version. PR by + Remi Collet. GitHub #74. +* The `maxminddb` `configure` script now uses `pkg-config` when + available to get libmaxmindb build info. PR by Remi Collet. + GitHub #73. +* The pure PHP reader now correctly decodes integers on 32-bit platforms. + Previously, large integers would overflow. Reported by Remi Collet. + GitHub #77. +* There are small performance improvements for the pure PHP reader. + +1.3.0 (2018-02-21) +------------------ + +* IMPORTANT: The `maxminddb` extension now obeys `open_basedir`. If + `open_basedir` is set, you _must_ store the database within the + specified directory. Placing the file outside of this directory + will result in an exception. Please test your integration before + upgrading the extension. This does not affect the pure PHP + implementation, which has always had this restriction. Reported + by Benoît Burnichon. GitHub #61. +* A custom `autoload.php` file is provided for installations without + Composer. GitHub #56. + +1.2.0 (2017-10-27) +------------------ + +* PHP 5.4 or greater is now required. +* The `Reader` class for the `maxminddb` extension is no longer final. + This was change to match the behavior of the pure PHP class. + Reported and fixed by venyii. GitHub #52 & #54. + +1.1.3 (2017-01-19) +------------------ + +* Fix incorrect version in `ext/php_maxminddb.h`. GitHub #48. + +1.1.2 (2016-11-22) +------------------ + +* Searching for database metadata only occurs within the last 128KB + (128 * 1024 bytes) of the file, speeding detection of corrupt + datafiles. Reported by Eric Teubert. GitHub #42. +* Suggest relevant extensions when installing with Composer. GitHub #37. + +1.1.1 (2016-09-15) +------------------ + +* Development files were added to the `.gitattributes` as `export-ignore` so + that they are not part of the Composer release. Pull request by Michele + Locati. GitHub #39. + +1.1.0 (2016-01-04) +------------------ + +* The MaxMind DB extension now supports PHP 7. Pull request by John Boehr. + GitHub #27. + +1.0.3 (2015-03-13) +------------------ + +* All uses of `strlen` were removed. This should prevent issues in situations + where the function is overloaded or otherwise broken. + +1.0.2 (2015-01-19) +------------------ + +* Previously the MaxMind DB extension would cause a segfault if the Reader + object's destructor was called without first having called the constructor. + (Reported by Matthias Saou & Juan Peri. GitHub #20.) + +1.0.1 (2015-01-12) +------------------ + +* In the last several releases, the version number in the extension was + incorrect. This release is being done to correct it. No other code changes + are included. + +1.0.0 (2014-09-22) +------------------ + +* First production release. +* In the pure PHP reader, a string length test after `fread()` was replaced + with the difference between the start pointer and the end pointer. This + provided a 15% speed increase. + +0.3.3 (2014-09-15) +------------------ + +* Clarified behavior of 128-bit type in documentation. +* Updated phpunit and fixed some test breakage from the newer version. + +0.3.2 (2014-09-10) +------------------ + +* Fixed invalid reference to global class RuntimeException from namespaced + code. Fixed by Steven Don. GitHub issue #15. +* Additional documentation of `Metadata` class as well as misc. documentation + cleanup. + +0.3.1 (2014-05-01) +------------------ + +* The API now works when `mbstring.func_overload` is set. +* BCMath is no longer required. If the decoder encounters a big integer, + it will try to use GMP and then BCMath. If both of those fail, it will + throw an exception. No databases released by MaxMind currently use big + integers. +* The API now officially supports HHVM when using the pure PHP reader. + +0.3.0 (2014-02-19) +------------------ + +* This API is now licensed under the Apache License, Version 2.0. +* The code for the C extension was cleaned up, fixing several potential + issues. + +0.2.0 (2013-10-21) +------------------ + +* Added optional C extension for using libmaxminddb in place of the pure PHP + reader. +* Significantly improved error handling in pure PHP reader. +* Improved performance for IPv4 lookups in an IPv6 database. + +0.1.0 (2013-07-16) +------------------ + +* Initial release diff --git a/patches/third_party/maxmind-db/reader/LICENSE b/patches/third_party/maxmind-db/reader/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/patches/third_party/maxmind-db/reader/README.md b/patches/third_party/maxmind-db/reader/README.md new file mode 100644 index 0000000..9843cf5 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/README.md @@ -0,0 +1,214 @@ +# MaxMind DB Reader PHP API # + +## Description ## + +This is the PHP API for reading MaxMind DB files. MaxMind DB is a binary file +format that stores data indexed by IP address subnets (IPv4 or IPv6). + +## Installation ## + +### C Extension (Recommended for Performance) ### + +For significantly faster IP lookups, we recommend installing the C extension via +[PIE](https://github.com/php/pie): + +```bash +pie install maxmind-db/reader-ext +``` + +The C extension requires the [libmaxminddb](https://github.com/maxmind/libmaxminddb) +C library. See the [installation instructions](https://github.com/maxmind/MaxMind-DB-Reader-php-ext#prerequisites) +for your platform. + +### Pure PHP (No Compilation Required) ### + +If you prefer not to compile a C extension or need maximum portability, you can +install the pure PHP implementation with [Composer](https://getcomposer.org/). + +### Download Composer ### + +To download Composer, run in the root directory of your project: + +```bash +curl -sS https://getcomposer.org/installer | php +``` + +You should now have the file `composer.phar` in your project directory. + +### Install Dependencies ### + +Run in your project root: + +``` +php composer.phar require maxmind-db/reader:^1.13.1 +``` + +You should now have the files `composer.json` and `composer.lock` as well as +the directory `vendor` in your project directory. If you use a version control +system, `composer.json` should be added to it. + +### Require Autoloader ### + +After installing the dependencies, you need to require the Composer autoloader +from your code: + +```php +require 'vendor/autoload.php'; +``` + +## Installation (Standalone) ## + +If you don't want to use Composer for some reason, a custom +`autoload.php` is provided for you in the project root. To use the +library, simply include that file, + +```php +require('/path/to/MaxMind-DB-Reader-php/autoload.php'); +``` + +and then instantiate the reader class normally: + +```php +use MaxMind\Db\Reader; +$reader = new Reader('example.mmdb'); +``` + +## Installation (RPM) + +RPMs are available in the [official Fedora repository](https://apps.fedoraproject.org/packages/php-maxminddb). + +To install on Fedora, run: + +```bash +dnf install php-maxminddb +``` + +To install on CentOS or RHEL 7, first [enable the EPEL repository](https://fedoraproject.org/wiki/EPEL) +and then run: + +```bash +yum install php-maxminddb +``` + +Please note that these packages are *not* maintained by MaxMind. + +## Usage ## + +## Example ## + +```php +get($ipAddress)); + +// getWithPrefixLen returns an array containing the record and the +// associated prefix length for that record. +print_r($reader->getWithPrefixLen($ipAddress)); + +$reader->close(); +``` + +## Optional PHP C Extension ## + +MaxMind provides an optional C extension that is a drop-in replacement for +`MaxMind\Db\Reader`. In order to use this extension, you must install the +Reader API as described above and install the extension as described below. If +you are using an autoloader, no changes to your code should be necessary. + +### Installing Extension via PIE (Recommended) ### + +We recommend installing the extension via [PIE](https://github.com/php/pie): + +```bash +pie install maxmind-db/reader-ext +``` + +See the [extension repository](https://github.com/maxmind/MaxMind-DB-Reader-php-ext#prerequisites) +for prerequisites including libmaxminddb installation instructions. + +### Installing Extension via PECL (Legacy) ### + +First install [libmaxminddb](https://github.com/maxmind/libmaxminddb) as +described in its [README.md +file](https://github.com/maxmind/libmaxminddb/blob/main/README.md#installing-from-a-tarball). +After successfully installing libmaxmindb, you may install the extension +from [PECL](https://pecl.php.net/package/maxminddb): + +``` +pecl install maxminddb +``` + +### Installing Extension from Source ### + +Alternatively, you may install it from the source. To do so, run the following +commands from the top-level directory of this distribution: + +``` +cd ext +phpize +./configure +make +make test +sudo make install +``` + +You then must load your extension. The recommended method is to add the +following to your `php.ini` file: + +``` +extension=maxminddb.so +``` + +Note: You may need to install the PHP development package on your OS such as +php5-dev for Debian-based systems or php-devel for RedHat/Fedora-based ones. + +## 128-bit Integer Support ## + +The MaxMind DB format includes 128-bit unsigned integer as a type. Although +no MaxMind-distributed database currently makes use of this type, both the +pure PHP reader and the C extension support this type. The pure PHP reader +requires gmp or bcmath to read databases with 128-bit unsigned integers. + +The integer is currently returned as a hexadecimal string (prefixed with "0x") +by the C extension and a decimal string (no prefix) by the pure PHP reader. +Any change to make the reader implementations always return either a +hexadecimal or decimal representation of the integer will NOT be considered a +breaking change. + +## Support ## + +Please report all issues with this code using the [GitHub issue tracker](https://github.com/maxmind/MaxMind-DB-Reader-php/issues). + +If you are having an issue with a MaxMind service that is not specific to the +client API, please see [our support page](https://www.maxmind.com/en/support). + +## Requirements ## + +This library requires PHP 7.2 or greater. + +The GMP or BCMath extension may be required to read some databases +using the pure PHP API. + +## Contributing ## + +Patches and pull requests are encouraged. All code should follow the PSR-1 and +PSR-2 style guidelines. Please include unit tests whenever possible. + +## Versioning ## + +The MaxMind DB Reader PHP API uses [Semantic Versioning](https://semver.org/). + +## Copyright and License ## + +This software is Copyright (c) 2014-2025 by MaxMind, Inc. + +This is free software, licensed under the Apache License, Version 2.0. diff --git a/patches/third_party/maxmind-db/reader/autoload.php b/patches/third_party/maxmind-db/reader/autoload.php new file mode 100644 index 0000000..fdd2f1c --- /dev/null +++ b/patches/third_party/maxmind-db/reader/autoload.php @@ -0,0 +1,47 @@ +class. + * + * @param string $class + * the name of the class to load + */ +function mmdb_autoload($class): void +{ + /* + * A project-specific mapping between the namespaces and where + * they're located. By convention, we include the trailing + * slashes. The one-element array here simply makes things easy + * to extend in the future if (for example) the test classes + * begin to use one another. + */ + $namespace_map = ['MaxMind\Db\\' => __DIR__ . '/src/MaxMind/Db/']; + + foreach ($namespace_map as $prefix => $dir) { + // First swap out the namespace prefix with a directory... + $path = str_replace($prefix, $dir, $class); + + // replace the namespace separator with a directory separator... + $path = str_replace('\\', '/', $path); + + // and finally, add the PHP file extension to the result. + $path .= '.php'; + + // $path should now contain the path to a PHP file defining $class + if (file_exists($path)) { + include $path; + } + } +} + +spl_autoload_register('mmdb_autoload'); diff --git a/patches/third_party/maxmind-db/reader/composer.json b/patches/third_party/maxmind-db/reader/composer.json new file mode 100644 index 0000000..f33af98 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/composer.json @@ -0,0 +1,43 @@ +{ + "name": "maxmind-db/reader", + "description": "MaxMind DB Reader API", + "keywords": ["database", "geoip", "geoip2", "geolocation", "maxmind"], + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "type": "library", + "license": "Apache-2.0", + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups", + "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "4.*", + "phpstan/phpstan": "*" + }, + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" + } + }, + "autoload-dev": { + "psr-4": { + "MaxMind\\Db\\Test\\Reader\\": "tests/MaxMind/Db/Test/Reader" + } + } +} diff --git a/patches/third_party/maxmind-db/reader/ext/config.m4 b/patches/third_party/maxmind-db/reader/ext/config.m4 new file mode 100644 index 0000000..c09151e --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/config.m4 @@ -0,0 +1,40 @@ +PHP_ARG_WITH(maxminddb, + [Whether to enable the MaxMind DB Reader extension], + [ --with-maxminddb Enable MaxMind DB Reader extension support]) + +PHP_ARG_ENABLE(maxminddb-debug, for MaxMind DB debug support, + [ --enable-maxminddb-debug Enable MaxMind DB debug support], no, no) + +if test $PHP_MAXMINDDB != "no"; then + + AC_PATH_PROG(PKG_CONFIG, pkg-config, no) + + AC_MSG_CHECKING(for libmaxminddb) + if test -x "$PKG_CONFIG" && $PKG_CONFIG --exists libmaxminddb; then + dnl retrieve build options from pkg-config + if $PKG_CONFIG libmaxminddb --atleast-version 1.0.0; then + LIBMAXMINDDB_INC=`$PKG_CONFIG libmaxminddb --cflags` + LIBMAXMINDDB_LIB=`$PKG_CONFIG libmaxminddb --libs` + LIBMAXMINDDB_VER=`$PKG_CONFIG libmaxminddb --modversion` + AC_MSG_RESULT(found version $LIBMAXMINDDB_VER) + else + AC_MSG_ERROR(system libmaxminddb must be upgraded to version >= 1.0.0) + fi + PHP_EVAL_LIBLINE($LIBMAXMINDDB_LIB, MAXMINDDB_SHARED_LIBADD) + PHP_EVAL_INCLINE($LIBMAXMINDDB_INC) + else + AC_MSG_RESULT(pkg-config information missing) + AC_MSG_WARN(will use libmaxmxinddb from compiler default path) + + PHP_CHECK_LIBRARY(maxminddb, MMDB_open) + PHP_ADD_LIBRARY(maxminddb, 1, MAXMINDDB_SHARED_LIBADD) + fi + + if test $PHP_MAXMINDDB_DEBUG != "no"; then + CFLAGS="$CFLAGS -Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Werror" + fi + + PHP_SUBST(MAXMINDDB_SHARED_LIBADD) + + PHP_NEW_EXTENSION(maxminddb, maxminddb.c, $ext_shared) +fi diff --git a/patches/third_party/maxmind-db/reader/ext/config.w32 b/patches/third_party/maxmind-db/reader/ext/config.w32 new file mode 100644 index 0000000..4eb18f8 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/config.w32 @@ -0,0 +1,10 @@ +ARG_WITH("maxminddb", "Enable MaxMind DB Reader extension support", "no"); + +if (PHP_MAXMINDDB == "yes") { + if (CHECK_HEADER_ADD_INCLUDE("maxminddb.h", "CFLAGS_MAXMINDDB", PHP_MAXMINDDB + ";" + PHP_PHP_BUILD + "\\include\\maxminddb") && + CHECK_LIB("libmaxminddb.lib", "maxminddb", PHP_MAXMINDDB)) { + EXTENSION("maxminddb", "maxminddb.c"); + } else { + WARNING('Could not find maxminddb.h or libmaxminddb.lib; skipping'); + } +} diff --git a/patches/third_party/maxmind-db/reader/ext/maxminddb.c b/patches/third_party/maxmind-db/reader/ext/maxminddb.c new file mode 100644 index 0000000..b4e078b --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/maxminddb.c @@ -0,0 +1,819 @@ +/* MaxMind, Inc., licenses this file to you 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. + */ + +#include "php_maxminddb.h" + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include "Zend/zend_exceptions.h" +#include "Zend/zend_types.h" +#include "ext/spl/spl_exceptions.h" +#include "ext/standard/info.h" +#include + +#ifdef ZTS +#include +#endif + +#define __STDC_FORMAT_MACROS +#include + +#define PHP_MAXMINDDB_NS ZEND_NS_NAME("MaxMind", "Db") +#define PHP_MAXMINDDB_READER_NS ZEND_NS_NAME(PHP_MAXMINDDB_NS, "Reader") +#define PHP_MAXMINDDB_METADATA_NS \ + ZEND_NS_NAME(PHP_MAXMINDDB_READER_NS, "Metadata") +#define PHP_MAXMINDDB_READER_EX_NS \ + ZEND_NS_NAME(PHP_MAXMINDDB_READER_NS, "InvalidDatabaseException") + +#define Z_MAXMINDDB_P(zv) php_maxminddb_fetch_object(Z_OBJ_P(zv)) +typedef size_t strsize_t; +typedef zend_object free_obj_t; + +/* For PHP 8 compatibility */ +#if PHP_VERSION_ID < 80000 + +#define PROP_OBJ(zv) (zv) + +#else + +#define PROP_OBJ(zv) Z_OBJ_P(zv) + +#define TSRMLS_C +#define TSRMLS_CC +#define TSRMLS_DC + +/* End PHP 8 compatibility */ +#endif + +#ifndef ZEND_ACC_CTOR +#define ZEND_ACC_CTOR 0 +#endif + +/* IS_MIXED was added in 2020 */ +#ifndef IS_MIXED +#define IS_MIXED IS_UNDEF +#endif + +/* ZEND_THIS was added in 7.4 */ +#ifndef ZEND_THIS +#define ZEND_THIS (&EX(This)) +#endif + +typedef struct _maxminddb_obj { + MMDB_s *mmdb; + zend_object std; +} maxminddb_obj; + +PHP_FUNCTION(maxminddb); + +static int +get_record(INTERNAL_FUNCTION_PARAMETERS, zval *record, int *prefix_len); +static const MMDB_entry_data_list_s * +handle_entry_data_list(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static const MMDB_entry_data_list_s * +handle_array(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static const MMDB_entry_data_list_s * +handle_map(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint128(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint64(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint32(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); + +#define CHECK_ALLOCATED(val) \ + if (!val) { \ + zend_error(E_ERROR, "Out of memory"); \ + return; \ + } + +static zend_object_handlers maxminddb_obj_handlers; +static zend_class_entry *maxminddb_ce, *maxminddb_exception_ce, *metadata_ce; + +static inline maxminddb_obj * +php_maxminddb_fetch_object(zend_object *obj TSRMLS_DC) { + return (maxminddb_obj *)((char *)(obj)-XtOffsetOf(maxminddb_obj, std)); +} + +ZEND_BEGIN_ARG_INFO_EX(arginfo_maxminddbreader_construct, 0, 0, 1) +ZEND_ARG_TYPE_INFO(0, db_file, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, __construct) { + char *db_file = NULL; + strsize_t name_len; + zval *_this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Os", + &_this_zval, + maxminddb_ce, + &db_file, + &name_len) == FAILURE) { + return; + } + + if (0 != php_check_open_basedir(db_file TSRMLS_CC) || + 0 != access(db_file, R_OK)) { + zend_throw_exception_ex( + spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "The file \"%s\" does not exist or is not readable.", + db_file); + return; + } + + MMDB_s *mmdb = (MMDB_s *)ecalloc(1, sizeof(MMDB_s)); + int const status = MMDB_open(db_file, MMDB_MODE_MMAP, mmdb); + + if (MMDB_SUCCESS != status) { + zend_throw_exception_ex( + maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error opening database file (%s). Is this a valid " + "MaxMind DB file?", + db_file); + efree(mmdb); + return; + } + + maxminddb_obj *mmdb_obj = Z_MAXMINDDB_P(ZEND_THIS); + mmdb_obj->mmdb = mmdb; +} + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX( + arginfo_maxminddbreader_get, 0, 1, IS_MIXED, 1) +ZEND_ARG_TYPE_INFO(0, ip_address, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, get) { + int prefix_len = 0; + get_record(INTERNAL_FUNCTION_PARAM_PASSTHRU, return_value, &prefix_len); +} + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX( + arginfo_maxminddbreader_getWithPrefixLen, 0, 1, IS_ARRAY, 1) +ZEND_ARG_TYPE_INFO(0, ip_address, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, getWithPrefixLen) { + zval record, z_prefix_len; + + int prefix_len = 0; + if (get_record(INTERNAL_FUNCTION_PARAM_PASSTHRU, &record, &prefix_len) == + FAILURE) { + return; + } + + array_init(return_value); + add_next_index_zval(return_value, &record); + + ZVAL_LONG(&z_prefix_len, prefix_len); + add_next_index_zval(return_value, &z_prefix_len); +} + +static int +get_record(INTERNAL_FUNCTION_PARAMETERS, zval *record, int *prefix_len) { + char *ip_address = NULL; + strsize_t name_len; + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Os", + &this_zval, + maxminddb_ce, + &ip_address, + &name_len) == FAILURE) { + return FAILURE; + } + + const maxminddb_obj *mmdb_obj = (maxminddb_obj *)Z_MAXMINDDB_P(ZEND_THIS); + + MMDB_s *mmdb = mmdb_obj->mmdb; + + if (NULL == mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to read from a closed MaxMind DB."); + return FAILURE; + } + + struct addrinfo hints = { + .ai_family = AF_UNSPEC, + .ai_flags = AI_NUMERICHOST, + /* We set ai_socktype so that we only get one result back */ + .ai_socktype = SOCK_STREAM}; + + struct addrinfo *addresses = NULL; + int gai_status = getaddrinfo(ip_address, NULL, &hints, &addresses); + if (gai_status) { + zend_throw_exception_ex(spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "The value \"%s\" is not a valid IP address.", + ip_address); + return FAILURE; + } + if (!addresses || !addresses->ai_addr) { + zend_throw_exception_ex( + spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "getaddrinfo was successful but failed to set the addrinfo"); + return FAILURE; + } + + int sa_family = addresses->ai_addr->sa_family; + + int mmdb_error = MMDB_SUCCESS; + MMDB_lookup_result_s result = + MMDB_lookup_sockaddr(mmdb, addresses->ai_addr, &mmdb_error); + + freeaddrinfo(addresses); + + if (MMDB_SUCCESS != mmdb_error) { + zend_class_entry *ex; + if (MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR == mmdb_error) { + ex = spl_ce_InvalidArgumentException; + } else { + ex = maxminddb_exception_ce; + } + zend_throw_exception_ex(ex, + 0 TSRMLS_CC, + "Error looking up %s. %s", + ip_address, + MMDB_strerror(mmdb_error)); + return FAILURE; + } + + *prefix_len = result.netmask; + + if (sa_family == AF_INET && mmdb->metadata.ip_version == 6) { + /* We return the prefix length given the IPv4 address. If there is + no IPv4 subtree, we return a prefix length of 0. */ + *prefix_len = *prefix_len >= 96 ? *prefix_len - 96 : 0; + } + + if (!result.found_entry) { + ZVAL_NULL(record); + return SUCCESS; + } + + MMDB_entry_data_list_s *entry_data_list = NULL; + int status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); + + if (MMDB_SUCCESS != status) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while looking up data for %s. %s", + ip_address, + MMDB_strerror(status)); + MMDB_free_entry_data_list(entry_data_list); + return FAILURE; + } else if (NULL == entry_data_list) { + zend_throw_exception_ex( + maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while looking up data for %s. Your database may " + "be corrupt or you have found a bug in libmaxminddb.", + ip_address); + return FAILURE; + } + + const MMDB_entry_data_list_s *rv = + handle_entry_data_list(entry_data_list, record TSRMLS_CC); + if (rv == NULL) { + /* We should have already thrown the exception in handle_entry_data_list + */ + return FAILURE; + } + MMDB_free_entry_data_list(entry_data_list); + return SUCCESS; +} + +ZEND_BEGIN_ARG_INFO_EX(arginfo_maxminddbreader_void, 0, 0, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, metadata) { + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "O", + &this_zval, + maxminddb_ce) == FAILURE) { + return; + } + + const maxminddb_obj *const mmdb_obj = + (maxminddb_obj *)Z_MAXMINDDB_P(this_zval); + + if (NULL == mmdb_obj->mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to read from a closed MaxMind DB."); + return; + } + + object_init_ex(return_value, metadata_ce); + + MMDB_entry_data_list_s *entry_data_list; + int status = + MMDB_get_metadata_as_entry_data_list(mmdb_obj->mmdb, &entry_data_list); + if (status != MMDB_SUCCESS) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while decoding metadata. %s", + MMDB_strerror(status)); + return; + } + + zval metadata_array; + const MMDB_entry_data_list_s *rv = + handle_entry_data_list(entry_data_list, &metadata_array TSRMLS_CC); + if (rv == NULL) { + return; + } + MMDB_free_entry_data_list(entry_data_list); + zend_call_method_with_1_params(PROP_OBJ(return_value), + metadata_ce, + &metadata_ce->constructor, + ZEND_CONSTRUCTOR_FUNC_NAME, + NULL, + &metadata_array); + zval_ptr_dtor(&metadata_array); +} + +PHP_METHOD(MaxMind_Db_Reader, close) { + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "O", + &this_zval, + maxminddb_ce) == FAILURE) { + return; + } + + maxminddb_obj *mmdb_obj = (maxminddb_obj *)Z_MAXMINDDB_P(this_zval); + + if (NULL == mmdb_obj->mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to close a closed MaxMind DB."); + return; + } + MMDB_close(mmdb_obj->mmdb); + efree(mmdb_obj->mmdb); + mmdb_obj->mmdb = NULL; +} + +static const MMDB_entry_data_list_s * +handle_entry_data_list(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + switch (entry_data_list->entry_data.type) { + case MMDB_DATA_TYPE_MAP: + return handle_map(entry_data_list, z_value TSRMLS_CC); + case MMDB_DATA_TYPE_ARRAY: + return handle_array(entry_data_list, z_value TSRMLS_CC); + case MMDB_DATA_TYPE_UTF8_STRING: + ZVAL_STRINGL(z_value, + entry_data_list->entry_data.utf8_string, + entry_data_list->entry_data.data_size); + break; + case MMDB_DATA_TYPE_BYTES: + ZVAL_STRINGL(z_value, + (char const *)entry_data_list->entry_data.bytes, + entry_data_list->entry_data.data_size); + break; + case MMDB_DATA_TYPE_DOUBLE: + ZVAL_DOUBLE(z_value, entry_data_list->entry_data.double_value); + break; + case MMDB_DATA_TYPE_FLOAT: + ZVAL_DOUBLE(z_value, entry_data_list->entry_data.float_value); + break; + case MMDB_DATA_TYPE_UINT16: + ZVAL_LONG(z_value, entry_data_list->entry_data.uint16); + break; + case MMDB_DATA_TYPE_UINT32: + handle_uint32(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_BOOLEAN: + ZVAL_BOOL(z_value, entry_data_list->entry_data.boolean); + break; + case MMDB_DATA_TYPE_UINT64: + handle_uint64(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_UINT128: + handle_uint128(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_INT32: + ZVAL_LONG(z_value, entry_data_list->entry_data.int32); + break; + default: + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Invalid data type arguments: %d", + entry_data_list->entry_data.type); + return NULL; + } + return entry_data_list; +} + +static const MMDB_entry_data_list_s * +handle_map(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + array_init(z_value); + const uint32_t map_size = entry_data_list->entry_data.data_size; + + uint32_t i; + for (i = 0; i < map_size && entry_data_list; i++) { + entry_data_list = entry_data_list->next; + + char *key = estrndup(entry_data_list->entry_data.utf8_string, + entry_data_list->entry_data.data_size); + if (NULL == key) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Invalid data type arguments"); + return NULL; + } + + entry_data_list = entry_data_list->next; + zval new_value; + entry_data_list = + handle_entry_data_list(entry_data_list, &new_value TSRMLS_CC); + if (entry_data_list != NULL) { + add_assoc_zval(z_value, key, &new_value); + } + efree(key); + } + return entry_data_list; +} + +static const MMDB_entry_data_list_s * +handle_array(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + const uint32_t size = entry_data_list->entry_data.data_size; + + array_init(z_value); + + uint32_t i; + for (i = 0; i < size && entry_data_list; i++) { + entry_data_list = entry_data_list->next; + zval new_value; + entry_data_list = + handle_entry_data_list(entry_data_list, &new_value TSRMLS_CC); + if (entry_data_list != NULL) { + add_next_index_zval(z_value, &new_value); + } + } + return entry_data_list; +} + +static void handle_uint128(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint64_t high = 0; + uint64_t low = 0; +#if MMDB_UINT128_IS_BYTE_ARRAY + int i; + for (i = 0; i < 8; i++) { + high = (high << 8) | entry_data_list->entry_data.uint128[i]; + } + + for (i = 8; i < 16; i++) { + low = (low << 8) | entry_data_list->entry_data.uint128[i]; + } +#else + high = entry_data_list->entry_data.uint128 >> 64; + low = (uint64_t)entry_data_list->entry_data.uint128; +#endif + + char *num_str; + spprintf(&num_str, 0, "0x%016" PRIX64 "%016" PRIX64, high, low); + CHECK_ALLOCATED(num_str); + + ZVAL_STRING(z_value, num_str); + efree(num_str); +} + +static void handle_uint32(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint32_t val = entry_data_list->entry_data.uint32; + +#if LONG_MAX >= UINT32_MAX + ZVAL_LONG(z_value, val); + return; +#else + if (val <= LONG_MAX) { + ZVAL_LONG(z_value, val); + return; + } + + char *int_str; + spprintf(&int_str, 0, "%" PRIu32, val); + CHECK_ALLOCATED(int_str); + + ZVAL_STRING(z_value, int_str); + efree(int_str); +#endif +} + +static void handle_uint64(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint64_t val = entry_data_list->entry_data.uint64; + +#if LONG_MAX >= UINT64_MAX + ZVAL_LONG(z_value, val); + return; +#else + if (val <= LONG_MAX) { + ZVAL_LONG(z_value, val); + return; + } + + char *int_str; + spprintf(&int_str, 0, "%" PRIu64, val); + CHECK_ALLOCATED(int_str); + + ZVAL_STRING(z_value, int_str); + efree(int_str); +#endif +} + +static void maxminddb_free_storage(free_obj_t *object TSRMLS_DC) { + maxminddb_obj *obj = + php_maxminddb_fetch_object((zend_object *)object TSRMLS_CC); + if (obj->mmdb != NULL) { + MMDB_close(obj->mmdb); + efree(obj->mmdb); + } + + zend_object_std_dtor(&obj->std TSRMLS_CC); +} + +static zend_object *maxminddb_create_handler(zend_class_entry *type TSRMLS_DC) { + maxminddb_obj *obj = (maxminddb_obj *)ecalloc(1, sizeof(maxminddb_obj)); + zend_object_std_init(&obj->std, type TSRMLS_CC); + object_properties_init(&(obj->std), type); + + obj->std.handlers = &maxminddb_obj_handlers; + + return &obj->std; +} + +/* clang-format off */ +static zend_function_entry maxminddb_methods[] = { + PHP_ME(MaxMind_Db_Reader, __construct, arginfo_maxminddbreader_construct, + ZEND_ACC_PUBLIC | ZEND_ACC_CTOR) + PHP_ME(MaxMind_Db_Reader, close, arginfo_maxminddbreader_void, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, get, arginfo_maxminddbreader_get, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, getWithPrefixLen, arginfo_maxminddbreader_getWithPrefixLen, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, metadata, arginfo_maxminddbreader_void, ZEND_ACC_PUBLIC) + { NULL, NULL, NULL } +}; +/* clang-format on */ + +ZEND_BEGIN_ARG_INFO_EX(arginfo_metadata_construct, 0, 0, 1) +ZEND_ARG_TYPE_INFO(0, metadata, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader_Metadata, __construct) { + zval *object = NULL; + zval *metadata_array = NULL; + zend_long node_count = 0; + zend_long record_size = 0; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Oa", + &object, + metadata_ce, + &metadata_array) == FAILURE) { + return; + } + + zval *tmp = NULL; + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "binary_format_major_version", + sizeof("binary_format_major_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "binaryFormatMajorVersion", + sizeof("binaryFormatMajorVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "binary_format_minor_version", + sizeof("binary_format_minor_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "binaryFormatMinorVersion", + sizeof("binaryFormatMinorVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "build_epoch", + sizeof("build_epoch") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "buildEpoch", + sizeof("buildEpoch") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "database_type", + sizeof("database_type") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "databaseType", + sizeof("databaseType") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "description", + sizeof("description") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "description", + sizeof("description") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "ip_version", + sizeof("ip_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "ipVersion", + sizeof("ipVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find( + HASH_OF(metadata_array), "languages", sizeof("languages") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "languages", + sizeof("languages") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "record_size", + sizeof("record_size") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "recordSize", + sizeof("recordSize") - 1, + tmp); + if (Z_TYPE_P(tmp) == IS_LONG) { + record_size = Z_LVAL_P(tmp); + } + } + + if (record_size != 0) { + zend_update_property_long(metadata_ce, + PROP_OBJ(object), + "nodeByteSize", + sizeof("nodeByteSize") - 1, + record_size / 4); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "node_count", + sizeof("node_count") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "nodeCount", + sizeof("nodeCount") - 1, + tmp); + if (Z_TYPE_P(tmp) == IS_LONG) { + node_count = Z_LVAL_P(tmp); + } + } + + if (record_size != 0) { + zend_update_property_long(metadata_ce, + PROP_OBJ(object), + "searchTreeSize", + sizeof("searchTreeSize") - 1, + record_size * node_count / 4); + } +} + +// clang-format off +static zend_function_entry metadata_methods[] = { + PHP_ME(MaxMind_Db_Reader_Metadata, __construct, arginfo_metadata_construct, ZEND_ACC_PUBLIC | ZEND_ACC_CTOR) + {NULL, NULL, NULL} +}; +// clang-format on + +PHP_MINIT_FUNCTION(maxminddb) { + zend_class_entry ce; + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_READER_EX_NS, NULL); + maxminddb_exception_ce = + zend_register_internal_class_ex(&ce, zend_ce_exception); + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_READER_NS, maxminddb_methods); + maxminddb_ce = zend_register_internal_class(&ce TSRMLS_CC); + maxminddb_ce->create_object = maxminddb_create_handler; + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_METADATA_NS, metadata_methods); + metadata_ce = zend_register_internal_class(&ce TSRMLS_CC); + zend_declare_property_null(metadata_ce, + "binaryFormatMajorVersion", + sizeof("binaryFormatMajorVersion") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "binaryFormatMinorVersion", + sizeof("binaryFormatMinorVersion") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "buildEpoch", sizeof("buildEpoch") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "databaseType", + sizeof("databaseType") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "description", sizeof("description") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "ipVersion", sizeof("ipVersion") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "languages", sizeof("languages") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "nodeByteSize", + sizeof("nodeByteSize") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "nodeCount", sizeof("nodeCount") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "recordSize", sizeof("recordSize") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "searchTreeSize", + sizeof("searchTreeSize") - 1, + ZEND_ACC_PUBLIC); + + memcpy(&maxminddb_obj_handlers, + zend_get_std_object_handlers(), + sizeof(zend_object_handlers)); + maxminddb_obj_handlers.clone_obj = NULL; + maxminddb_obj_handlers.offset = XtOffsetOf(maxminddb_obj, std); + maxminddb_obj_handlers.free_obj = maxminddb_free_storage; + zend_declare_class_constant_string(maxminddb_ce, + "MMDB_LIB_VERSION", + sizeof("MMDB_LIB_VERSION") - 1, + MMDB_lib_version() TSRMLS_CC); + + return SUCCESS; +} + +static PHP_MINFO_FUNCTION(maxminddb) { + php_info_print_table_start(); + + php_info_print_table_row(2, "MaxMind DB Reader", "enabled"); + php_info_print_table_row( + 2, "maxminddb extension version", PHP_MAXMINDDB_VERSION); + php_info_print_table_row( + 2, "libmaxminddb library version", MMDB_lib_version()); + + php_info_print_table_end(); +} + +zend_module_entry maxminddb_module_entry = {STANDARD_MODULE_HEADER, + PHP_MAXMINDDB_EXTNAME, + NULL, + PHP_MINIT(maxminddb), + NULL, + NULL, + NULL, + PHP_MINFO(maxminddb), + PHP_MAXMINDDB_VERSION, + STANDARD_MODULE_PROPERTIES}; + +#ifdef COMPILE_DL_MAXMINDDB +ZEND_GET_MODULE(maxminddb) +#endif diff --git a/patches/third_party/maxmind-db/reader/ext/php_maxminddb.h b/patches/third_party/maxmind-db/reader/ext/php_maxminddb.h new file mode 100644 index 0000000..30e2461 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/php_maxminddb.h @@ -0,0 +1,24 @@ +/* MaxMind, Inc., licenses this file to you 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. + */ + +#include + +#ifndef PHP_MAXMINDDB_H +#define PHP_MAXMINDDB_H 1 +#define PHP_MAXMINDDB_VERSION "1.13.1" +#define PHP_MAXMINDDB_EXTNAME "maxminddb" + +extern zend_module_entry maxminddb_module_entry; +#define phpext_maxminddb_ptr &maxminddb_module_entry + +#endif diff --git a/patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt b/patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt new file mode 100644 index 0000000..09810ee --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt @@ -0,0 +1,12 @@ +--TEST-- +Check for maxminddb presence +--SKIPIF-- + +--FILE-- + +--EXPECT-- +maxminddb extension is available diff --git a/patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt b/patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt new file mode 100644 index 0000000..d91b7d0 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt @@ -0,0 +1,13 @@ +--TEST-- +Check that Reader class is not final +--SKIPIF-- + +--FILE-- +isFinal()); +?> +--EXPECT-- +bool(false) diff --git a/patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt b/patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt new file mode 100644 index 0000000..26e9781 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt @@ -0,0 +1,12 @@ +--TEST-- +openbase_dir is followed +--INI-- +open_basedir=/--dne-- +--FILE-- + +--EXPECTREGEX-- +.*open_basedir restriction in effect.* diff --git a/patches/third_party/maxmind-db/reader/package.xml b/patches/third_party/maxmind-db/reader/package.xml new file mode 100644 index 0000000..15db600 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/package.xml @@ -0,0 +1,61 @@ + + + + maxminddb + pecl.php.net + Reader for the MaxMind DB file format + This is the PHP extension for reading MaxMind DB files. MaxMind DB is a binary file format that stores data indexed by IP address subnets (IPv4 or IPv6). + + Greg Oschwald + oschwald + goschwald@maxmind.com + yes + + 2025-11-21 + + 1.13.1 + 1.13.1 + + + stable + stable + + Apache License 2.0 + * First PIE release. No other changes. + + + + + + + + + + + + + + + + + + + + + + + + + 7.2.0 + + + 1.10.0 + + + + maxminddb + + diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php new file mode 100644 index 0000000..a0b28b4 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php @@ -0,0 +1,404 @@ + + */ + private static $METADATA_START_MARKER_LENGTH = 14; + + /** + * @var int + */ + private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB + + /** + * @var Decoder + */ + private $decoder; + + /** + * @var resource + */ + private $fileHandle; + + /** + * @var int + */ + private $fileSize; + + /** + * @var int + */ + private $ipV4Start; + + /** + * @var Metadata + */ + private $metadata; + + /** + * Constructs a Reader for the MaxMind DB format. The file passed to it must + * be a valid MaxMind DB file such as a GeoIp2 database file. + * + * @param string $database the MaxMind DB file to use + * + * @throws \InvalidArgumentException for invalid database path or unknown arguments + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + */ + public function __construct(string $database) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + if (is_dir($database)) { + // This matches the error that the C extension throws. + throw new InvalidDatabaseException( + "Error opening database file ($database). Is this a valid MaxMind DB file?" + ); + } + + $fileHandle = @fopen($database, 'rb'); + if ($fileHandle === false) { + throw new \InvalidArgumentException( + "The file \"$database\" does not exist or is not readable." + ); + } + $this->fileHandle = $fileHandle; + + $fstat = fstat($fileHandle); + if ($fstat === false) { + throw new \UnexpectedValueException( + "Error determining the size of \"$database\"." + ); + } + $this->fileSize = $fstat['size']; + + $start = $this->findMetadataStart($database); + $metadataDecoder = new Decoder($this->fileHandle, $start); + [$metadataArray] = $metadataDecoder->decode($start); + $this->metadata = new Metadata($metadataArray); + $this->decoder = new Decoder( + $this->fileHandle, + $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE + ); + $this->ipV4Start = $this->ipV4StartNode(); + } + + /** + * Retrieves the record for the IP address. + * + * @param string $ipAddress the IP address to look up + * + * @throws \BadMethodCallException if this method is called on a closed database + * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + * + * @return mixed the record for the IP address + */ + public function get(string $ipAddress) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + [$record] = $this->getWithPrefixLen($ipAddress); + + return $record; + } + + /** + * Retrieves the record for the IP address and its associated network prefix length. + * + * @param string $ipAddress the IP address to look up + * + * @throws \BadMethodCallException if this method is called on a closed database + * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + * + * @return array{0:mixed, 1:int} an array where the first element is the record and the + * second the network prefix length for the record + */ + public function getWithPrefixLen(string $ipAddress): array + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + [$pointer, $prefixLen] = $this->findAddressInTree($ipAddress); + if ($pointer === 0) { + return [null, $prefixLen]; + } + + return [$this->resolveDataPointer($pointer), $prefixLen]; + } + + /** + * @return array{0:int, 1:int} + */ + private function findAddressInTree(string $ipAddress): array + { + $packedAddr = @inet_pton($ipAddress); + if ($packedAddr === false) { + throw new \InvalidArgumentException( + "The value \"$ipAddress\" is not a valid IP address." + ); + } + + $rawAddress = unpack('C*', $packedAddr); + if ($rawAddress === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned char of the packed in_addr representation.' + ); + } + + $bitCount = \count($rawAddress) * 8; + + // The first node of the tree is always node 0, at the beginning of the + // value + $node = 0; + + $metadata = $this->metadata; + + // Check if we are looking up an IPv4 address in an IPv6 tree. If this + // is the case, we can skip over the first 96 nodes. + if ($metadata->ipVersion === 6) { + if ($bitCount === 32) { + $node = $this->ipV4Start; + } + } elseif ($metadata->ipVersion === 4 && $bitCount === 128) { + throw new \InvalidArgumentException( + "Error looking up $ipAddress. You attempted to look up an" + . ' IPv6 address in an IPv4-only database.' + ); + } + + $nodeCount = $metadata->nodeCount; + + for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) { + $tempBit = 0xFF & $rawAddress[($i >> 3) + 1]; + $bit = 1 & ($tempBit >> 7 - ($i % 8)); + + $node = $this->readNode($node, $bit); + } + if ($node === $nodeCount) { + // Record is empty + return [0, $i]; + } + if ($node > $nodeCount) { + // Record is a data pointer + return [$node, $i]; + } + + throw new InvalidDatabaseException( + 'Invalid or corrupt database. Maximum search depth reached without finding a leaf node' + ); + } + + private function ipV4StartNode(): int + { + // If we have an IPv4 database, the start node is the first node + if ($this->metadata->ipVersion === 4) { + return 0; + } + + $node = 0; + + for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) { + $node = $this->readNode($node, 0); + } + + return $node; + } + + private function readNode(int $nodeNumber, int $index): int + { + $baseOffset = $nodeNumber * $this->metadata->nodeByteSize; + + switch ($this->metadata->recordSize) { + case 24: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3); + $rc = unpack('N', "\x00" . $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + case 28: + $bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4); + if ($index === 0) { + $middle = (0xF0 & \ord($bytes[3])) >> 4; + } else { + $middle = 0x0F & \ord($bytes[0]); + } + $rc = unpack('N', \chr($middle) . substr($bytes, $index, 3)); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + case 32: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4); + $rc = unpack('N', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + default: + throw new InvalidDatabaseException( + 'Unknown record size: ' + . $this->metadata->recordSize + ); + } + } + + /** + * @return mixed + */ + private function resolveDataPointer(int $pointer) + { + $resolved = $pointer - $this->metadata->nodeCount + + $this->metadata->searchTreeSize; + if ($resolved >= $this->fileSize) { + throw new InvalidDatabaseException( + "The MaxMind DB file's search tree is corrupt" + ); + } + + [$data] = $this->decoder->decode($resolved); + + return $data; + } + + /* + * This is an extremely naive but reasonably readable implementation. There + * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever + * an issue, but I suspect it won't be. + */ + private function findMetadataStart(string $filename): int + { + $handle = $this->fileHandle; + $fileSize = $this->fileSize; + $marker = self::$METADATA_START_MARKER; + $markerLength = self::$METADATA_START_MARKER_LENGTH; + + $minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize); + + for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) { + if (fseek($handle, $offset) !== 0) { + break; + } + + $value = fread($handle, $markerLength); + if ($value === $marker) { + return $offset + $markerLength; + } + } + + throw new InvalidDatabaseException( + "Error opening database file ($filename). " + . 'Is this a valid MaxMind DB file?' + ); + } + + /** + * @throws \InvalidArgumentException if arguments are passed to the method + * @throws \BadMethodCallException if the database has been closed + * + * @return Metadata object for the database + */ + public function metadata(): Metadata + { + if (\func_num_args()) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) + ); + } + + // Not technically required, but this makes it consistent with + // C extension and it allows us to change our implementation later. + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + return clone $this->metadata; + } + + /** + * Closes the MaxMind DB and returns resources to the system. + * + * @throws \Exception + * if an I/O error occurs + */ + public function close(): void + { + if (\func_num_args()) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) + ); + } + + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to close a closed MaxMind DB.' + ); + } + fclose($this->fileHandle); + } +} diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php new file mode 100644 index 0000000..1bb6731 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php @@ -0,0 +1,452 @@ +fileStream = $fileStream; + $this->pointerBase = $pointerBase; + + $this->pointerTestHack = $pointerTestHack; + + $this->switchByteOrder = $this->isPlatformLittleEndian(); + } + + /** + * @return array + */ + public function decode(int $offset): array + { + $ctrlByte = \ord(Util::read($this->fileStream, $offset, 1)); + ++$offset; + + $type = $ctrlByte >> 5; + + // Pointers are a special case, we don't read the next $size bytes, we + // use the size to determine the length of the pointer and then follow + // it. + if ($type === self::_POINTER) { + [$pointer, $offset] = $this->decodePointer($ctrlByte, $offset); + + // for unit testing + if ($this->pointerTestHack) { + return [$pointer]; + } + + [$result] = $this->decode($pointer); + + return [$result, $offset]; + } + + if ($type === self::_EXTENDED) { + $nextByte = \ord(Util::read($this->fileStream, $offset, 1)); + + $type = $nextByte + 7; + + if ($type < 8) { + throw new InvalidDatabaseException( + 'Something went horribly wrong in the decoder. An extended type ' + . 'resolved to a type number < 8 (' + . $type + . ')' + ); + } + + ++$offset; + } + + [$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset); + + return $this->decodeByType($type, $offset, $size); + } + + /** + * @param int<0, max> $size + * + * @return array{0:mixed, 1:int} + */ + private function decodeByType(int $type, int $offset, int $size): array + { + switch ($type) { + case self::_MAP: + return $this->decodeMap($size, $offset); + + case self::_ARRAY: + return $this->decodeArray($size, $offset); + + case self::_BOOLEAN: + return [$this->decodeBoolean($size), $offset]; + } + + $newOffset = $offset + $size; + $bytes = Util::read($this->fileStream, $offset, $size); + + switch ($type) { + case self::_BYTES: + case self::_UTF8_STRING: + return [$bytes, $newOffset]; + + case self::_DOUBLE: + $this->verifySize(8, $size); + + return [$this->decodeDouble($bytes), $newOffset]; + + case self::_FLOAT: + $this->verifySize(4, $size); + + return [$this->decodeFloat($bytes), $newOffset]; + + case self::_INT32: + return [$this->decodeInt32($bytes, $size), $newOffset]; + + case self::_UINT16: + case self::_UINT32: + case self::_UINT64: + case self::_UINT128: + return [$this->decodeUint($bytes, $size), $newOffset]; + + default: + throw new InvalidDatabaseException( + 'Unknown or unexpected type: ' . $type + ); + } + } + + private function verifySize(int $expected, int $actual): void + { + if ($expected !== $actual) { + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + } + + /** + * @return array{0:array, 1:int} + */ + private function decodeArray(int $size, int $offset): array + { + $array = []; + + for ($i = 0; $i < $size; ++$i) { + [$value, $offset] = $this->decode($offset); + $array[] = $value; + } + + return [$array, $offset]; + } + + private function decodeBoolean(int $size): bool + { + return $size !== 0; + } + + private function decodeDouble(string $bytes): float + { + // This assumes IEEE 754 doubles, but most (all?) modern platforms + // use them. + $rc = unpack('E', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a double value from the given bytes.' + ); + } + [, $double] = $rc; + + return $double; + } + + private function decodeFloat(string $bytes): float + { + // This assumes IEEE 754 floats, but most (all?) modern platforms + // use them. + $rc = unpack('G', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a float value from the given bytes.' + ); + } + [, $float] = $rc; + + return $float; + } + + private function decodeInt32(string $bytes, int $size): int + { + switch ($size) { + case 0: + return 0; + + case 1: + case 2: + case 3: + $bytes = str_pad($bytes, 4, "\x00", \STR_PAD_LEFT); + + break; + + case 4: + break; + + default: + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + + $rc = unpack('l', $this->maybeSwitchByteOrder($bytes)); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a 32bit integer value from the given bytes.' + ); + } + [, $int] = $rc; + + return $int; + } + + /** + * @return array{0:array, 1:int} + */ + private function decodeMap(int $size, int $offset): array + { + $map = []; + + for ($i = 0; $i < $size; ++$i) { + [$key, $offset] = $this->decode($offset); + [$value, $offset] = $this->decode($offset); + $map[$key] = $value; + } + + return [$map, $offset]; + } + + /** + * @return array{0:int, 1:int} + */ + private function decodePointer(int $ctrlByte, int $offset): array + { + $pointerSize = (($ctrlByte >> 3) & 0x3) + 1; + + $buffer = Util::read($this->fileStream, $offset, $pointerSize); + $offset += $pointerSize; + + switch ($pointerSize) { + case 1: + $packed = \chr($ctrlByte & 0x7) . $buffer; + $rc = unpack('n', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes (pointerSize is 1).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase; + + break; + + case 2: + $packed = "\x00" . \chr($ctrlByte & 0x7) . $buffer; + $rc = unpack('N', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes (pointerSize is 2).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase + 2048; + + break; + + case 3: + $packed = \chr($ctrlByte & 0x7) . $buffer; + + // It is safe to use 'N' here, even on 32 bit machines as the + // first bit is 0. + $rc = unpack('N', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes (pointerSize is 3).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase + 526336; + + break; + + case 4: + // We cannot use unpack here as we might overflow on 32 bit + // machines + $pointerOffset = $this->decodeUint($buffer, $pointerSize); + + $pointerBase = $this->pointerBase; + + if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) { + $pointer = $pointerOffset + $pointerBase; + } else { + throw new \RuntimeException( + 'The database offset is too large to be represented on your platform.' + ); + } + + break; + + default: + throw new InvalidDatabaseException( + 'Unexpected pointer size ' . $pointerSize + ); + } + + return [$pointer, $offset]; + } + + // @phpstan-ignore-next-line + private function decodeUint(string $bytes, int $byteLength) + { + if ($byteLength === 0) { + return 0; + } + + // PHP integers are signed. PHP_INT_SIZE - 1 is the number of + // complete bytes that can be converted to an integer. However, + // we can convert another byte if the leading bit is zero. + $useRealInts = $byteLength <= \PHP_INT_SIZE - 1 + || ($byteLength === \PHP_INT_SIZE && (\ord($bytes[0]) & 0x80) === 0); + + if ($useRealInts) { + $integer = 0; + for ($i = 0; $i < $byteLength; ++$i) { + $part = \ord($bytes[$i]); + $integer = ($integer << 8) + $part; + } + + return $integer; + } + + // We only use gmp or bcmath if the final value is too big + $integerAsString = '0'; + for ($i = 0; $i < $byteLength; ++$i) { + $part = \ord($bytes[$i]); + + if (\extension_loaded('gmp')) { + $integerAsString = gmp_strval(gmp_add(gmp_mul($integerAsString, '256'), $part)); + } elseif (\extension_loaded('bcmath')) { + $integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part); + } else { + throw new \RuntimeException( + 'The gmp or bcmath extension must be installed to read this database.' + ); + } + } + + return $integerAsString; + } + + /** + * @return array{0:int, 1:int} + */ + private function sizeFromCtrlByte(int $ctrlByte, int $offset): array + { + $size = $ctrlByte & 0x1F; + + if ($size < 29) { + return [$size, $offset]; + } + + $bytesToRead = $size - 28; + $bytes = Util::read($this->fileStream, $offset, $bytesToRead); + + if ($size === 29) { + $size = 29 + \ord($bytes); + } elseif ($size === 30) { + $rc = unpack('n', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes.' + ); + } + [, $adjust] = $rc; + $size = 285 + $adjust; + } else { + $rc = unpack('N', "\x00" . $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes.' + ); + } + [, $adjust] = $rc; + $size = $adjust + 65821; + } + + return [$size, $offset + $bytesToRead]; + } + + private function maybeSwitchByteOrder(string $bytes): string + { + return $this->switchByteOrder ? strrev($bytes) : $bytes; + } + + private function isPlatformLittleEndian(): bool + { + $testint = 0x00FF; + $packed = pack('S', $testint); + $rc = unpack('v', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes.' + ); + } + + return $testint === current($rc); + } +} diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php new file mode 100644 index 0000000..b1da1ed --- /dev/null +++ b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php @@ -0,0 +1,11 @@ + + */ + public $description; + + /** + * This is an unsigned 16-bit integer which is always 4 or 6. It indicates + * whether the database contains IPv4 or IPv6 address data. + * + * @var int + */ + public $ipVersion; + + /** + * An array of strings, each of which is a language code. A given record + * may contain data items that have been localized to some or all of + * these languages. This may be undefined. + * + * @var array + */ + public $languages; + + /** + * @var int + */ + public $nodeByteSize; + + /** + * This is an unsigned 32-bit integer indicating the number of nodes in + * the search tree. + * + * @var int + */ + public $nodeCount; + + /** + * This is an unsigned 16-bit integer. It indicates the number of bits in a + * record in the search tree. Note that each node consists of two records. + * + * @var int + */ + public $recordSize; + + /** + * @var int + */ + public $searchTreeSize; + + /** + * @param array $metadata + */ + public function __construct(array $metadata) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + $this->binaryFormatMajorVersion + = $metadata['binary_format_major_version']; + $this->binaryFormatMinorVersion + = $metadata['binary_format_minor_version']; + $this->buildEpoch = $metadata['build_epoch']; + $this->databaseType = $metadata['database_type']; + $this->languages = $metadata['languages']; + $this->description = $metadata['description']; + $this->ipVersion = $metadata['ip_version']; + $this->nodeCount = $metadata['node_count']; + $this->recordSize = $metadata['record_size']; + $this->nodeByteSize = $this->recordSize / 4; + $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize; + } +} diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php new file mode 100644 index 0000000..c2c3212 --- /dev/null +++ b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php @@ -0,0 +1,33 @@ + $numberOfBytes + */ + public static function read($stream, int $offset, int $numberOfBytes): string + { + if ($numberOfBytes === 0) { + return ''; + } + if (fseek($stream, $offset) === 0) { + $value = fread($stream, $numberOfBytes); + + // We check that the number of bytes read is equal to the number + // asked for. We use ftell as getting the length of $value is + // much slower. + if ($value !== false && ftell($stream) - $offset === $numberOfBytes) { + return $value; + } + } + + throw new InvalidDatabaseException( + 'The MaxMind DB file contains bad data' + ); + } +} diff --git a/patches/third_party/maxmind/web-service-common/CHANGELOG.md b/patches/third_party/maxmind/web-service-common/CHANGELOG.md new file mode 100644 index 0000000..667ccc8 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/CHANGELOG.md @@ -0,0 +1,129 @@ +CHANGELOG +========= + +0.11.1 (2026-01-13) +------------------- + +* Removed deprecated `curl_close()` calls. These were no-ops since PHP 8.0 + and are deprecated in PHP 8.5. Pull request by Sam Reed. GitHub #105. +* Added native property and return types. PHPStan level increased from 6 to 8. + +0.11.0 (2025-11-20) +------------------- + +* Type hints have been improved. + +0.10.0 (2024-11-14) +------------------- + +* PHP 8.1 or greater is now required. +* Type hints for PHPStan have been improved. + +0.9.0 (2022-03-28) +------------------ + +* Improved internal type hint usage. + +0.8.1 (2020-11-02) +------------------ + +* We now correctly handle responses without a `Content-Type` header. In 0.8.0, + such responses could lead to a type error. In particular, this affected the + minFraud Report Transaction endpoint, which returns a response with no + content. Reported by Dmitry Malashko. GitHub #99 on + `maxmind/minfraud-api-php`. + +0.8.0 (2020-10-01) +------------------ + +* PHP 7.2 or greater is now required. +* Added additional type hints. + +0.7.0 (2020-05-06) +------------------ + +* Responses with a 204 status code are accepted as successes. + +0.6.0 (2019-12-12) +------------------ + +* Curl handles are now reused across requests. Pull request by Willem + Stuursma-Ruwen. GitHub #24. +* PHP 5.6 is now required. + +0.5.0 (2018-02-12) +------------------ + +* Refer to account IDs using the terminology "account" rather than "user". + +0.4.0 (2017-07-10) +------------------ + +* PHP 5.4 is now required. + +0.3.1 (2016-08-10) +------------------ + +* On Mac OS X when using a curl built against SecureTransport, the certs + in the system's keychain will now be used instead of the CA bundle on + the file system. + +0.3.0 (2016-08-09) +------------------ + +* This package now uses `composer/ca-bundle` by default rather than a CA + bundle distributed with this package. `composer/ca-bundle` will first try + to use the system CA bundle and will fall back to the Mozilla CA bundle + when no system bundle is available. You may still specify your own bundle + using the `caBundle` option. + +0.2.1 (2016-06-13) +------------------ + +* Fix typo in code to copy cert to temp directory. + +0.2.0 (2016-06-10) +------------------ + +* Added handling of additional error codes that the web service may return. +* A `USER_ID_UNKNOWN` error will now throw a + `MaxMind\Exception\AuthenticationException`. +* Added support for `proxy` option. Closes #6. + +0.1.0 (2016-05-23) +------------------ + +* A `PERMISSION_REQUIRED` error will now throw a `PermissionRequiredException` + exception. +* Added a `.gitattributes` file to exclude tests from Composer releases. + GitHub #7. +* Updated included cert bundle. + +0.0.4 (2015-07-21) +------------------ + +* Added extremely basic tests for the curl calls. +* Fixed broken POSTs. + +0.0.3 (2015-06-30) +------------------ + +* Floats now work with the `timeout` and `connectTimeout` options. Fix by + Benjamin Pick. GitHub PR #2. +* `curl_error` is now used instead of `curl_strerror`. The latter is only + available for PHP 5.5 or later. Fix by Benjamin Pick. GitHub PR #1. + + +0.0.2 (2015-06-09) +------------------ + +* An exception is now immediately thrown curl error rather than letting later + status code checks throw an exception. This improves the exception message + greatly. +* If this library is inside a phar archive, the CA certs are copied out of the + archive to a temporary file so that curl can use them. + +0.0.1 (2015-06-01) +------------------ + +* Initial release. diff --git a/patches/third_party/maxmind/web-service-common/LICENSE b/patches/third_party/maxmind/web-service-common/LICENSE new file mode 100644 index 0000000..62589ed --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + https://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. diff --git a/patches/third_party/maxmind/web-service-common/README.md b/patches/third_party/maxmind/web-service-common/README.md new file mode 100644 index 0000000..26092c5 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/README.md @@ -0,0 +1,25 @@ +# Common Code for MaxMind Web Service Clients # + +This is _not_ intended for direct use by third parties. Rather, it is for +shared code between MaxMind's various web service client APIs. + +## Requirements ## + +The library requires PHP 8.1 or greater. + +There are several other dependencies as defined in the `composer.json` file. + +## Contributing ## + +Patches and pull requests are encouraged. All code should follow the PSR-12 +style guidelines. Please include unit tests whenever possible. + +## Versioning ## + +This API uses [Semantic Versioning](https://semver.org/). + +## Copyright and License ## + +This software is Copyright (c) 2015-2026 by MaxMind, Inc. + +This is free software, licensed under the Apache License, Version 2.0. diff --git a/patches/third_party/maxmind/web-service-common/composer.json b/patches/third_party/maxmind/web-service-common/composer.json new file mode 100644 index 0000000..c9c7bd2 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/composer.json @@ -0,0 +1,32 @@ +{ + "name": "maxmind/web-service-common", + "description": "Internal MaxMind Web Service API", + "minimum-stability": "stable", + "homepage": "https://github.com/maxmind/web-service-common-php", + "type": "library", + "license": "Apache-2.0", + "authors": [ + { + "name": "Gregory Oschwald", + "email": "goschwald@maxmind.com" + } + ], + "require": { + "php": ">=8.1", + "composer/ca-bundle": "^1.0.3", + "ext-curl": "*", + "ext-json": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "4.*", + "phpstan/phpstan": "*" + }, + "autoload": { + "psr-4": { + "MaxMind\\Exception\\": "src/Exception", + "MaxMind\\WebService\\": "src/WebService" + } + } +} diff --git a/patches/third_party/maxmind/web-service-common/dev-bin/release.sh b/patches/third_party/maxmind/web-service-common/dev-bin/release.sh new file mode 100755 index 0000000..552998d --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/dev-bin/release.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -eu -o pipefail + +# Pre-flight checks - verify all required tools are available and configured +# before making any changes to the repository + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo "Error: $1 is not installed or not in PATH" + exit 1 + fi +} + +# Verify gh CLI is authenticated +if ! gh auth status &>/dev/null; then + echo "Error: gh CLI is not authenticated. Run 'gh auth login' first." + exit 1 +fi + +# Verify we can access this repository via gh +if ! gh repo view --json name &>/dev/null; then + echo "Error: Cannot access repository via gh. Check your authentication and repository access." + exit 1 +fi + +# Verify git can connect to the remote (catches SSH key issues, etc.) +if ! git ls-remote origin &>/dev/null; then + echo "Error: Cannot connect to git remote. Check your git credentials/SSH keys." + exit 1 +fi + +check_command php + +# Check that we're not on the main branch +current_branch=$(git branch --show-current) +if [ "$current_branch" = "main" ]; then + echo "Error: Releases should not be done directly on the main branch." + echo "Please create a release branch and run this script from there." + exit 1 +fi + +# Fetch latest changes and check that we're not behind origin/main +echo "Fetching from origin..." +git fetch origin + +if ! git merge-base --is-ancestor origin/main HEAD; then + echo "Error: Current branch is behind origin/main." + echo "Please merge or rebase with origin/main before releasing." + exit 1 +fi + +changelog=$(cat CHANGELOG.md) + +regex=' +([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?) \(([0-9]{4}-[0-9]{2}-[0-9]{2})\) +-* + +((.| +)*) +' + +if [[ ! $changelog =~ $regex ]]; then + echo "Could not find date line in change log!" + exit 1 +fi + +version="${BASH_REMATCH[1]}" +date="${BASH_REMATCH[3]}" +notes="$(echo "${BASH_REMATCH[4]}" | sed -n -E '/^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?/,$!p')" + +if [[ "$date" != "$(date +"%Y-%m-%d")" ]]; then + echo "$date is not today!" + exit 1 +fi + +tag="v$version" + +if [ -n "$(git status --porcelain)" ]; then + echo ". is not clean." >&2 + exit 1 +fi + +php composer.phar self-update +php composer.phar update + +./vendor/bin/phpunit + +echo "Release notes for $tag:" +echo "$notes" + +read -r -e -p "Commit changes and push to origin? " should_push + +if [ "$should_push" != "y" ]; then + echo "Aborting" + exit 1 +fi + +git push + +gh release create --target "$(git branch --show-current)" -t "$version" -n "$notes" "$tag" diff --git a/patches/third_party/maxmind/web-service-common/phpstan.neon b/patches/third_party/maxmind/web-service-common/phpstan.neon new file mode 100644 index 0000000..fee38d3 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + paths: + - src + - tests + diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php b/patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php new file mode 100644 index 0000000..5b016ce --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php @@ -0,0 +1,12 @@ +uri = $uri; + parent::__construct($message, $httpStatus, $previous); + } + + public function getUri(): string + { + return $this->uri; + } + + public function getStatusCode(): int + { + return $this->getCode(); + } +} diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php b/patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php new file mode 100644 index 0000000..2831456 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php @@ -0,0 +1,12 @@ +error = $error; + parent::__construct($message, $httpStatus, $uri, $previous); + } + + public function getErrorCode(): string + { + return $this->error; + } +} diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php b/patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php new file mode 100644 index 0000000..97af06c --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php @@ -0,0 +1,12 @@ + $options an array of options. Possible keys: + * * `host` - The host to use when connecting to the web service. + * * `useHttps` - Set to false to disable HTTPS. + * * `userAgent` - The prefix of the User-Agent to use in the request. + * * `caBundle` - The bundle of CA root certificates to use in the request. + * * `connectTimeout` - The connect timeout to use for the request. + * * `timeout` - The timeout to use for the request. + * * `proxy` - The HTTP proxy to use. May include a schema, port, + * username, and password, e.g., `http://username:password@127.0.0.1:10`. + */ + public function __construct( + int $accountId, + string $licenseKey, + array $options = [] + ) { + $this->accountId = $accountId; + $this->licenseKey = $licenseKey; + + $this->httpRequestFactory = $options['httpRequestFactory'] ?? new RequestFactory(); + $this->host = $options['host'] ?? 'api.maxmind.com'; + $this->useHttps = $options['useHttps'] ?? true; + $this->userAgentPrefix = isset($options['userAgent']) ? $options['userAgent'] . ' ' : ''; + $this->caBundle = $options['caBundle'] ?? $this->getCaBundle(); + $this->connectTimeout = $options['connectTimeout'] ?? null; + $this->timeout = $options['timeout'] ?? null; + $this->proxy = $options['proxy'] ?? null; + } + + /** + * @param string $service name of the service querying + * @param string $path the URI path to use + * @param array $input the data to be posted as JSON + * + * @throws InvalidInputException when the request has missing or invalid + * data + * @throws AuthenticationException when there is an issue authenticating the + * request + * @throws InsufficientFundsException when your account is out of funds + * @throws InvalidRequestException when the request is invalid for some + * other reason, e.g., invalid JSON in the POST. + * @throws HttpException when an unexpected HTTP error occurs + * @throws WebServiceException when some other error occurs. This also + * serves as the base class for the above exceptions. + * + * @return array|null The decoded content of a successful response + */ + public function post(string $service, string $path, array $input): ?array + { + $requestBody = json_encode($input); + if ($requestBody === false) { + throw new InvalidInputException( + 'Error encoding input as JSON: ' + . $this->jsonErrorDescription() + ); + } + + $request = $this->createRequest( + $path, + ['Content-Type: application/json'] + ); + + [$statusCode, $contentType, $responseBody] = $request->post($requestBody); + + return $this->handleResponse( + $statusCode, + $contentType, + $responseBody, + $service, + $path + ); + } + + /** + * @return array|null + */ + public function get(string $service, string $path): ?array + { + $request = $this->createRequest( + $path + ); + + [$statusCode, $contentType, $responseBody] = $request->get(); + + return $this->handleResponse( + $statusCode, + $contentType, + $responseBody, + $service, + $path + ); + } + + private function userAgent(): string + { + $curlVersion = curl_version(); + if ($curlVersion === false) { + throw new \RuntimeException('curl_version() returned false'); + } + + return $this->userAgentPrefix . 'MaxMind-WS-API/' . self::VERSION . ' PHP/' . \PHP_VERSION + . ' curl/' . $curlVersion['version']; + } + + /** + * @param array $headers + */ + private function createRequest(string $path, array $headers = []): Http\Request + { + $headers = [ + ...$headers, + 'Authorization: Basic ' + . base64_encode($this->accountId . ':' . $this->licenseKey), + 'Accept: application/json', + ]; + + return $this->httpRequestFactory->request( + $this->urlFor($path), + [ + 'caBundle' => $this->caBundle, + 'connectTimeout' => $this->connectTimeout, + 'headers' => $headers, + 'proxy' => $this->proxy, + 'timeout' => $this->timeout, + 'userAgent' => $this->userAgent(), + ] + ); + } + + /** + * @param int $statusCode the HTTP status code of the response + * @param string|null $contentType the Content-Type of the response + * @param string|null $responseBody the response body + * @param string $service the name of the service + * @param string $path the path used in the request + * + * @throws AuthenticationException when there is an issue authenticating the + * request + * @throws InsufficientFundsException when your account is out of funds + * @throws InvalidRequestException when the request is invalid for some + * other reason, e.g., invalid JSON in the POST. + * @throws HttpException when an unexpected HTTP error occurs + * @throws WebServiceException when some other error occurs. This also + * serves as the base class for the above exceptions + * + * @return array|null The decoded content of a successful response + */ + private function handleResponse( + int $statusCode, + ?string $contentType, + ?string $responseBody, + string $service, + string $path + ): ?array { + if ($statusCode >= 400 && $statusCode <= 499) { + $this->handle4xx($statusCode, $contentType, $responseBody, $service, $path); + } elseif ($statusCode >= 500) { + $this->handle5xx($statusCode, $service, $path); + } elseif ($statusCode !== 200 && $statusCode !== 204) { + $this->handleUnexpectedStatus($statusCode, $service, $path); + } + + return $this->handleSuccess($statusCode, $responseBody, $service); + } + + /** + * @return string describing the JSON error + */ + private function jsonErrorDescription(): string + { + $errno = json_last_error(); + + switch ($errno) { + case \JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded.'; + + case \JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON.'; + + case \JSON_ERROR_CTRL_CHAR: + return 'Control character error.'; + + case \JSON_ERROR_SYNTAX: + return 'Syntax error.'; + + case \JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters.'; + + default: + return "Other JSON error ($errno)."; + } + } + + /** + * @param string $path the path to use in the URL + * + * @return string the constructed URL + */ + private function urlFor(string $path): string + { + return ($this->useHttps ? 'https://' : 'http://') . $this->host . $path; + } + + /** + * @param int $statusCode the HTTP status code + * @param string|null $contentType the response content-type + * @param string|null $body the response body + * @param string $service the service name + * @param string $path the path used in the request + * + * @throws AuthenticationException + * @throws HttpException + * @throws InsufficientFundsException + * @throws InvalidRequestException + */ + private function handle4xx( + int $statusCode, + ?string $contentType, + ?string $body, + string $service, + string $path + ): void { + if ($body === null || $body === '') { + throw new HttpException( + "Received a $statusCode error for $service with no body", + $statusCode, + $this->urlFor($path) + ); + } + if ($contentType === null || !str_contains($contentType, 'json')) { + throw new HttpException( + "Received a $statusCode error for $service with " + . 'the following body: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + $message = json_decode($body, true); + if ($message === null) { + throw new HttpException( + "Received a $statusCode error for $service but could " + . 'not decode the response as JSON: ' + . $this->jsonErrorDescription() . ' Body: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + if (!isset($message['code']) || !isset($message['error'])) { + throw new HttpException( + 'Error response contains JSON but it does not ' + . 'specify code or error keys: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + $this->handleWebServiceError( + $message['error'], + $message['code'], + $statusCode, + $path + ); + } + + /** + * @param string $message the error message from the web service + * @param string $code the error code from the web service + * @param int $statusCode the HTTP status code + * @param string $path the path used in the request + * + * @throws AuthenticationException + * @throws InvalidRequestException + * @throws InsufficientFundsException + */ + private function handleWebServiceError( + string $message, + string $code, + int $statusCode, + string $path + ): void { + switch ($code) { + case 'IP_ADDRESS_NOT_FOUND': + case 'IP_ADDRESS_RESERVED': + throw new IpAddressNotFoundException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + + case 'ACCOUNT_ID_REQUIRED': + case 'ACCOUNT_ID_UNKNOWN': + case 'AUTHORIZATION_INVALID': + case 'LICENSE_KEY_REQUIRED': + case 'USER_ID_REQUIRED': + case 'USER_ID_UNKNOWN': + throw new AuthenticationException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + + case 'OUT_OF_QUERIES': + case 'INSUFFICIENT_FUNDS': + throw new InsufficientFundsException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + + case 'PERMISSION_REQUIRED': + throw new PermissionRequiredException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + + default: + throw new InvalidRequestException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + } + } + + /** + * @param int $statusCode the HTTP status code + * @param string $service the service name + * @param string $path the URI path used in the request + * + * @throws HttpException + */ + private function handle5xx(int $statusCode, string $service, string $path): void + { + throw new HttpException( + "Received a server error ($statusCode) for $service", + $statusCode, + $this->urlFor($path) + ); + } + + /** + * @param int $statusCode the HTTP status code + * @param string $service the service name + * @param string $path the URI path used in the request + * + * @throws HttpException + */ + private function handleUnexpectedStatus(int $statusCode, string $service, string $path): void + { + throw new HttpException( + 'Received an unexpected HTTP status ' + . "($statusCode) for $service", + $statusCode, + $this->urlFor($path) + ); + } + + /** + * @param int $statusCode the HTTP status code + * @param string|null $body the successful request body + * @param string $service the service name + * + * @throws WebServiceException if a response body is included but not + * expected, or is not expected but not + * included, or is expected and included + * but cannot be decoded as JSON + * + * @return array|null the decoded request body + */ + private function handleSuccess(int $statusCode, ?string $body, string $service): ?array + { + // A 204 should have no response body + if ($statusCode === 204) { + if ($body !== null && $body !== '') { + throw new WebServiceException( + "Received a 204 response for $service along with an " + . "unexpected HTTP body: $body" + ); + } + + return null; + } + + // A 200 should have a valid JSON body + if ($body === null || $body === '') { + throw new WebServiceException( + "Received a 200 response for $service but did not " + . 'receive a HTTP body.' + ); + } + + $decodedContent = json_decode($body, true); + if ($decodedContent === null) { + throw new WebServiceException( + "Received a 200 response for $service but could " + . 'not decode the response as JSON: ' + . $this->jsonErrorDescription() . ' Body: ' . $body + ); + } + + return $decodedContent; + } + + private function getCaBundle(): ?string + { + $curlVersion = curl_version(); + if ($curlVersion === false) { + throw new \RuntimeException('curl_version() returned false'); + } + + // On OS X, when the SSL version is "SecureTransport", the system's + // keychain will be used. + if ($curlVersion['ssl_version'] === 'SecureTransport') { + return null; + } + $cert = CaBundle::getSystemCaRootBundlePath(); + + // Check if the cert is inside a phar. If so, we need to copy the cert + // to a temp file so that curl can see it. + if (str_starts_with($cert, 'phar://')) { + $tempDir = sys_get_temp_dir(); + $newCert = tempnam($tempDir, 'geoip2-'); + if ($newCert === false) { + throw new \RuntimeException( + "Unable to create temporary file in $tempDir" + ); + } + if (!copy($cert, $newCert)) { + throw new \RuntimeException( + "Could not copy $cert to $newCert: " + . var_export(error_get_last(), true) + ); + } + + // We use a shutdown function rather than the destructor as the + // destructor isn't called on a fatal error such as an uncaught + // exception. + register_shutdown_function( + function () use ($newCert) { + unlink($newCert); + } + ); + $cert = $newCert; + } + if (!file_exists($cert)) { + throw new \RuntimeException("CA cert does not exist at $cert"); + } + + return $cert; + } +} diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php new file mode 100644 index 0000000..7318db5 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php @@ -0,0 +1,146 @@ +, + * proxy: string|null, + * timeout: float|int, + * userAgent: string + * } + */ + private readonly array $options; + + /** + * @param array{ + * caBundle?: string, + * connectTimeout: float|int, + * curlHandle: \CurlHandle, + * headers: array, + * proxy: string|null, + * timeout: float|int, + * userAgent: string + * } $options + */ + public function __construct(string $url, array $options) + { + $this->url = $url; + $this->options = $options; + $this->ch = $options['curlHandle']; + } + + /** + * @throws HttpException + * + * @return array{0:int, 1:string|null, 2:string|null} + */ + public function post(string $body): array + { + $curl = $this->createCurl(); + + curl_setopt($curl, \CURLOPT_POST, true); + curl_setopt($curl, \CURLOPT_POSTFIELDS, $body); + + return $this->execute($curl); + } + + /** + * @return array{0:int, 1:string|null, 2:string|null} + */ + public function get(): array + { + $curl = $this->createCurl(); + + curl_setopt($curl, \CURLOPT_HTTPGET, true); + + return $this->execute($curl); + } + + private function createCurl(): \CurlHandle + { + curl_reset($this->ch); + + $opts = []; + $opts[\CURLOPT_URL] = $this->url; + + if (!empty($this->options['caBundle'])) { + $opts[\CURLOPT_CAINFO] = $this->options['caBundle']; + } + + $opts[\CURLOPT_ENCODING] = ''; + $opts[\CURLOPT_SSL_VERIFYHOST] = 2; + $opts[\CURLOPT_FOLLOWLOCATION] = false; + $opts[\CURLOPT_SSL_VERIFYPEER] = true; + $opts[\CURLOPT_RETURNTRANSFER] = true; + + $opts[\CURLOPT_HTTPHEADER] = $this->options['headers']; + $opts[\CURLOPT_USERAGENT] = $this->options['userAgent']; + if ($this->options['proxy'] !== null) { + $opts[\CURLOPT_PROXY] = $this->options['proxy']; + } + + $connectTimeout = $this->options['connectTimeout']; + $opts[\CURLOPT_CONNECTTIMEOUT_MS] = (int) ceil($connectTimeout * 1000); + + $timeout = $this->options['timeout']; + $opts[\CURLOPT_TIMEOUT_MS] = (int) ceil($timeout * 1000); + + // @phpstan-ignore argument.type (PHPStan's curl stubs require non-empty-string for URL/userAgent) + curl_setopt_array($this->ch, $opts); + + return $this->ch; + } + + /** + * @throws HttpException + * + * @return array{0:int, 1:string|null, 2:string|null} + */ + private function execute(\CurlHandle $curl): array + { + $body = curl_exec($curl); + if ($errno = curl_errno($curl)) { + $errorMessage = curl_error($curl); + + throw new HttpException( + "cURL error ({$errno}): {$errorMessage}", + 0, + $this->url + ); + } + + $statusCode = curl_getinfo($curl, \CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($curl, \CURLINFO_CONTENT_TYPE); + + return [ + $statusCode, + // The PHP docs say "Content-Type: of the requested document. NULL + // indicates server did not send valid Content-Type: header" for + // CURLINFO_CONTENT_TYPE. However, it will return FALSE if no header + // is set. To keep our types simple, we return null in this case. + $contentType === false ? null : $contentType, + // curl_exec returns false on failure, but we've already checked + // for errors above and thrown an exception. Cast to string to + // satisfy PHPStan since curl_exec technically returns string|bool. + $body === false ? null : (string) $body, + ]; + } +} diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php new file mode 100644 index 0000000..9a03e5f --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php @@ -0,0 +1,28 @@ + $options + */ + public function __construct(string $url, array $options); + + /** + * @return array{0:int, 1:string|null, 2:string|null} + */ + public function post(string $body): array; + + /** + * @return array{0:int, 1:string|null, 2:string|null} + */ + public function get(): array; +} diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php new file mode 100644 index 0000000..d0a19f6 --- /dev/null +++ b/patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php @@ -0,0 +1,44 @@ +ch === null) { + $ch = curl_init(); + if ($ch === false) { + throw new \RuntimeException('Unable to initialize cURL handle'); + } + $this->ch = $ch; + } + + return $this->ch; + } + + /** + * @param array $options + */ + public function request(string $url, array $options): Request + { + $options['curlHandle'] = $this->getCurlHandle(); + + // @phpstan-ignore argument.type (options array is built dynamically by Client) + return new CurlRequest($url, $options); + } +} diff --git a/public/assets/js/backend/domain.js b/public/assets/js/backend/domain.js index 5b31450..9912702 100755 --- a/public/assets/js/backend/domain.js +++ b/public/assets/js/backend/domain.js @@ -1,10 +1,25 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + /** + * 列表 Ajax 仅保留 addtabs 参数。 + * 切勿拼接 location.search 中的 sort/filter/op 等,否则从其它模块跳入 /domain/index 时会带错字段导致列表为空。 + */ + var buildListQuery = function () { + var rawSearch = location.search || ''; + if (!rawSearch) { + return ''; + } + var addtabsMatch = rawSearch.match(/(?:[?&])addtabs=[^&]*/); + return addtabsMatch ? ('?' + addtabsMatch[0].replace(/^[?&]/, '')) : ''; + }; + var Controller = { index: function () { + var listQuery = buildListQuery(); + var indexUrl = 'domain/index' + listQuery; Table.api.init({ extend: { - index_url: 'domain/index' + location.search, + index_url: indexUrl, add_url: 'domain/add', edit_url: '', del_url: '', @@ -25,7 +40,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin columns: [ [ {checkbox: true}, - {field: 'domain', title: __('Domain'), operate: 'LIKE'}, + {field: 'domain', title: __('Domain'), operate: 'LIKE', renderDefault: false}, {field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url}, {field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status}, {field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status}, diff --git a/public/assets/js/backend/split/link.js b/public/assets/js/backend/split/link.js index a8de5ab..c8e78ff 100644 --- a/public/assets/js/backend/split/link.js +++ b/public/assets/js/backend/split/link.js @@ -37,8 +37,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin field: 'auto_reply_text', title: __('Reply statements column'), operate: false, - class: 'autocontent', - hover: true, formatter: Controller.api.formatter.autoReplyText }, {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, @@ -58,6 +56,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin icon: 'fa fa-commenting-o', classname: 'btn btn-warning btn-xs btn-split-autoreply', url: 'javascript:;' + }, + { + name: 'pixel', + text: __('Pixel config'), + title: __('Pixel config'), + icon: 'fa fa-bullseye', + classname: 'btn btn-info btn-xs btn-split-pixel', + url: 'javascript:;' } ], formatter: Table.api.formatter.operate @@ -97,6 +103,20 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openAutoReplyModal(row); }); + table.on('click', '.btn-split-pixel', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var rowIndex = $(this).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row || !row.id) { + return false; + } + Controller.api.openPixelModal(row); + }); + + Controller.api.bindAutoReplyPreviewTips(table); + Table.api.bindevent(table); }, add: function () { @@ -107,8 +127,45 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + /** + * 规范化后台跨模块跳转 URL,避免在 split.link 页面内相对解析成 split.link/domain + * + * @param {string} url API 返回的地址 + * @param {string} fallback 相对 admin 模块根路径,如 domain / domain/add + * @return {string} + */ + normalizeAdminRouteUrl: function (url, fallback) { + fallback = fallback || 'domain'; + url = $.trim(url || ''); + if (url === '' || /split\.link\/domain/i.test(url)) { + return fallback; + } + var modulePrefix = (Config.moduleurl || '').replace(/\/+$/, ''); + if (url.indexOf('://') !== -1) { + try { + var parsed = new URL(url, window.location.origin); + var path = (parsed.pathname || '').replace(/\/+$/, ''); + if (modulePrefix && path.indexOf(modulePrefix) === 0) { + path = path.slice(modulePrefix.length); + } + path = path.replace(/^\/+/, ''); + return /^split\.link\/domain/i.test(path) ? fallback : (path || fallback); + } catch (e) { + return fallback; + } + } + if (url.charAt(0) === '/') { + url = url.replace(/^\/+/, ''); + var moduleKey = modulePrefix.replace(/^\/+/, ''); + if (moduleKey && url.indexOf(moduleKey + '/') === 0) { + url = url.slice(moduleKey.length + 1); + } + } + return /^split\.link\/domain/i.test(url) ? fallback : url; + }, /** 弹窗样式(仅注入一次) */ modalStyleInjected: false, + pixelModalStyleInjected: false, injectModalStyles: function () { if (Controller.api.modalStyleInjected) { return; @@ -140,18 +197,63 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin ].join(''); $('').text(css).appendTo('head'); }, + injectPixelModalStyles: function () { + if (Controller.api.pixelModalStyleInjected) { + return; + } + Controller.api.pixelModalStyleInjected = true; + var css = [ + '.split-pixel-layer .layui-layer-content{padding:0;overflow:hidden;max-height:calc(86vh - 108px);}', + '.split-pixel-layer .layui-layer-btn{border-top:1px solid #e8e8e8;background:#fafafa;}', + '.split-pixel-modal{padding:18px 22px 14px;box-sizing:border-box;display:flex;flex-direction:column;min-height:480px;height:calc(86vh - 108px);max-height:720px;}', + '.split-pixel-modal .split-pixel-tip{margin:0 0 14px;padding:10px 14px;background:#f0f7ff;border-left:3px solid #337ab7;border-radius:0 4px 4px 0;color:#555;font-size:13px;line-height:1.65;}', + '.split-pixel-modal .split-pixel-tabs{margin-bottom:0;border-bottom:1px solid #ddd;}', + '.split-pixel-modal .split-pixel-tabs>li>a{padding:9px 18px;font-weight:600;color:#666;}', + '.split-pixel-modal .split-pixel-tabs>li.active>a{color:#337ab7;border-bottom-color:#fff;}', + '.split-pixel-modal .split-pixel-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;padding-top:14px;}', + '.split-pixel-modal .split-pixel-tab-content>.tab-pane{display:none;flex:1;flex-direction:column;min-height:0;}', + '.split-pixel-modal .split-pixel-tab-content>.tab-pane.active{display:flex;}', + '.split-pixel-modal .split-pixel-list{flex:1;display:flex;flex-direction:column;min-height:0;}', + '.split-pixel-modal .split-pixel-toolbar{margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}', + '.split-pixel-modal .split-pixel-toolbar .btn-add-pixel-row{font-weight:600;padding:6px 14px;}', + '.split-pixel-modal .split-pixel-table-wrap{flex:1;min-height:320px;overflow:auto;border:1px solid #dce3eb;border-radius:6px;background:#fff;box-shadow:inset 0 1px 2px rgba(0,0,0,.03);}', + '.split-pixel-modal .split-pixel-table{margin-bottom:0;font-size:13px;table-layout:auto;width:100%;}', + '.split-pixel-modal .split-pixel-table thead th{background:linear-gradient(180deg,#f8fafc 0%,#eef2f6 100%);white-space:nowrap;vertical-align:middle;text-align:center;font-weight:600;color:#444;border-bottom:2px solid #dce3eb;padding:10px 8px;position:sticky;top:0;z-index:3;box-shadow:0 1px 0 #dce3eb;}', + '.split-pixel-modal .split-pixel-table tbody td{vertical-align:middle;padding:10px 8px;border-color:#edf1f5;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:hover{background:#f7fbff;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even){background:#fbfcfd;}', + '.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even):hover{background:#f7fbff;}', + '.split-pixel-modal .split-pixel-table .form-control{min-width:0;height:32px;line-height:1.42857143;padding:6px 10px;border-radius:4px;}', + '.split-pixel-modal .split-pixel-table .pixel-id{min-width:130px;}', + '.split-pixel-modal .split-pixel-table .pixel-access-token{min-width:150px;}', + '.split-pixel-modal .split-pixel-table .pixel-test-code{min-width:100px;}', + '.split-pixel-modal .split-pixel-table th.pixel-event-col,.split-pixel-modal .split-pixel-table td.pixel-event-cell{min-width:148px;width:148px;}', + '.split-pixel-modal .split-pixel-table .pixel-event{width:100%;min-width:132px;max-width:none;padding-right:28px;text-overflow:clip;overflow:visible;white-space:nowrap;cursor:pointer;}', + '.split-pixel-modal .split-pixel-empty-row td{padding:48px 16px;color:#999;text-align:center;font-size:13px;background:#fafbfc;}', + '.split-pixel-modal .split-pixel-sort-group{width:118px;margin:0 auto;}', + '.split-pixel-modal .split-pixel-sort-group .form-control{text-align:center;padding-left:4px;padding-right:4px;}', + '.split-pixel-modal .pixel-switch-wrap{text-align:center;}', + '.split-pixel-modal .pixel-switch-wrap input[type=checkbox]{width:17px;height:17px;margin:0;cursor:pointer;vertical-align:middle;}', + '.split-pixel-modal .pixel-row-index{display:inline-block;min-width:26px;height:26px;line-height:26px;border-radius:13px;background:#e8eef5;color:#4a6785;font-weight:600;font-size:12px;}', + '.split-pixel-modal .btn-pixel-row-remove{padding:4px 8px;border-radius:4px;}', + '.split-pixel-modal .col-token{min-width:160px;}' + ].join(''); + $('').text(css).appendTo('head'); + }, formatter: { /** - * 回复语:单元格内省略,悬停显示完整内容 + * 回复语:列表最多 50 字 + ...,悬停保留换行显示全文 */ autoReplyText: function (value, row, index) { - value = value == null ? '' : value.toString(); - if (value === '') { + var full = (row.auto_reply != null && row.auto_reply !== '') ? String(row.auto_reply) : ''; + if (full === '') { return '-'; } - var safe = Fast.api.escape(value); - return "
" + safe + "
"; + var preview = value != null && value !== '' ? String(value) : full; + var safePreview = Fast.api.escape(preview); + var encFull = encodeURIComponent(full); + return '' + + safePreview + ''; }, /** * 分流链接列:链接样式 + COPY 图标,点击链接打开弹窗 @@ -173,6 +275,39 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.renderCopyModal(linkCode, data); }); }, + /** 回复语列悬停提示(保留换行) */ + autoReplyTipIndex: null, + bindAutoReplyPreviewTips: function (table) { + table.off('mouseenter.splitAutoReply mouseleave.splitAutoReply') + .on('mouseenter.splitAutoReply', '.split-auto-reply-preview', function () { + var enc = $(this).attr('data-full'); + if (!enc) { + return; + } + var full = ''; + try { + full = decodeURIComponent(enc); + } catch (e) { + return; + } + var html = '
' + + Controller.api.escapeHtmlWithNewlines(full) + '
'; + Controller.api.autoReplyTipIndex = Layer.tips(html, this, { + tips: [1, ''], + time: 0, + maxWidth: 450 + }); + }) + .on('mouseleave.splitAutoReply', '.split-auto-reply-preview', function () { + if (Controller.api.autoReplyTipIndex != null) { + Layer.close(Controller.api.autoReplyTipIndex); + Controller.api.autoReplyTipIndex = null; + } + }); + }, + escapeHtmlWithNewlines: function (text) { + return $('
').text(text).html().replace(/\n/g, '
'); + }, /** * 打开自动回复弹窗 * @@ -226,6 +361,233 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } }); }, + pixelRowUid: function (prefix) { + return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); + }, + openPixelModal: function (row) { + Fast.api.ajax({ + url: 'split.link/pixel/ids/' + row.id, + type: 'get' + }, function (data, ret) { + var info = ret.data || data || {}; + Controller.api.renderPixelModal(row.id, info); + return false; + }); + }, + renderPixelModal: function (linkId, info) { + Controller.api.injectPixelModalStyles(); + var eventOptions = $.isArray(info.event_options) ? info.event_options : [ + 'PageView', 'Lead', 'Contact', 'AddToCart', 'Purchase', 'Subscribe' + ]; + var fbRows = $.isArray(info.facebook) ? info.facebook : []; + var tkRows = $.isArray(info.tiktok) ? info.tiktok : []; + + var html = [ + '
', + '
' + __('Pixel config tip') + '
', + ' ', + '
', + '
', + Controller.api.buildPixelListShell('facebook', __('Add FB Pixel')), + '
', + '
', + Controller.api.buildPixelListShell('tiktok', __('Add TK Pixel')), + '
', + '
', + '
' + ].join(''); + + Layer.open({ + type: 1, + title: __('Pixel config') + ' - ' + Fast.api.escape(info.link_code || ''), + area: ['1140px', '86vh'], + maxmin: true, + shadeClose: false, + content: html, + btn: [__('OK'), __('Close')], + success: function (layero) { + layero.addClass('split-pixel-layer'); + var $box = layero.find('.split-pixel-modal'); + var fillRows = function (platform, rows) { + var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]'); + var $body = $list.find('tbody.split-pixel-list-body'); + $body.find('tr.split-pixel-row').remove(); + $.each(rows, function (_, rowData) { + $body.append(Controller.api.buildPixelRowHtml(platform, rowData, eventOptions)); + }); + Controller.api.syncPixelListEmpty($list); + }; + fillRows('facebook', fbRows); + fillRows('tiktok', tkRows); + + $box.on('click', '.btn-add-pixel-row', function () { + var platform = $(this).data('platform'); + var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]'); + var $body = $list.find('tbody.split-pixel-list-body'); + var sortVal = $body.find('tr.split-pixel-row').length; + $body.append(Controller.api.buildPixelRowHtml(platform, { + id: Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'), + enabled: 1, + server_postback: 0, + pixel_id: '', + access_token: '', + test_code: '', + event: 'PageView', + sort: sortVal + }, eventOptions)); + Controller.api.syncPixelListEmpty($list); + var $wrap = $list.find('.split-pixel-table-wrap'); + if ($wrap.length) { + $wrap.scrollTop($wrap[0].scrollHeight); + } + }); + + $box.on('click', '.btn-pixel-sort-up', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $input = $row.find('.pixel-sort-input'); + $input.val(Math.max(0, (parseInt($input.val(), 10) || 0) + 1)); + }); + $box.on('click', '.btn-pixel-sort-down', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $input = $row.find('.pixel-sort-input'); + $input.val(Math.max(0, (parseInt($input.val(), 10) || 0) - 1)); + }); + $box.on('click', '.btn-pixel-row-remove', function () { + var $row = $(this).closest('tr.split-pixel-row'); + var $list = $row.closest('.split-pixel-list'); + $row.remove(); + Controller.api.syncPixelListEmpty($list); + }); + $box.on('change', '.pixel-event', function () { + var val = $(this).val() || ''; + $(this).attr('title', val); + }); + }, + yes: function (index, layero) { + var payload = Controller.api.collectPixelPayload(layero.find('.split-pixel-modal')); + var invalid = false; + $.each(payload.facebook.concat(payload.tiktok), function (_, item) { + if (!$.trim(item.pixel_id)) { + invalid = true; + return false; + } + }); + if (invalid) { + Toastr.error(__('Pixel ID required')); + return false; + } + Fast.api.ajax({ + url: 'split.link/pixel/ids/' + linkId, + type: 'post', + data: {pixel_config: payload} + }, function () { + Layer.close(index); + Toastr.success(__('Pixel config saved')); + }); + return false; + } + }); + }, + buildPixelListShell: function (platform, addBtnText) { + return [ + '
', + '
', + ' ', + '
', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
' + __('Pixel row index') + '' + __('Pixel enabled') + '' + __('Server postback') + '' + __('Pixel ID') + ' *' + __('Pixel event') + '' + __('Access Token') + '' + __('Test code') + '' + __('Pixel sort') + '' + __('Operate') + '
' + __('Pixel list empty') + '
', + '
', + '
' + ].join(''); + }, + syncPixelListEmpty: function ($list) { + var $body = $list.find('tbody.split-pixel-list-body'); + var $rows = $body.find('tr.split-pixel-row'); + $body.find('tr.split-pixel-empty-row').toggle($rows.length === 0); + $rows.each(function (idx) { + $(this).find('.pixel-row-index').text(String(idx + 1)); + }); + }, + buildPixelRowHtml: function (platform, row, eventOptions) { + row = row || {}; + var id = row.id || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'); + var enabled = parseInt(row.enabled, 10) === 1; + var serverPostback = parseInt(row.server_postback, 10) === 1; + var eventOpts = ''; + $.each(eventOptions, function (_, ev) { + eventOpts += ''; + }); + var tokenPlaceholder = __('Optional'); + var tokenHint = row.has_access_token ? ' placeholder="' + tokenPlaceholder + ' (' + __('Optional') + ')"' : ' placeholder="' + tokenPlaceholder + '"'; + var selectedEvent = row.event || 'PageView'; + + return [ + '', + ' -', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + ' ', + '' + ].join(''); + }, + collectPixelPayload: function ($box) { + var readList = function (platform) { + var rows = []; + $box.find('.split-pixel-list[data-platform="' + platform + '"] tbody .split-pixel-row').each(function () { + var $row = $(this); + rows.push({ + id: $row.attr('data-row-id') || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'), + enabled: $row.find('.pixel-enabled').prop('checked') ? 1 : 0, + server_postback: $row.find('.pixel-server-postback').prop('checked') ? 1 : 0, + pixel_id: $.trim($row.find('.pixel-id').val()), + access_token: $.trim($row.find('.pixel-access-token').val()), + test_code: $.trim($row.find('.pixel-test-code').val()), + event: $row.find('.pixel-event').val() || 'PageView', + sort: Math.max(0, parseInt($row.find('.pixel-sort-input').val(), 10) || 0) + }); + }); + return rows; + }; + return { + facebook: readList('facebook'), + tiktok: readList('tiktok') + }; + }, loadCopyModalData: function (callback) { Fast.api.ajax({ url: 'split.link/copyinfo', @@ -248,7 +610,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); } var myDomains = $.isArray(data.my_domains) ? data.my_domains : []; - var domainIndexUrl = data.domain_index_url || 'domain/index'; + var domainIndexUrl = Controller.api.normalizeAdminRouteUrl(data.domain_index_url, 'domain'); + var domainAddUrl = Controller.api.normalizeAdminRouteUrl(data.domain_add_url, 'domain/add'); var configIndexUrl = data.config_index_url || 'general/config/index'; var defaultType = platformDomains.length ? 'platform' : 'my'; var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : ''); @@ -383,10 +746,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.copyText(linkCode); }); - $box.on('click', '.btn-manage-domain, .btn-go-add-domain', function (e) { + $box.on('click', '.btn-manage-domain', function (e) { e.preventDefault(); Layer.close(index); - Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Manage my domains'), 'fa fa-globe'); + Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Domain management'), 'fa fa-globe'); + }); + + $box.on('click', '.btn-go-add-domain', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(domainAddUrl), __('Domain management'), 'fa fa-plus'); }); $box.on('click', '.btn-goto-config', function (e) { @@ -431,8 +800,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin var statusHtml = ''; if (options.showStatus) { statusHtml = '' - + 'NS:' + Fast.api.escape(options.nsText || '-') + '' - + 'DNS:' + Fast.api.escape(options.dnsText || '-') + '' + + '' + __('NS') + ':' + Fast.api.escape(options.nsText || '-') + '' + + '' + __('DNS') + ':' + Fast.api.escape(options.dnsText || '-') + '' + ''; } return '
'