Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/Config/Hostnames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Config;

class Hostnames
{
// List of known two-part TLDs for subdomain extraction
public const TWO_PART_TLDS = [
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
'gob.cl', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl',
'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir',
];
}
50 changes: 50 additions & 0 deletions system/Helpers/url_helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use CodeIgniter\HTTP\URI;
use CodeIgniter\Router\Exceptions\RouterException;
use Config\App;
use Config\Hostnames;

// CodeIgniter URL Helpers

Expand Down Expand Up @@ -534,3 +535,52 @@ function url_is(string $path): bool
return (bool) preg_match("|^{$path}$|", $currentPath, $matches);
}
}

if (! function_exists('parse_subdomain')) {
/**
* Parses the subdomain from the current host name.
*
* @param string|null $host The hostname to parse. If null, uses the current request's host.
*
* @return string The subdomain, or an empty string if none exists.
*/
function parse_subdomain(?string $host = null): string
{
if ($host === null) {
$host = service('request')->getUri()->getHost();
}

// Handle localhost and IP addresses - they don't have subdomains
if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) {
return '';
}

$parts = explode('.', $host);
$partCount = count($parts);

// Need at least 3 parts for a subdomain (subdomain.domain.tld)
// e.g., api.example.com
if ($partCount < 3) {
return '';
}

// Check if we have a two-part TLD (e.g., co.uk, com.au)
$lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1];

if (in_array($lastTwoParts, Hostnames::TWO_PART_TLDS, true)) {
// For two-part TLD, need at least 4 parts for subdomain
// e.g., api.example.co.uk (4 parts)
if ($partCount < 4) {
return ''; // No subdomain, just domain.co.uk
}

// Remove the two-part TLD and domain name (last 3 parts)
// e.g., admin.api.example.co.uk -> admin.api
return implode('.', array_slice($parts, 0, $partCount - 3));
}

// Standard TLD: Remove TLD and domain (last 2 parts)
// e.g., admin.api.example.com -> admin.api
return implode('.', array_slice($parts, 0, $partCount - 2));
}
}
70 changes: 1 addition & 69 deletions system/Router/Attributes/Restrict.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,38 +42,6 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Restrict implements RouteAttributeInterface
{
private const TWO_PART_TLDS = [
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
'gob.cl',
];

public function __construct(
public array|string|null $environment = null,
public array|string|null $hostname = null,
Expand Down Expand Up @@ -145,7 +113,7 @@ private function checkSubdomain(RequestInterface $request): void
return;
}

$currentSubdomain = $this->getSubdomain($request);
$currentSubdomain = parse_subdomain($request->getUri()->getHost());
$allowedSubdomains = array_map('strtolower', (array) $this->subdomain);

// If no subdomain exists but one is required
Expand All @@ -158,40 +126,4 @@ private function checkSubdomain(RequestInterface $request): void
throw new PageNotFoundException('Access denied: subdomain is blocked.');
}
}

private function getSubdomain(RequestInterface $request): string
{
$host = strtolower($request->getUri()->getHost());

// Handle localhost and IP addresses - they don't have subdomains
if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) {
return '';
}

$parts = explode('.', $host);
$partCount = count($parts);

// Need at least 3 parts for a subdomain (subdomain.domain.tld)
// e.g., api.example.com
if ($partCount < 3) {
return '';
}
// Check if we have a two-part TLD (e.g., co.uk, com.au)
$lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1];
if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) {
// For two-part TLD, need at least 4 parts for subdomain
// e.g., api.example.co.uk (4 parts)
if ($partCount < 4) {
return ''; // No subdomain, just domain.co.uk
}

// Remove the two-part TLD and domain name (last 3 parts)
// e.g., admin.api.example.co.uk -> admin.api
return implode('.', array_slice($parts, 0, $partCount - 3));
}

// Standard TLD: Remove TLD and domain (last 2 parts)
// e.g., admin.api.example.com -> admin.api
return implode('.', array_slice($parts, 0, $partCount - 2));
}
}
46 changes: 1 addition & 45 deletions system/Router/RouteCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -1637,7 +1637,7 @@ private function checkSubdomains($subdomains): bool
}

if ($this->currentSubdomain === null) {
$this->currentSubdomain = $this->determineCurrentSubdomain();
$this->currentSubdomain = parse_subdomain($this->httpHost);
}

if (! is_array($subdomains)) {
Expand All @@ -1653,50 +1653,6 @@ private function checkSubdomains($subdomains): bool
return in_array($this->currentSubdomain, $subdomains, true);
}

/**
* Examines the HTTP_HOST to get the best match for the subdomain. It
* won't be perfect, but should work for our needs.
*
* It's especially not perfect since it's possible to register a domain
* with a period (.) as part of the domain name.
*
* @return false|string the subdomain
*/
private function determineCurrentSubdomain()
{
// We have to ensure that a scheme exists
// on the URL else parse_url will mis-interpret
// 'host' as the 'path'.
$url = $this->httpHost;
if (! str_starts_with($url, 'http')) {
$url = 'http://' . $url;
}

$parsedUrl = parse_url($url);

$host = explode('.', $parsedUrl['host']);

if ($host[0] === 'www') {
unset($host[0]);
}

// Get rid of any domains, which will be the last
unset($host[count($host) - 1]);

// Account for .co.uk, .co.nz, etc. domains
if (end($host) === 'co') {
$host = array_slice($host, 0, -1);
}

// If we only have 1 part left, then we don't have a sub-domain.
if (count($host) === 1) {
// Set it to false so we don't make it back here again.
return false;
}

return array_shift($host);
}

/**
* Reset the routes, so that a test case can provide the
* explicit ones needed for it.
Expand Down
36 changes: 36 additions & 0 deletions tests/system/Helpers/URLHelper/MiscUrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -963,4 +963,40 @@ public function testUrlToMissingArgument(): void

url_to('loginURL');
}

#[DataProvider('provideParseSubdomain')]
public function testParseSubdomain(?string $host, string $expected, bool $useRequest = false): void
{
if ($useRequest) {
// create a request whose host will be used when passing null to parse_subdomain
$this->config->baseURL = 'http://sub.example.com/';
$this->createRequest('http://sub.example.com/');

$this->assertSame($expected, parse_subdomain(null));

return;
}

$this->assertSame($expected, parse_subdomain($host));
}

/**
* Provides test cases for parsing subdomains.
*
* @return array<string, array{0: string|null, 1: string, 2: bool}>
*/
public static function provideParseSubdomain(): iterable
{
return [
'standard subdomain' => ['api.example.com', 'api', false],
'multi-level subdomain' => ['admin.api.example.com', 'admin.api', false],
'no subdomain (domain only)' => ['example.com', '', false],
'localhost' => ['localhost', '', false],
'ipv4' => ['127.0.0.1', '', false],
'ipv6' => ['::1', '', false],
'two-part tld no subdomain' => ['example.co.uk', '', false],
'two-part tld with subdomain' => ['api.example.co.uk', 'api', false],
'null uses request host' => [null, 'sub', true],
];
}
}
15 changes: 15 additions & 0 deletions user_guide_src/source/helpers/url_helper.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,21 @@ The following functions are available:
This function works the same as :php:func:`url_title()` but it converts all
accented characters automatically.

.. php:function:: parse_subdomain($hostname)

:param string|null $hostname: The hostname to parse. If null, uses the current request's host.
:returns: The subdomain, or an empty string if none exists.
:rtype: string

Parses the subdomain from the given host name.

Here are some examples:

.. literalinclude:: url_helper/027.php

You can customize the list of known two-part TLDs by adding them to the
``Config\Hostnames::TWO_PART_TLDS`` array.

.. php:function:: prep_url([$str = ''[, $secure = false]])

:param string $str: URL string
Expand Down
14 changes: 14 additions & 0 deletions user_guide_src/source/helpers/url_helper/027.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

// Outputs "blog"
echo parse_subdomain('blog.example.com');

// Outputs an empty string
echo parse_subdomain('example.com');
echo parse_subdomain('example.co.uk');

// Outputs "shop" - correctly handles two-part TLDs
echo parse_subdomain('shop.example.co.uk');

// Outputs "shop.old"
echo parse_subdomain('shop.old.example.co.uk');
Loading