前端分流页功能
This commit is contained in:
Executable
BIN
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;
|
||||||
@@ -9,6 +9,7 @@ use app\common\controller\Backend;
|
|||||||
use app\common\library\CountryIso;
|
use app\common\library\CountryIso;
|
||||||
use app\common\service\SplitAutoReplyService;
|
use app\common\service\SplitAutoReplyService;
|
||||||
use app\common\service\SplitLinkCodeService;
|
use app\common\service\SplitLinkCodeService;
|
||||||
|
use app\common\service\SplitPixelConfigService;
|
||||||
use app\common\service\SplitPlatformDomainService;
|
use app\common\service\SplitPlatformDomainService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
@@ -224,8 +225,8 @@ class Link extends Backend
|
|||||||
$this->success('', null, [
|
$this->success('', null, [
|
||||||
'platform_domains' => $platformDomains,
|
'platform_domains' => $platformDomains,
|
||||||
'my_domains' => $myDomains,
|
'my_domains' => $myDomains,
|
||||||
'domain_index_url' => (string) url('domain/index'),
|
'domain_index_url' => (string) url('domain', '', false, false),
|
||||||
'domain_add_url' => (string) url('domain/add'),
|
'domain_add_url' => (string) url('domain/add', '', false, false),
|
||||||
'config_index_url' => (string) url('general/config/index'),
|
'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' => '停用',
|
'Status hidden' => '停用',
|
||||||
'Copy split link' => '复制分流链接',
|
'Copy split link' => '复制分流链接',
|
||||||
'Manage my domains' => '管理我的域名',
|
'Manage my domains' => '管理我的域名',
|
||||||
|
'Domain management' => '域名管理',
|
||||||
'Select main domain' => '选择主域名',
|
'Select main domain' => '选择主域名',
|
||||||
'Platform assigned domain' => '平台分配域名',
|
'Platform assigned domain' => '平台分配域名',
|
||||||
'My domains' => '我的域名',
|
'My domains' => '我的域名',
|
||||||
@@ -39,4 +40,26 @@ return [
|
|||||||
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
|
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
|
||||||
'Auto reply saved' => '自动回复已保存',
|
'Auto reply saved' => '自动回复已保存',
|
||||||
'Reply statements column' => '回复语',
|
'Reply statements column' => '回复语',
|
||||||
|
'NS' => 'NS',
|
||||||
|
'DNS' => 'DNS',
|
||||||
|
'Pixel config' => '像素配置',
|
||||||
|
'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access Token,Token 留空会保留原配置。',
|
||||||
|
'Facebook Pixel' => 'Facebook Pixel',
|
||||||
|
'TikTok Pixel' => 'TikTok Pixel',
|
||||||
|
'Add FB Pixel' => '+ 添加FB Pixel',
|
||||||
|
'Add TK Pixel' => '+ 添加TK Pixel',
|
||||||
|
'Pixel enabled' => '启用',
|
||||||
|
'Server postback' => '服务端回传',
|
||||||
|
'Pixel ID' => 'Pixel ID',
|
||||||
|
'Access Token' => 'Access Token',
|
||||||
|
'Test code' => '测试码',
|
||||||
|
'Pixel event' => '事件',
|
||||||
|
'Pixel sort' => '排序',
|
||||||
|
'Pixel config saved' => '像素配置已保存',
|
||||||
|
'Pixel ID required' => '请填写 Pixel ID',
|
||||||
|
'Remove row' => '删除',
|
||||||
|
'Optional' => '选填',
|
||||||
|
'Pixel list empty' => '暂无配置,请点击上方按钮添加',
|
||||||
|
'Pixel row index' => '序号',
|
||||||
|
'Operate' => '操作',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -94,15 +94,11 @@ class Link extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表「回复语」列展示(多行合并为单行,供省略号截断)
|
* 列表「回复语」列预览(最多 50 字 + ...,悬停用原始 auto_reply 换行展示)
|
||||||
*/
|
*/
|
||||||
public function getAutoReplyTextAttr($value, $data): string
|
public function getAutoReplyTextAttr($value, $data): string
|
||||||
{
|
{
|
||||||
$lines = \app\common\service\SplitAutoReplyService::parseLines((string)($data['auto_reply'] ?? ''));
|
return \app\common\service\SplitAutoReplyService::previewForList((string) ($data['auto_reply'] ?? ''));
|
||||||
if ($lines === []) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return implode(';', $lines);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIpProtectTextAttr($value, $data): string
|
public function getIpProtectTextAttr($value, $data): string
|
||||||
|
|||||||
@@ -507,3 +507,10 @@ EOT;
|
|||||||
return $icon;
|
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 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp:https://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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||||
|
*/
|
||||||
|
private static function buildTelegram(string $number): string
|
||||||
|
{
|
||||||
|
$id = ltrim($number, '+');
|
||||||
|
if ($id === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://t.me/+' . $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line:https://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_protect:0=关闭,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 访客 IP(IP 防护开启时用于国家校验)
|
||||||
|
*/
|
||||||
|
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
|
||||||
|
{
|
||||||
|
$linkCode = SplitLinkCodeService::normalize($linkCode);
|
||||||
|
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = Link::where('link_code', $linkCode)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->find();
|
||||||
|
if (!$link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipProtect = (int) $link->getAttr('ip_protect');
|
||||||
|
$countries = (string) $link->getAttr('countries');
|
||||||
|
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<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
@@ -183,16 +183,27 @@ return [
|
|||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
// | 缓存设置
|
// | 缓存设置
|
||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
'cache' => [
|
// 'cache' => [
|
||||||
// 驱动方式
|
// // 驱动方式
|
||||||
'type' => 'File',
|
// 'type' => 'File',
|
||||||
// 缓存保存目录
|
// // 缓存保存目录
|
||||||
'path' => CACHE_PATH,
|
// 'path' => CACHE_PATH,
|
||||||
// 缓存前缀
|
// // 缓存前缀
|
||||||
'prefix' => '',
|
// 'prefix' => '',
|
||||||
// 缓存有效期 0表示永久缓存
|
// // 缓存有效期 0表示永久缓存
|
||||||
'expire' => 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, // 超时时间
|
||||||
],
|
],
|
||||||
|
|
||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
// | 会话设置
|
// | 会话设置
|
||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -11,11 +11,14 @@
|
|||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// 分流链接公开落地页:/s/{9位链接码}
|
||||||
|
's/:link_code' => 'index/Split/visit',
|
||||||
//别名配置,别名只能是映射到控制器且访问时必须加上请求的方法
|
//别名配置,别名只能是映射到控制器且访问时必须加上请求的方法
|
||||||
'__alias__' => [
|
'__alias__' => [
|
||||||
],
|
],
|
||||||
//变量规则
|
//变量规则
|
||||||
'__pattern__' => [
|
'__pattern__' => [
|
||||||
|
'link_code' => '[a-z]{9}',
|
||||||
],
|
],
|
||||||
// 域名绑定到模块
|
// 域名绑定到模块
|
||||||
// '__domain__' => [
|
// '__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\library\CountryIso;
|
||||||
use app\common\service\SplitAutoReplyService;
|
use app\common\service\SplitAutoReplyService;
|
||||||
use app\common\service\SplitLinkCodeService;
|
use app\common\service\SplitLinkCodeService;
|
||||||
|
use app\common\service\SplitPixelConfigService;
|
||||||
use app\common\service\SplitPlatformDomainService;
|
use app\common\service\SplitPlatformDomainService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
@@ -224,8 +225,8 @@ class Link extends Backend
|
|||||||
$this->success('', null, [
|
$this->success('', null, [
|
||||||
'platform_domains' => $platformDomains,
|
'platform_domains' => $platformDomains,
|
||||||
'my_domains' => $myDomains,
|
'my_domains' => $myDomains,
|
||||||
'domain_index_url' => (string) url('domain/index'),
|
'domain_index_url' => (string) url('domain', '', false, false),
|
||||||
'domain_add_url' => (string) url('domain/add'),
|
'domain_add_url' => (string) url('domain/add', '', false, false),
|
||||||
'config_index_url' => (string) url('general/config/index'),
|
'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' => '停用',
|
'Status hidden' => '停用',
|
||||||
'Copy split link' => '复制分流链接',
|
'Copy split link' => '复制分流链接',
|
||||||
'Manage my domains' => '管理我的域名',
|
'Manage my domains' => '管理我的域名',
|
||||||
|
'Domain management' => '域名管理',
|
||||||
'Select main domain' => '选择主域名',
|
'Select main domain' => '选择主域名',
|
||||||
'Platform assigned domain' => '平台分配域名',
|
'Platform assigned domain' => '平台分配域名',
|
||||||
'My domains' => '我的域名',
|
'My domains' => '我的域名',
|
||||||
@@ -41,4 +42,24 @@ return [
|
|||||||
'Reply statements column' => '回复语',
|
'Reply statements column' => '回复语',
|
||||||
'NS' => 'NS',
|
'NS' => 'NS',
|
||||||
'DNS' => 'DNS',
|
'DNS' => 'DNS',
|
||||||
|
'Pixel config' => '像素配置',
|
||||||
|
'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access Token,Token 留空会保留原配置。',
|
||||||
|
'Facebook Pixel' => 'Facebook Pixel',
|
||||||
|
'TikTok Pixel' => 'TikTok Pixel',
|
||||||
|
'Add FB Pixel' => '+ 添加FB Pixel',
|
||||||
|
'Add TK Pixel' => '+ 添加TK Pixel',
|
||||||
|
'Pixel enabled' => '启用',
|
||||||
|
'Server postback' => '服务端回传',
|
||||||
|
'Pixel ID' => 'Pixel ID',
|
||||||
|
'Access Token' => 'Access Token',
|
||||||
|
'Test code' => '测试码',
|
||||||
|
'Pixel event' => '事件',
|
||||||
|
'Pixel sort' => '排序',
|
||||||
|
'Pixel config saved' => '像素配置已保存',
|
||||||
|
'Pixel ID required' => '请填写 Pixel ID',
|
||||||
|
'Remove row' => '删除',
|
||||||
|
'Optional' => '选填',
|
||||||
|
'Pixel list empty' => '暂无配置,请点击上方按钮添加',
|
||||||
|
'Pixel row index' => '序号',
|
||||||
|
'Operate' => '操作',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp:https://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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||||
|
*/
|
||||||
|
private static function buildTelegram(string $number): string
|
||||||
|
{
|
||||||
|
$id = ltrim($number, '+');
|
||||||
|
if ($id === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://t.me/+' . $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line:https://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_protect:0=关闭,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 访客 IP(IP 防护开启时用于国家校验)
|
||||||
|
*/
|
||||||
|
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
|
||||||
|
{
|
||||||
|
$linkCode = SplitLinkCodeService::normalize($linkCode);
|
||||||
|
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = Link::where('link_code', $linkCode)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->find();
|
||||||
|
if (!$link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipProtect = (int) $link->getAttr('ip_protect');
|
||||||
|
$countries = (string) $link->getAttr('countries');
|
||||||
|
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<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',
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -56,6 +56,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
icon: 'fa fa-commenting-o',
|
icon: 'fa fa-commenting-o',
|
||||||
classname: 'btn btn-warning btn-xs btn-split-autoreply',
|
classname: 'btn btn-warning btn-xs btn-split-autoreply',
|
||||||
url: 'javascript:;'
|
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
|
formatter: Table.api.formatter.operate
|
||||||
@@ -95,6 +103,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
Controller.api.openAutoReplyModal(row);
|
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);
|
Controller.api.bindAutoReplyPreviewTips(table);
|
||||||
|
|
||||||
Table.api.bindevent(table);
|
Table.api.bindevent(table);
|
||||||
@@ -107,8 +127,45 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
Controller.api.bindevent();
|
Controller.api.bindevent();
|
||||||
},
|
},
|
||||||
api: {
|
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,
|
modalStyleInjected: false,
|
||||||
|
pixelModalStyleInjected: false,
|
||||||
injectModalStyles: function () {
|
injectModalStyles: function () {
|
||||||
if (Controller.api.modalStyleInjected) {
|
if (Controller.api.modalStyleInjected) {
|
||||||
return;
|
return;
|
||||||
@@ -140,6 +197,49 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
].join('');
|
].join('');
|
||||||
$('<style id="split-link-copy-modal-style" type="text/css"></style>').text(css).appendTo('head');
|
$('<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: {
|
formatter: {
|
||||||
/**
|
/**
|
||||||
* 回复语:列表最多 50 字 + ...,悬停保留换行显示全文
|
* 回复语:列表最多 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) {
|
loadCopyModalData: function (callback) {
|
||||||
Fast.api.ajax({
|
Fast.api.ajax({
|
||||||
url: 'split.link/copyinfo',
|
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 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 configIndexUrl = data.config_index_url || 'general/config/index';
|
||||||
var defaultType = platformDomains.length ? 'platform' : 'my';
|
var defaultType = platformDomains.length ? 'platform' : 'my';
|
||||||
var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : '');
|
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);
|
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();
|
e.preventDefault();
|
||||||
Layer.close(index);
|
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) {
|
$box.on('click', '.btn-goto-config', function (e) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
9
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4
|
||||||
+323
@@ -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
@@ -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
@@ -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
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+35
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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');
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.*
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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
Reference in New Issue
Block a user