前端分流页功能

This commit is contained in:
root
2026-06-05 04:22:29 +08:00
parent 8afe25a960
commit 34d76cce74
120 changed files with 10782 additions and 284 deletions
BIN
View File
Binary file not shown.
@@ -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;
+55 -2
View File
@@ -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,
]);
}
/**
* 编辑(分流链接码不可修改)
*
@@ -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 TokenToken 留空会保留原配置。',
'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' => '操作',
];
+2 -6
View File
@@ -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
+7
View File
@@ -507,3 +507,10 @@ EOT;
return $icon;
}
}
if (!function_exists('raw')) {
function raw($str)
{
return $str;
}
}
@@ -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') . '...';
}
/**
* 解析为多行文本数组
*
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 按号码类型拼接各平台加好友链接
*/
class SplitFriendUrlBuilder
{
/**
* 构建跳转 URL;无法构建时返回空字符串
*/
public static function build(string $numberType, string $number, string $numberTypeCustom = ''): string
{
$number = trim($number);
if ($number === '') {
return '';
}
switch ($numberType) {
case 'whatsapp':
return self::buildWhatsApp($number);
case 'telegram':
return self::buildTelegram($number);
case 'line':
return self::buildLine($number);
case 'custom':
return self::buildCustom($number, $numberTypeCustom);
default:
return '';
}
}
/**
* WhatsApphttps://api.whatsapp.com/send?phone= 仅数字
*/
private static function buildWhatsApp(string $number): string
{
$digits = preg_replace('/\D+/', '', $number) ?? '';
if ($digits === '') {
return '';
}
return 'https://api.whatsapp.com/send?phone=' . $digits;
}
/**
* Telegramhttps://t.me/+ 号码(去掉前导 +
*/
private static function buildTelegram(string $number): string
{
$id = ltrim($number, '+');
if ($id === '') {
return '';
}
return 'https://t.me/+' . $id;
}
/**
* Linehttps://line.me/ti/p/~ 拼接号码
*/
private static function buildLine(string $number): string
{
return 'https://line.me/ti/p/~' . $number;
}
/**
* 自定义:号码字段即为完整链接(仅允许 http/https
*/
private static function buildCustom(string $number, string $numberTypeCustom): string
{
$url = trim($number);
if ($url === '' && trim($numberTypeCustom) !== '') {
$url = trim($numberTypeCustom);
}
if ($url === '') {
return '';
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return '';
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
return $url;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
/**
* 基于 GeoLite2-Country.mmdb 的 IP 国家查询(MaxMind GeoIP2
*/
class SplitGeoIpService
{
/** 项目根目录下的 MaxMind 国家库文件名 */
private const DB_FILENAME = 'GeoLite2-Country.mmdb';
private static ?Reader $reader = null;
/**
* 解析 IP 对应 ISO 3166-1 alpha-2 国家代码;无法解析时返回 null
*/
public static function getCountryIso2(string $ip): ?string
{
$ip = trim($ip);
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return null;
}
self::bootstrapLibrary();
try {
$record = self::getReader()->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;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
*/
class SplitIpProtectService
{
/**
* 是否允许继续轮转跳转
*
* @param int|string $ipProtect 链接 ip_protect0=关闭,1=开启
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
* @param string $clientIp 访客 IP
*/
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
{
if ((int) $ipProtect !== 1) {
return true;
}
$allowed = self::parseAllowedCountries($countriesStorage);
if ($allowed === []) {
return false;
}
if (!SplitGeoIpService::isDatabaseAvailable()) {
return false;
}
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
if ($visitorCountry === null || $visitorCountry === '') {
return false;
}
return in_array($visitorCountry, $allowed, true);
}
/**
* @return array<int, string> 大写 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;
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 中转页浏览器 Pixel 脚本渲染(Facebook / TikTok
*/
class SplitPixelBrowserRenderer
{
/**
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array{
* head_html: string,
* body_html: string,
* track_lines: array<int, string>,
* track_jobs: array<int, array<string, mixed>>
* }
*/
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<int, string> $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'
<script type="text/javascript">
(function () {
var url = {$redirectUrlJson};
if (!url) {
return;
}
setTimeout(function () {
{$trackBlock}
window.location.replace(url);
}, {$maxWaitMs});
})();
</script>
JS;
}
/**
* @param array<int, string> $lines
*/
private static function wrapScriptBlock(array $lines): string
{
if ($lines === []) {
return '';
}
return '<script type="text/javascript">' . "\n"
. implode("\n", $lines) . "\n"
. '</script>';
}
private static function facebookLoaderScript(): string
{
return <<<'HTML'
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
</script>
HTML;
}
private static function tiktokLoaderScript(): string
{
return <<<'HTML'
<script>
!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)}}(window,document,'ttq');
</script>
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);
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接像素配置:解析、校验、合并保存、脱敏
*/
class SplitPixelConfigService
{
public const PLATFORM_FACEBOOK = 'facebook';
public const PLATFORM_TIKTOK = 'tiktok';
/** @var string[] */
public const EVENT_OPTIONS = [
'PageView',
'Lead',
'Contact',
'AddToCart',
'Purchase',
'Subscribe',
];
private const MAX_ITEMS_PER_PLATFORM = 20;
/**
* 解析存储 JSON 为规范结构
*
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<string, mixed> $incoming
* @param array<string, mixed> $existing
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $row
* @return array<string, mixed>
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array<string, array<string, mixed>>
*/
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;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Log;
/**
* 分流中转页服务端像素回传(Facebook CAPI / TikTok Events API
*/
class SplitPixelPostbackService
{
/**
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
* @param array<int, string> $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));
}
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use app\admin\model\split\Number;
use think\Collection;
/**
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
*/
class SplitRedirectService
{
private SplitRoundRobinStore $roundRobinStore;
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
{
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
}
/**
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
*
* @param string $linkCode 9 位链接码
* @param string $clientIp 访客 IPIP 防护开启时用于国家校验)
*/
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<int, Number>|array<int, Number> $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;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Config;
/**
* 分流链接号码严格轮转计数(Redis INCR,不可用时降级为 runtime 文件锁)
*/
class SplitRoundRobinStore
{
private const KEY_PREFIX = 'split:rr:';
/** @var \Redis|null */
private $redis = null;
private bool $redisReady = false;
private bool $redisAttempted = false;
/**
* 获取本次访问应使用的号码下标(0 .. count-1
*/
public function nextIndex(int $splitLinkId, int $numberCount): int
{
if ($splitLinkId <= 0 || $numberCount <= 0) {
return 0;
}
if ($this->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;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
/**
* 分流中转页上下文:跳转 URL + 像素渲染
*/
class SplitVisitPageService
{
/**
* @return array{
* redirect_url: string,
* redirect_url_json: string,
* pixel_head_html: string,
* pixel_body_html: string,
* orchestrator_html: string
* }|null
*/
public static function build(string $linkCode, string $clientIp, string $userAgent, string $pageUrl): ?array
{
$redirectUrl = (new SplitRedirectService())->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'] ?? []
),
];
}
}
+20 -9
View File
@@ -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, // 超时时间
],
// +----------------------------------------------------------------------
// | 会话设置
// +----------------------------------------------------------------------
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
/**
* 短链入口:/s/{link_code} 由 ThinkPHP 解析为 controller=S、action={link_code},经 _empty 转 visit
* (部署 application/route.php 后亦可显式路由到 Split/visit
*/
class S extends Split
{
/**
* @param string $link_code 动作名即为 9 位链接码
*/
public function _empty(string $link_code = ''): string
{
return $this->visit($link_code);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
use app\common\controller\Frontend;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitVisitPageService;
use think\exception\HttpException;
use think\exception\HttpResponseException;
/**
* 分流链接公开落地页(/s/{link_code}
*/
class Split extends Frontend
{
private const PATCH_VIEW_DIR = 'patches/application/index/view/split/';
/** @var string[] */
protected $noNeedLogin = ['visit'];
/** @var string[] */
protected $noNeedRight = ['visit'];
protected $layout = '';
/**
* 访问分流短链:解析号码并输出 JS 跳转页
*
* @param string $link_code 路由参数:9 位小写字母链接码
*/
public function visit(string $link_code = ''): string
{
$code = SplitLinkCodeService::normalize($link_code);
if (!SplitLinkCodeService::isValidFormat($code)) {
$this->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));
}
}
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404</title>
</head>
<body></body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title></title>
{$pixel_head_html|raw}
</head>
<body>
{$pixel_body_html|raw}
{$orchestrator_html|raw}
<noscript>
<meta http-equiv="refresh" content="0;url={$redirect_url|htmlentities}">
</noscript>
</body>
</html>
+3
View File
@@ -11,11 +11,14 @@
// +----------------------------------------------------------------------
return [
// 分流链接公开落地页:/s/{9位链接码}
's/:link_code' => 'index/Split/visit',
//别名配置,别名只能是映射到控制器且访问时必须加上请求的方法
'__alias__' => [
],
//变量规则
'__pattern__' => [
'link_code' => '[a-z]{9}',
],
// 域名绑定到模块
// '__domain__' => [
@@ -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;
@@ -0,0 +1,4 @@
-- 移除分流模块下重复的「管理我的域名」菜单(统一使用顶级「域名管理」)
SET NAMES utf8mb4;
DELETE FROM `fa_auth_rule` WHERE `name` LIKE 'split.mydomain%';
@@ -1,247 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\admin\model\Domain as DomainModel;
use app\common\controller\Backend;
use app\common\service\CloudflareService;
use app\common\service\DomainDetectRateLimitService;
use think\Db;
use think\Exception;
use think\exception\PDOException;
use think\exception\ValidateException;
/**
* 域名管理
*
* @icon fa fa-globe
* @remark 域名接入Cloudflare,新增后请在详情查看NS并到注册商修改
*/
class Domain extends Backend
{
/** @var DomainModel */
protected $model = null;
protected $searchFields = 'domain';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
public function _initialize()
{
parent::_initialize();
$this->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);
}
}
@@ -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,
]);
}
/**
* 编辑(分流链接码不可修改)
*
@@ -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 TokenToken 留空会保留原配置。',
'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' => '操作',
];
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 按号码类型拼接各平台加好友链接
*/
class SplitFriendUrlBuilder
{
/**
* 构建跳转 URL;无法构建时返回空字符串
*/
public static function build(string $numberType, string $number, string $numberTypeCustom = ''): string
{
$number = trim($number);
if ($number === '') {
return '';
}
switch ($numberType) {
case 'whatsapp':
return self::buildWhatsApp($number);
case 'telegram':
return self::buildTelegram($number);
case 'line':
return self::buildLine($number);
case 'custom':
return self::buildCustom($number, $numberTypeCustom);
default:
return '';
}
}
/**
* WhatsApphttps://api.whatsapp.com/send?phone= 仅数字
*/
private static function buildWhatsApp(string $number): string
{
$digits = preg_replace('/\D+/', '', $number) ?? '';
if ($digits === '') {
return '';
}
return 'https://api.whatsapp.com/send?phone=' . $digits;
}
/**
* Telegramhttps://t.me/+ 号码(去掉前导 +
*/
private static function buildTelegram(string $number): string
{
$id = ltrim($number, '+');
if ($id === '') {
return '';
}
return 'https://t.me/+' . $id;
}
/**
* Linehttps://line.me/ti/p/~ 拼接号码
*/
private static function buildLine(string $number): string
{
return 'https://line.me/ti/p/~' . $number;
}
/**
* 自定义:号码字段即为完整链接(仅允许 http/https
*/
private static function buildCustom(string $number, string $numberTypeCustom): string
{
$url = trim($number);
if ($url === '' && trim($numberTypeCustom) !== '') {
$url = trim($numberTypeCustom);
}
if ($url === '') {
return '';
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return '';
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
return $url;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
/**
* 基于 GeoLite2-Country.mmdb 的 IP 国家查询(MaxMind GeoIP2
*/
class SplitGeoIpService
{
/** 项目根目录下的 MaxMind 国家库文件名 */
private const DB_FILENAME = 'GeoLite2-Country.mmdb';
private static ?Reader $reader = null;
/**
* 解析 IP 对应 ISO 3166-1 alpha-2 国家代码;无法解析时返回 null
*/
public static function getCountryIso2(string $ip): ?string
{
$ip = trim($ip);
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return null;
}
self::bootstrapLibrary();
try {
$record = self::getReader()->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;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
*/
class SplitIpProtectService
{
/**
* 是否允许继续轮转跳转
*
* @param int|string $ipProtect 链接 ip_protect0=关闭,1=开启
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
* @param string $clientIp 访客 IP
*/
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
{
if ((int) $ipProtect !== 1) {
return true;
}
$allowed = self::parseAllowedCountries($countriesStorage);
if ($allowed === []) {
return false;
}
if (!SplitGeoIpService::isDatabaseAvailable()) {
return false;
}
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
if ($visitorCountry === null || $visitorCountry === '') {
return false;
}
return in_array($visitorCountry, $allowed, true);
}
/**
* @return array<int, string> 大写 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;
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 中转页浏览器 Pixel 脚本渲染(Facebook / TikTok
*/
class SplitPixelBrowserRenderer
{
/**
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array{
* head_html: string,
* body_html: string,
* track_lines: array<int, string>,
* track_jobs: array<int, array<string, mixed>>
* }
*/
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<int, string> $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'
<script type="text/javascript">
(function () {
var url = {$redirectUrlJson};
if (!url) {
return;
}
setTimeout(function () {
{$trackBlock}
window.location.replace(url);
}, {$maxWaitMs});
})();
</script>
JS;
}
/**
* @param array<int, string> $lines
*/
private static function wrapScriptBlock(array $lines): string
{
if ($lines === []) {
return '';
}
return '<script type="text/javascript">' . "\n"
. implode("\n", $lines) . "\n"
. '</script>';
}
private static function facebookLoaderScript(): string
{
return <<<'HTML'
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
</script>
HTML;
}
private static function tiktokLoaderScript(): string
{
return <<<'HTML'
<script>
!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)}}(window,document,'ttq');
</script>
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);
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接像素配置:解析、校验、合并保存、脱敏
*/
class SplitPixelConfigService
{
public const PLATFORM_FACEBOOK = 'facebook';
public const PLATFORM_TIKTOK = 'tiktok';
/** @var string[] */
public const EVENT_OPTIONS = [
'PageView',
'Lead',
'Contact',
'AddToCart',
'Purchase',
'Subscribe',
];
private const MAX_ITEMS_PER_PLATFORM = 20;
/**
* 解析存储 JSON 为规范结构
*
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<string, mixed> $incoming
* @param array<string, mixed> $existing
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $row
* @return array<string, mixed>
*/
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<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
* @return array<string, array<string, mixed>>
*/
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;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Log;
/**
* 分流中转页服务端像素回传(Facebook CAPI / TikTok Events API
*/
class SplitPixelPostbackService
{
/**
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
* @param array<int, string> $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));
}
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use app\admin\model\split\Number;
use think\Collection;
/**
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
*/
class SplitRedirectService
{
private SplitRoundRobinStore $roundRobinStore;
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
{
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
}
/**
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
*
* @param string $linkCode 9 位链接码
* @param string $clientIp 访客 IPIP 防护开启时用于国家校验)
*/
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<int, Number>|array<int, Number> $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;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Config;
/**
* 分流链接号码严格轮转计数(Redis INCR,不可用时降级为 runtime 文件锁)
*/
class SplitRoundRobinStore
{
private const KEY_PREFIX = 'split:rr:';
/** @var \Redis|null */
private $redis = null;
private bool $redisReady = false;
private bool $redisAttempted = false;
/**
* 获取本次访问应使用的号码下标(0 .. count-1
*/
public function nextIndex(int $splitLinkId, int $numberCount): int
{
if ($splitLinkId <= 0 || $numberCount <= 0) {
return 0;
}
if ($this->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;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
/**
* 分流中转页上下文:跳转 URL + 像素渲染
*/
class SplitVisitPageService
{
/**
* @return array{
* redirect_url: string,
* redirect_url_json: string,
* pixel_head_html: string,
* pixel_body_html: string,
* orchestrator_html: string
* }|null
*/
public static function build(string $linkCode, string $clientIp, string $userAgent, string $pageUrl): ?array
{
$redirectUrl = (new SplitRedirectService())->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'] ?? []
),
];
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
/**
* 短链入口:/s/{link_code} ThinkPHP 解析为 controller=S、action={link_code},经 _empty visit
* (部署 application/route.php 后亦可显式路由到 Split/visit
*/
class S extends Split
{
/**
* @param string $link_code 动作名即为 9 位链接码
*/
public function _empty(string $link_code = ''): string
{
return $this->visit($link_code);
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
use app\common\controller\Frontend;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitVisitPageService;
use think\exception\HttpException;
use think\exception\HttpResponseException;
/**
* 分流链接公开落地页(/s/{link_code}
*/
class Split extends Frontend
{
private const PATCH_VIEW_DIR = 'patches/application/index/view/split/';
/** @var string[] */
protected $noNeedLogin = ['visit'];
/** @var string[] */
protected $noNeedRight = ['visit'];
protected $layout = '';
/**
* 访问分流短链:解析号码并输出 JS 跳转页
*
* @param string $link_code 路由参数:9 位小写字母链接码
*/
public function visit(string $link_code = ''): string
{
$code = SplitLinkCodeService::normalize($link_code);
if (!SplitLinkCodeService::isValidFormat($code)) {
$this->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));
}
}
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404</title>
</head>
<body></body>
</html>
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title></title>
{$pixel_head_html|raw}
</head>
<body>
{$pixel_body_html|raw}
{$orchestrator_html|raw}
<noscript>
<meta http-equiv="refresh" content="0;url={$redirect_url|htmlentities}">
</noscript>
</body>
</html>
@@ -0,0 +1,9 @@
<?php
/**
* 分流落地页路由片段:部署时合并进 application/route.php
* 规则:'s/:link_code' => 'index/Split/visit'pattern link_code => [a-z]{9}
*/
return [
's/:link_code' => 'index/Split/visit',
];
+147
View File
@@ -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;
});
+337 -3
View File
@@ -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('');
$('<style id="split-link-copy-modal-style" type="text/css"></style>').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('');
$('<style id="split-pixel-modal-style" type="text/css"></style>').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 = [
'<div class="split-pixel-modal">',
' <div class="split-pixel-tip">' + __('Pixel config tip') + '</div>',
' <ul class="nav nav-tabs split-pixel-tabs" role="tablist">',
' <li role="presentation" class="active"><a href="#split-pixel-fb" role="tab" data-toggle="tab"><i class="fa fa-facebook-square"></i> ' + __('Facebook Pixel') + '</a></li>',
' <li role="presentation"><a href="#split-pixel-tk" role="tab" data-toggle="tab"><i class="fa fa-music"></i> ' + __('TikTok Pixel') + '</a></li>',
' </ul>',
' <div class="tab-content split-pixel-tab-content">',
' <div role="tabpanel" class="tab-pane active" id="split-pixel-fb">',
Controller.api.buildPixelListShell('facebook', __('Add FB Pixel')),
' </div>',
' <div role="tabpanel" class="tab-pane" id="split-pixel-tk">',
Controller.api.buildPixelListShell('tiktok', __('Add TK Pixel')),
' </div>',
' </div>',
'</div>'
].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 [
'<div class="split-pixel-list" data-platform="' + platform + '">',
' <div class="split-pixel-toolbar">',
' <button type="button" class="btn btn-success btn-sm btn-add-pixel-row" data-platform="' + platform + '">',
' <i class="fa fa-plus"></i> ' + addBtnText,
' </button>',
' </div>',
' <div class="table-responsive split-pixel-table-wrap">',
' <table class="table table-bordered table-hover split-pixel-table">',
' <thead>',
' <tr>',
' <th width="52">' + __('Pixel row index') + '</th>',
' <th width="58" title="' + __('Pixel enabled') + '">' + __('Pixel enabled') + '</th>',
' <th width="78" title="' + __('Server postback') + '">' + __('Server postback') + '</th>',
' <th width="140">' + __('Pixel ID') + ' <span class="text-danger">*</span></th>',
' <th class="pixel-event-col">' + __('Pixel event') + '</th>',
' <th class="col-token">' + __('Access Token') + '</th>',
' <th width="108">' + __('Test code') + '</th>',
' <th width="124">' + __('Pixel sort') + '</th>',
' <th width="58">' + __('Operate') + '</th>',
' </tr>',
' </thead>',
' <tbody class="split-pixel-list-body">',
' <tr class="split-pixel-empty-row"><td colspan="9"><i class="fa fa-inbox" style="margin-right:6px;opacity:.5;"></i>' + __('Pixel list empty') + '</td></tr>',
' </tbody>',
' </table>',
' </div>',
'</div>'
].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 += '<option value="' + ev + '"' + (row.event === ev ? ' selected' : '') + '>' + ev + '</option>';
});
var tokenPlaceholder = __('Optional');
var tokenHint = row.has_access_token ? ' placeholder="' + tokenPlaceholder + ' (' + __('Optional') + ')"' : ' placeholder="' + tokenPlaceholder + '"';
var selectedEvent = row.event || 'PageView';
return [
'<tr class="split-pixel-row" data-row-id="' + Fast.api.escape(id) + '">',
' <td class="text-center"><span class="pixel-row-index">-</span></td>',
' <td class="pixel-switch-wrap"><input type="checkbox" class="pixel-enabled" title="' + __('Pixel enabled') + '" ' + (enabled ? 'checked' : '') + '></td>',
' <td class="pixel-switch-wrap"><input type="checkbox" class="pixel-server-postback" title="' + __('Server postback') + '" ' + (serverPostback ? 'checked' : '') + '></td>',
' <td><input type="text" class="form-control input-sm pixel-id" value="' + Fast.api.escape(row.pixel_id || '') + '" placeholder="' + __('Pixel ID') + '"></td>',
' <td class="pixel-event-cell"><select class="form-control input-sm pixel-event" title="' + Fast.api.escape(selectedEvent) + '">' + eventOpts + '</select></td>',
' <td class="col-token"><input type="text" class="form-control input-sm pixel-access-token" value=""' + tokenHint + '></td>',
' <td><input type="text" class="form-control input-sm pixel-test-code" value="' + Fast.api.escape(row.test_code || '') + '" placeholder="' + tokenPlaceholder + '"></td>',
' <td>',
' <div class="input-group input-group-sm split-pixel-sort-group">',
' <span class="input-group-btn"><button type="button" class="btn btn-default btn-pixel-sort-down" title="-1"><i class="fa fa-minus"></i></button></span>',
' <input type="number" min="0" class="form-control pixel-sort-input" value="' + (parseInt(row.sort, 10) || 0) + '" title="' + __('Pixel sort') + '">',
' <span class="input-group-btn"><button type="button" class="btn btn-default btn-pixel-sort-up" title="+1"><i class="fa fa-plus"></i></button></span>',
' </div>',
' </td>',
' <td class="text-center">',
' <button type="button" class="btn btn-danger btn-xs btn-pixel-row-remove" title="' + __('Remove row') + '"><i class="fa fa-trash"></i></button>',
' </td>',
'</tr>'
].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) {
+1
View File
@@ -0,0 +1 @@
9
+1
View File
@@ -0,0 +1 @@
4
+323
View File
@@ -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.
+202
View File
@@ -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.
+442
View File
@@ -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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-City.mmdb');
// Replace "city" with the appropriate method for your database, e.g.,
// "country".
$record = $reader->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Anonymous-IP.mmdb');
$record = $reader->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Connection-Type.mmdb');
$record = $reader->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Domain.mmdb');
$record = $reader->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Enterprise.mmdb');
// Use the ->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-ISP.mmdb');
$record = $reader->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
<?php
require_once 'vendor/autoload.php';
use GeoIp2\WebService\Client;
// This creates a Client object that can be reused across requests.
// Replace "42" with your account ID and "license_key" with your license
// key. Set the "host" to "geolite.info" in the fourth argument options
// array to use the GeoLite2 web service instead of the GeoIP2 web
// service.
$client = new Client(42, 'abcdef123456');
// Replace "city" with the method corresponding to the web service that
// you are using, e.g., "country", "insights".
$record = $client->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.
+32
View File
@@ -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"
}
}
}
@@ -0,0 +1,26 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use GeoIp2\Database\Reader;
srand(0);
$reader = new Reader('GeoIP2-City.mmdb');
$count = 500000;
$startTime = microtime(true);
for ($i = 0; $i < $count; ++$i) {
$ip = long2ip(rand(0, 2 ** 32 - 1));
try {
$t = $reader->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";
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Database;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\AbstractModel;
use GeoIp2\Model\AnonymousIp;
use GeoIp2\Model\Asn;
use GeoIp2\Model\City;
use GeoIp2\Model\ConnectionType;
use GeoIp2\Model\Country;
use GeoIp2\Model\Domain;
use GeoIp2\Model\Enterprise;
use GeoIp2\Model\Isp;
use GeoIp2\ProviderInterface;
use MaxMind\Db\Reader as DbReader;
use MaxMind\Db\Reader\InvalidDatabaseException;
/**
* Instances of this class provide a reader for the GeoIP2 database format.
* IP addresses can be looked up using the database specific methods.
*
* ## Usage ##
*
* The basic API for this class is the same for every database. First, you
* create a reader object, specifying a file name. You then call the method
* corresponding to the specific database, passing it the IP address you want
* to look up.
*
* If the request succeeds, the method call will return a model class for
* the method you called. This model in turn contains multiple record classes,
* each of which represents part of the data returned by the database. If
* the database does not contain the requested information, the attributes
* on the record class will have a `null` value.
*
* If the address is not in the database, an
* {@link \GeoIp2\Exception\AddressNotFoundException} exception will be
* thrown. If an invalid IP address is passed to one of the methods, a
* SPL {@link \InvalidArgumentException} will be thrown. If the database is
* corrupt or invalid, a {@link \MaxMind\Db\Reader\InvalidDatabaseException}
* will be thrown.
*/
class Reader implements ProviderInterface
{
/**
* @var DbReader
*/
private $dbReader;
/**
* @var string
*/
private $dbType;
/**
* @var array<string>
*/
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();
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AddressNotFoundException extends GeoIp2Exception
{
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AuthenticationException extends GeoIp2Exception
{
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class GeoIp2Exception extends \Exception
{
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents an HTTP transport error.
*/
class HttpException extends GeoIp2Exception
{
/**
* The URI queried.
*
* @var string
*/
public $uri;
public function __construct(
string $message,
int $httpStatus,
string $uri,
\Exception $previous = null
) {
$this->uri = $uri;
parent::__construct($message, $httpStatus, $previous);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents an error returned by MaxMind's GeoIP2
* web service.
*/
class InvalidRequestException extends HttpException
{
/**
* The code returned by the MaxMind web service.
*
* @var string
*/
public $error;
public function __construct(
string $message,
string $error,
int $httpStatus,
string $uri,
\Exception $previous = null
) {
$this->error = $error;
parent::__construct($message, $httpStatus, $uri, $previous);
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class OutOfQueriesException extends GeoIp2Exception
{
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* @ignore
*/
abstract class AbstractModel implements \JsonSerializable
{
/**
* @var array<string, mixed>
*/
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;
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Anonymous IP model.
*
* @property-read bool $isAnonymous This is true if the IP address belongs to
* any sort of anonymous network.
* @property-read bool $isAnonymousVpn This is true if the IP address is
* registered to an anonymous VPN provider. If a VPN provider does not
* register subnets under names associated with them, we will likely only
* flag their IP ranges using the isHostingProvider property.
* @property-read bool $isHostingProvider This is true if the IP address belongs
* to a hosting or VPN provider (see description of isAnonymousVpn property).
* @property-read bool $isPublicProxy This is true if the IP address belongs to
* a public proxy.
* @property-read bool $isResidentialProxy This is true if the IP address is
* on a suspected anonymizing network and belongs to a residential ISP.
* @property-read bool $isTorExitNode This is true if the IP address is a Tor
* exit node.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class AnonymousIp extends AbstractModel
{
/**
* @var bool
*/
protected $isAnonymous;
/**
* @var bool
*/
protected $isAnonymousVpn;
/**
* @var bool
*/
protected $isHostingProvider;
/**
* @var bool
*/
protected $isPublicProxy;
/**
* @var bool
*/
protected $isResidentialProxy;
/**
* @var bool
*/
protected $isTorExitNode;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->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'));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoLite2 ASN model.
*
* @property-read int|null $autonomousSystemNumber The autonomous system number
* associated with the IP address.
* @property-read string|null $autonomousSystemOrganization The organization
* associated with the registered autonomous system number for the IP
* address.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Asn extends AbstractModel
{
/**
* @var int|null
*/
protected $autonomousSystemNumber;
/**
* @var string|null
*/
protected $autonomousSystemOrganization;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->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'));
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by City Plus web service and City
* database.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more
* details.
*
* @property-read \GeoIp2\Record\City $city City data for the requested IP
* address.
* @property-read \GeoIp2\Record\Location $location Location data for the
* requested IP address.
* @property-read \GeoIp2\Record\Postal $postal Postal data for the
* requested IP address.
* @property-read array $subdivisions An array \GeoIp2\Record\Subdivision
* objects representing the country subdivisions for the requested IP
* address. The number and type of subdivisions varies by country, but a
* subdivision is typically a state, province, county, etc. Subdivisions
* are ordered from most general (largest) to most specific (smallest).
* If the response did not contain any subdivisions, this method returns
* an empty array.
* @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An object
* representing the most specific subdivision returned. If the response
* did not contain any subdivisions, this method returns an empty
* \GeoIp2\Record\Subdivision object.
*/
class City extends Country
{
/**
* @ignore
*
* @var \GeoIp2\Record\City
*/
protected $city;
/**
* @ignore
*
* @var \GeoIp2\Record\Location
*/
protected $location;
/**
* @ignore
*
* @var \GeoIp2\Record\Postal
*/
protected $postal;
/**
* @ignore
*
* @var array<\GeoIp2\Record\Subdivision>
*/
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);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Connection-Type model.
*
* @property-read string|null $connectionType The connection type may take the
* following values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
* Additional values may be added in the future.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class ConnectionType extends AbstractModel
{
/**
* @var string|null
*/
protected $connectionType;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->connectionType = $this->get('connection_type');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Country web service and database.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more details.
*
* @property-read \GeoIp2\Record\Continent $continent Continent data for the
* requested IP address.
* @property-read \GeoIp2\Record\Country $country Country data for the requested
* IP address. This object represents the country where MaxMind believes the
* end user is located.
* @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
* account.
* @property-read \GeoIp2\Record\Country $registeredCountry Registered country
* data for the requested IP address. This record represents the country
* where the ISP has registered a given IP block and may differ from the
* user's country.
* @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
* Represented country data for the requested IP address. The represented
* country is used for things like military bases. It is only present when
* the represented country differs from the country.
* @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
* requested IP address.
* @property-read array $raw The raw data from the web service.
*/
class Country extends AbstractModel
{
/**
* @var \GeoIp2\Record\Continent
*/
protected $continent;
/**
* @var \GeoIp2\Record\Country
*/
protected $country;
/**
* @var array<string>
*/
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;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Domain model.
*
* @property-read string|null $domain The second level domain associated with the
* IP address. This will be something like "example.com" or
* "example.co.uk", not "foo.example.com".
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Domain extends AbstractModel
{
/**
* @var string|null
*/
protected $domain;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->domain = $this->get('domain');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Enterprise database lookups.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more
* details.
*/
class Enterprise extends City
{
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Insights web service.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for
* more details.
*/
class Insights extends City
{
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 ISP model.
*
* @property-read int|null $autonomousSystemNumber The autonomous system number
* associated with the IP address.
* @property-read string|null $autonomousSystemOrganization The organization
* associated with the registered autonomous system number for the IP
* address.
* @property-read string|null $isp The name of the ISP associated with the IP
* address.
* @property-read string|null $mobileCountryCode The [mobile country code
* (MCC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP.
* @property-read string|null $mobileNetworkCode The [mobile network code
* (MNC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP.
* @property-read string|null $organization The name of the organization associated
* with the IP address.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Isp extends AbstractModel
{
/**
* @var int|null
*/
protected $autonomousSystemNumber;
/**
* @var string|null
*/
protected $autonomousSystemOrganization;
/**
* @var string|null
*/
protected $isp;
/**
* @var string|null
*/
protected $mobileCountryCode;
/**
* @var string|null
*/
protected $mobileNetworkCode;
/**
* @var string|null
*/
protected $organization;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->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'));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace GeoIp2;
interface ProviderInterface
{
/**
* @param string $ipAddress an IPv4 or IPv6 address to lookup
*
* @return \GeoIp2\Model\Country a Country model for the requested IP address
*/
public function country(string $ipAddress): Model\Country;
/**
* @param string $ipAddress an IPv4 or IPv6 address to lookup
*
* @return \GeoIp2\Model\City a City model for the requested IP address
*/
public function city(string $ipAddress): Model\City;
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
abstract class AbstractPlaceRecord extends AbstractRecord
{
/**
* @var array<string>
*/
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;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
abstract class AbstractRecord implements \JsonSerializable
{
/**
* @var array<string, mixed>
*/
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;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* City-level data associated with an IP address.
*
* This record is returned by all location services and databases besides
* Country.
*
* @property-read int|null $confidence A value from 0-100 indicating MaxMind's
* confidence that the city is correct. This attribute is only available
* from the Insights service and the GeoIP2 Enterprise database.
* @property-read int|null $geonameId The GeoName ID for the city. This attribute
* is returned by all location services and databases.
* @property-read string|null $name The name of the city based on the locales list
* passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class City extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = ['confidence', 'geonameId', 'names'];
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the continent record associated with an IP address.
*
* This record is returned by all location services and databases.
*
* @property-read string|null $code A two character continent code like "NA" (North
* America) or "OC" (Oceania). This attribute is returned by all location
* services and databases.
* @property-read int|null $geonameId The GeoName ID for the continent. This
* attribute is returned by all location services and databases.
* @property-read string|null $name Returns the name of the continent based on the
* locales list passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class Continent extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'code',
'geonameId',
'names',
];
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the country record associated with an IP address.
*
* This record is returned by all location services and databases.
*
* @property-read int|null $confidence A value from 0-100 indicating MaxMind's
* confidence that the country is correct. This attribute is only available
* from the Insights service and the GeoIP2 Enterprise database.
* @property-read int|null $geonameId The GeoName ID for the country. This
* attribute is returned by all location services and databases.
* @property-read bool $isInEuropeanUnion This is true if the country is a
* member state of the European Union. This attribute is returned by all
* location services and databases.
* @property-read string|null $isoCode The two-character ISO 3166-1 alpha code
* for the country. See https://en.wikipedia.org/wiki/ISO_3166-1. This
* attribute is returned by all location services and databases.
* @property-read string|null $name The name of the country based on the locales
* list passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class Country extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'confidence',
'geonameId',
'isInEuropeanUnion',
'isoCode',
'names',
];
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the location record associated with an IP address.
*
* This record is returned by all location services and databases besides
* Country.
*
* @property-read int|null $averageIncome The average income in US dollars
* associated with the requested IP address. This attribute is only available
* from the Insights service.
* @property-read int|null $accuracyRadius The approximate accuracy radius in
* kilometers around the latitude and longitude for the IP address. This is
* the radius where we have a 67% confidence that the device using the IP
* address resides within the circle centered at the latitude and longitude
* with the provided radius.
* @property-read float|null $latitude The approximate latitude of the location
* associated with the IP address. This value is not precise and should not be
* used to identify a particular address or household.
* @property-read float|null $longitude The approximate longitude of the location
* associated with the IP address. This value is not precise and should not be
* used to identify a particular address or household.
* @property-read int|null $populationDensity The estimated population per square
* kilometer associated with the IP address. This attribute is only available
* from the Insights service.
* @property-read int|null $metroCode The metro code of the location if the location
* is in the US. MaxMind returns the same metro codes as the
* Google AdWords API. See
* https://developers.google.com/adwords/api/docs/appendix/cities-DMAregions.
* @property-read string|null $timeZone The time zone associated with location, as
* specified by the IANA Time Zone Database, e.g., "America/New_York". See
* https://www.iana.org/time-zones.
*/
class Location extends AbstractRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'averageIncome',
'accuracyRadius',
'latitude',
'longitude',
'metroCode',
'populationDensity',
'postalCode',
'postalConfidence',
'timeZone',
];
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data about your account.
*
* This record is returned by all location services and databases.
*
* @property-read int|null $queriesRemaining The number of remaining queries you
* have for the service you are calling.
*/
class MaxMind extends AbstractRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = ['queriesRemaining'];
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the postal record associated with an IP address.
*
* This record is returned by all location databases and services besides
* Country.
*
* @property-read string|null $code The postal code of the location. Postal codes
* are not available for all countries. In some countries, this will only
* contain part of the postal code. This attribute is returned by all location
* databases and services besides Country.
* @property-read int|null $confidence A value from 0-100 indicating MaxMind's
* confidence that the postal code is correct. This attribute is only
* available from the Insights service and the GeoIP2 Enterprise
* database.
*/
class Postal extends AbstractRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = ['code', 'confidence'];
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the represented country associated with an IP address.
*
* This class contains the country-level data associated with an IP address
* for the IP's represented country. The represented country is the country
* represented by something like a military base.
*
* @property-read string|null $type A string indicating the type of entity that is
* representing the country. Currently we only return <code>military</code>
* but this could expand to include other types in the future.
*/
class RepresentedCountry extends Country
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'confidence',
'geonameId',
'isInEuropeanUnion',
'isoCode',
'names',
'type',
];
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the subdivisions associated with an IP address.
*
* This record is returned by all location databases and services besides
* Country.
*
* @property-read int|null $confidence This is a value from 0-100 indicating
* MaxMind's confidence that the subdivision is correct. This attribute is
* only available from the Insights service and the GeoIP2 Enterprise
* database.
* @property-read int|null $geonameId This is a GeoName ID for the subdivision.
* This attribute is returned by all location databases and services besides
* Country.
* @property-read string|null $isoCode This is a string up to three characters long
* contain the subdivision portion of the ISO 3166-2 code. See
* https://en.wikipedia.org/wiki/ISO_3166-2. This attribute is returned by all
* location databases and services except Country.
* @property-read string|null $name The name of the subdivision based on the
* locales list passed to the constructor. This attribute is returned by all
* location databases and services besides Country.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* databases and services besides Country.
*/
class Subdivision extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'confidence',
'geonameId',
'isoCode',
'names',
];
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
use GeoIp2\Util;
/**
* Contains data for the traits record associated with an IP address.
*
* This record is returned by all location services and databases.
*
* @property-read int|null $autonomousSystemNumber The autonomous system number
* associated with the IP address. See
* https://en.wikipedia.org/wiki/Autonomous_system_(Internet%29. This attribute
* is only available from the City Plus and Insights web services and the
* GeoIP2 Enterprise database.
* @property-read string|null $autonomousSystemOrganization The organization
* associated with the registered autonomous system number for the IP address.
* See https://en.wikipedia.org/wiki/Autonomous_system_(Internet%29. This
* attribute is only available from the City Plus and Insights web services and
* the GeoIP2 Enterprise database.
* @property-read string|null $connectionType The connection type may take the
* following values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
* Additional values may be added in the future. This attribute is only
* available in the GeoIP2 Enterprise database.
* @property-read string|null $domain The second level domain associated with the
* IP address. This will be something like "example.com" or "example.co.uk",
* not "foo.example.com". This attribute is only available from the
* City Plus and Insights web services and the GeoIP2 Enterprise
* database.
* @property-read string $ipAddress The IP address that the data in the model
* is for. If you performed a "me" lookup against the web service, this
* will be the externally routable IP address for the system the code is
* running on. If the system is behind a NAT, this may differ from the IP
* address locally assigned to it. This attribute is returned by all end
* points.
* @property-read bool $isAnonymous This is true if the IP address belongs to
* any sort of anonymous network. This property is only available from GeoIP2
* Insights.
* @property-read bool $isAnonymousProxy *Deprecated.* Please see our GeoIP2
* Anonymous IP database
* (https://www.maxmind.com/en/geoip2-anonymous-ip-database) to determine
* whether the IP address is used by an anonymizing service.
* @property-read bool $isAnonymousVpn This is true if the IP address is
* registered to an anonymous VPN provider. If a VPN provider does not register
* subnets under names associated with them, we will likely only flag their IP
* ranges using the isHostingProvider property. This property is only available
* from GeoIP2 Insights.
* @property-read bool $isHostingProvider This is true if the IP address belongs
* to a hosting or VPN provider (see description of isAnonymousVpn property).
* This property is only available from GeoIP2 Insights.
* @property-read bool $isLegitimateProxy This attribute is true if MaxMind
* believes this IP address to be a legitimate proxy, such as an internal
* VPN used by a corporation. This attribute is only available in the GeoIP2
* Enterprise database.
* @property-read bool $isPublicProxy This is true if the IP address belongs to
* a public proxy. This property is only available from GeoIP2 Insights.
* @property-read bool $isResidentialProxy This is true if the IP address is
* on a suspected anonymizing network and belongs to a residential ISP. This
* property is only available from GeoIP2 Insights.
* @property-read bool $isSatelliteProvider *Deprecated.* Due to the
* increased coverage by mobile carriers, very few satellite providers now
* serve multiple countries. As a result, the output does not provide
* sufficiently relevant data for us to maintain it.
* @property-read bool $isTorExitNode This is true if the IP address is a Tor
* exit node. This property is only available from GeoIP2 Insights.
* @property-read string|null $isp The name of the ISP associated with the IP
* address. This attribute is only available from the City Plus and Insights
* web services and the GeoIP2 Enterprise database.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
* @property-read string|null $organization The name of the organization
* associated with the IP address. This attribute is only available from the
* City Plus and Insights web services and the GeoIP2 Enterprise database.
* @property-read string|null $mobileCountryCode The [mobile country code
* (MCC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP. This property is available from the City Plus and
* Insights web services and the GeoIP2 Enterprise database.
* @property-read string|null $mobileNetworkCode The [mobile network code
* (MNC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP. This property is available from the City Plus and
* Insights web services and the GeoIP2 Enterprise database.
* @property-read float|null $staticIpScore An indicator of how static or
* dynamic an IP address is. This property is only available from GeoIP2
* Insights.
* @property-read int|null $userCount The estimated number of users sharing
* the IP/network during the past 24 hours. For IPv4, the count is for the
* individual IP. For IPv6, the count is for the /64 network. This property is
* only available from GeoIP2 Insights.
* @property-read string|null $userType <p>The user type associated with the IP
* address. This can be one of the following values:</p>
* <ul>
* <li>business
* <li>cafe
* <li>cellular
* <li>college
* <li>consumer_privacy_network
* <li>content_delivery_network
* <li>dialup
* <li>government
* <li>hosting
* <li>library
* <li>military
* <li>residential
* <li>router
* <li>school
* <li>search_engine_spider
* <li>traveler
* </ul>
* <p>
* This attribute is only available from the Insights web service and the
* GeoIP2 Enterprise database.
* </p>
*/
class Traits extends AbstractRecord
{
/**
* @ignore
*
* @var array<string>
*/
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);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace GeoIp2;
class Util
{
/**
* This returns the network in CIDR notation for the given IP and prefix
* length. This is for internal use only.
*
* @internal
* @ignore
*/
public static function cidr(string $ipAddress, int $prefixLen): string
{
$ipBytes = inet_pton($ipAddress);
$networkBytes = str_repeat("\0", \strlen($ipBytes));
$curPrefix = $prefixLen;
for ($i = 0; $i < \strlen($ipBytes) && $curPrefix > 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";
}
}
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace GeoIp2\WebService;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Exception\AuthenticationException;
use GeoIp2\Exception\GeoIp2Exception;
use GeoIp2\Exception\HttpException;
use GeoIp2\Exception\InvalidRequestException;
use GeoIp2\Exception\OutOfQueriesException;
use GeoIp2\Model\City;
use GeoIp2\Model\Country;
use GeoIp2\Model\Insights;
use GeoIp2\ProviderInterface;
use MaxMind\WebService\Client as WsClient;
/**
* This class provides a client API for all the GeoIP2 web services.
* The services are Country, City Plus, and Insights. Each service returns
* a different set of data about an IP address, with Country returning the
* least data and Insights the most.
*
* Each web service is represented by a different model class, and these model
* classes in turn contain multiple record classes. The record classes have
* attributes which contain data about the IP address.
*
* If the web service does not return a particular piece of data for an IP
* address, the associated attribute is not populated.
*
* The web service may not return any information for an entire record, in
* which case all of the attributes for that record class will be empty.
*
* ## Usage ##
*
* The basic API for this class is the same for all of the web service end
* points. First you create a web service object with your MaxMind `$accountId`
* and `$licenseKey`, then you call the method corresponding to a specific end
* point, passing it the IP address you want to look up.
*
* If the request succeeds, the method call will return a model class for
* the service you called. This model in turn contains multiple record
* classes, each of which represents part of the data returned by the web
* service.
*
* If the request fails, the client class throws an exception.
*/
class Client implements ProviderInterface
{
/**
* @var array<string>
*/
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);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* 加载 patches/third_party 中的 GeoIp2 / MaxMind DB Reader(无需 composer 写入 vendor
*/
(function (): void {
static $loaded = false;
if ($loaded) {
return;
}
$loaded = true;
$base = __DIR__;
require_once $base . '/maxmind-db/reader/autoload.php';
spl_autoload_register(static function (string $class) use ($base): void {
$map = [
'GeoIp2\\' => $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;
}
});
})();
+261
View File
@@ -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
+202
View File
@@ -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.
+214
View File
@@ -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
<?php
require_once 'vendor/autoload.php';
use MaxMind\Db\Reader;
$ipAddress = '24.24.24.24';
$databaseFile = 'GeoIP2-City.mmdb';
$reader = new Reader($databaseFile);
// get returns just the record for the IP address
print_r($reader->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.
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* PSR-4 autoloader implementation for the MaxMind\DB namespace.
* First we define the 'mmdb_autoload' function, and then we register
* it with 'spl_autoload_register' so that PHP knows to use it.
*
* @param mixed $class
*/
/**
* Automatically include the file that defines <code>class</code>.
*
* @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');
+43
View File
@@ -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"
}
}
}
+40
View File
@@ -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
+10
View File
@@ -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');
}
}
+819
View File
@@ -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 <php.h>
#include <zend.h>
#include "Zend/zend_exceptions.h"
#include "Zend/zend_types.h"
#include "ext/spl/spl_exceptions.h"
#include "ext/standard/info.h"
#include <maxminddb.h>
#ifdef ZTS
#include <TSRM.h>
#endif
#define __STDC_FORMAT_MACROS
#include <inttypes.h>
#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
@@ -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 <zend_interfaces.h>
#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
@@ -0,0 +1,12 @@
--TEST--
Check for maxminddb presence
--SKIPIF--
<?php if (!extension_loaded('maxminddb')) {
echo 'skip';
} ?>
--FILE--
<?php
echo 'maxminddb extension is available';
?>
--EXPECT--
maxminddb extension is available
@@ -0,0 +1,13 @@
--TEST--
Check that Reader class is not final
--SKIPIF--
<?php if (!extension_loaded('maxminddb')) {
echo 'skip';
} ?>
--FILE--
<?php
$reflectionClass = new \ReflectionClass('MaxMind\Db\Reader');
var_dump($reflectionClass->isFinal());
?>
--EXPECT--
bool(false)
@@ -0,0 +1,12 @@
--TEST--
openbase_dir is followed
--INI--
open_basedir=/--dne--
--FILE--
<?php
use MaxMind\Db\Reader;
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-City.mmdb');
?>
--EXPECTREGEX--
.*open_basedir restriction in effect.*
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0"?>
<package version="2.0" xmlns="http://pear.php.net/dtd/package-2.0"
xmlns:tasks="http://pear.php.net/dtd/tasks-1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pear.php.net/dtd/tasks-1.0 http://pear.php.net/dtd/tasks-1.0.xsd http://pear.php.net/dtd/package-2.0 http://pear.php.net/dtd/package-2.0.xsd">
<name>maxminddb</name>
<channel>pecl.php.net</channel>
<summary>Reader for the MaxMind DB file format</summary>
<description>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).</description>
<lead>
<name>Greg Oschwald</name>
<user>oschwald</user>
<email>goschwald@maxmind.com</email>
<active>yes</active>
</lead>
<date>2025-11-21</date>
<version>
<release>1.13.1</release>
<api>1.13.1</api>
</version>
<stability>
<release>stable</release>
<api>stable</api>
</stability>
<license uri="https://github.com/maxmind/MaxMind-DB-Reader-php/blob/main/LICENSE">Apache License 2.0</license>
<notes>* First PIE release. No other changes.</notes>
<contents>
<dir name="/">
<file role="doc" name="LICENSE"/>
<file role="doc" name="CHANGELOG.md"/>
<file role="doc" name="README.md"/>
<dir name="ext">
<file role="src" name="config.m4"/>
<file role="src" name="config.w32"/>
<file role="src" name="maxminddb.c"/>
<file role="src" name="php_maxminddb.h"/>
<dir name="tests">
<file role="test" name="001-load.phpt"/>
<file role="test" name="002-final.phpt"/>
<file role="test" name="003-open-basedir.phpt"/>
</dir>
</dir>
</dir>
</contents>
<dependencies>
<required>
<php>
<min>7.2.0</min>
</php>
<pearinstaller>
<min>1.10.0</min>
</pearinstaller>
</required>
</dependencies>
<providesextension>maxminddb</providesextension>
<extsrcrelease />
</package>
@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db;
use MaxMind\Db\Reader\Decoder;
use MaxMind\Db\Reader\InvalidDatabaseException;
use MaxMind\Db\Reader\Metadata;
use MaxMind\Db\Reader\Util;
/**
* Instances of this class provide a reader for the MaxMind DB format. IP
* addresses can be looked up using the get method.
*/
class Reader
{
/**
* @var int
*/
private static $DATA_SECTION_SEPARATOR_SIZE = 16;
/**
* @var string
*/
private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
/**
* @var int<0, max>
*/
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);
}
}
@@ -0,0 +1,452 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
// @codingStandardsIgnoreLine
class Decoder
{
/**
* @var resource
*/
private $fileStream;
/**
* @var int
*/
private $pointerBase;
/**
* This is only used for unit testing.
*
* @var bool
*/
private $pointerTestHack;
/**
* @var bool
*/
private $switchByteOrder;
private const _EXTENDED = 0;
private const _POINTER = 1;
private const _UTF8_STRING = 2;
private const _DOUBLE = 3;
private const _BYTES = 4;
private const _UINT16 = 5;
private const _UINT32 = 6;
private const _MAP = 7;
private const _INT32 = 8;
private const _UINT64 = 9;
private const _UINT128 = 10;
private const _ARRAY = 11;
// 12 is the container type
// 13 is the end marker type
private const _BOOLEAN = 14;
private const _FLOAT = 15;
/**
* @param resource $fileStream
*/
public function __construct(
$fileStream,
int $pointerBase = 0,
bool $pointerTestHack = false
) {
$this->fileStream = $fileStream;
$this->pointerBase = $pointerBase;
$this->pointerTestHack = $pointerTestHack;
$this->switchByteOrder = $this->isPlatformLittleEndian();
}
/**
* @return array<mixed>
*/
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<mixed>, 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<string, mixed>, 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);
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class should be thrown when unexpected data is found in the database.
*/
// phpcs:disable
class InvalidDatabaseException extends \Exception {}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class provides the metadata for the MaxMind DB file.
*/
class Metadata
{
/**
* This is an unsigned 16-bit integer indicating the major version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMajorVersion;
/**
* This is an unsigned 16-bit integer indicating the minor version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMinorVersion;
/**
* This is an unsigned 64-bit integer that contains the database build
* timestamp as a Unix epoch value.
*
* @var int
*/
public $buildEpoch;
/**
* This is a string that indicates the structure of each data record
* associated with an IP address. The actual definition of these
* structures is left up to the database creator.
*
* @var string
*/
public $databaseType;
/**
* This key will always point to a map (associative array). The keys of
* that map will be language codes, and the values will be a description
* in that language as a UTF-8 string. May be undefined for some
* databases.
*
* @var array<string, string>
*/
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<string>
*/
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<string, mixed> $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;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
class Util
{
/**
* @param resource $stream
* @param int<0, max> $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'
);
}
}

Some files were not shown because too many files have changed in this diff Show More