Skip to content

Commit 675dfef

Browse files
lonnieezelldatamweb
andcommitted
refactor(app): Standardize subdomain detection logic (codeigniter4#9751)
* refactor(app): Standardize subdomain detection logic * Update app/Config/Hostnames.php Co-authored-by: Pooya Parsa <[email protected]> * Update tests/system/Helpers/URLHelper/MiscUrlTest.php Co-authored-by: Pooya Parsa <[email protected]> * addressing review comments * cs fix * cs fix * cs fix * remove typo in docs ci-skip --------- Co-authored-by: Pooya Parsa <[email protected]>
1 parent 37ff9fa commit 675dfef

20 files changed

Lines changed: 683 additions & 149 deletions

File tree

app/Config/Hostnames.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Config;
4+
5+
class Hostnames
6+
{
7+
// List of known two-part TLDs for subdomain extraction
8+
public const TWO_PART_TLDS = [
9+
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
10+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
11+
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
12+
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
13+
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
14+
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
15+
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
16+
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
17+
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
18+
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
19+
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
20+
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
21+
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
22+
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
23+
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
24+
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
25+
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
26+
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
27+
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
28+
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
29+
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
30+
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
31+
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
32+
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
33+
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
34+
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
35+
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
36+
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
37+
'gob.cl', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl',
38+
'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir',
39+
];
40+
}

app/Config/Routing.php

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@
1818
*/
1919
class Routing extends BaseRouting
2020
{
21+
/**
22+
* If TRUE, the system will attempt to match the URI against
23+
* Controllers by matching each segment against folders/files
24+
* in APPPATH/Controllers, when a match wasn't found against
25+
* defined routes.
26+
*
27+
* If FALSE, will stop searching and do NO automatic routing.
28+
*
29+
* Default: false
30+
*/
31+
public bool $autoRoute = false;
32+
33+
/**
34+
* If TRUE, the system will use route definition files to
35+
* define routes. If FALSE, any routes defined in the Routes.php
36+
* files will be ignored.
37+
*
38+
* Default: true
39+
*/
40+
public bool $definedRoutes = true;
41+
2142
/**
2243
* For Defined Routes.
2344
* An array of files that contain route definitions.
@@ -86,16 +107,6 @@ class Routing extends BaseRouting
86107
*/
87108
public ?string $override404 = null;
88109

89-
/**
90-
* If TRUE, the system will attempt to match the URI against
91-
* Controllers by matching each segment against folders/files
92-
* in APPPATH/Controllers, when a match wasn't found against
93-
* defined routes.
94-
*
95-
* If FALSE, will stop searching and do NO automatic routing.
96-
*/
97-
public bool $autoRoute = false;
98-
99110
/**
100111
* If TRUE, the system will look for attributes on controller
101112
* class and methods that can run before and after the

system/CodeIgniter.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,12 @@ protected function tryToRouteIt(?RouteCollectionInterface $routes = null)
797797
$this->benchmark->start('routing');
798798

799799
if (! $routes instanceof RouteCollectionInterface) {
800-
$routes = service('routes')->loadRoutes();
800+
$routes = Services::routes();
801+
802+
// Only load the routes if we are using definedRoutes
803+
if (config('Routing')->definedRoutes) {
804+
$routes->loadRoutes();
805+
}
801806
}
802807

803808
// $routes is defined in Config/Routes.php

system/Config/Routing.php

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@
1818
*/
1919
class Routing extends BaseConfig
2020
{
21+
/**
22+
* If TRUE, the system will attempt to match the URI against
23+
* Controllers by matching each segment against folders/files
24+
* in APPPATH/Controllers, when a match wasn't found against
25+
* defined routes.
26+
*
27+
* If FALSE, will stop searching and do NO automatic routing.
28+
*/
29+
public bool $autoRoute = false;
30+
31+
/**
32+
* If TRUE, the system will use route definition files to
33+
* define routes. If FALSE, any routes defined in the Routes.php
34+
* files will be ignored.
35+
*
36+
* Default: true
37+
*/
38+
public bool $definedRoutes = true;
39+
2140
/**
2241
* For Defined Routes.
2342
* An array of files that contain route definitions.
@@ -86,16 +105,6 @@ class Routing extends BaseConfig
86105
*/
87106
public ?string $override404 = null;
88107

89-
/**
90-
* If TRUE, the system will attempt to match the URI against
91-
* Controllers by matching each segment against folders/files
92-
* in APPPATH/Controllers, when a match wasn't found against
93-
* defined routes.
94-
*
95-
* If FALSE, will stop searching and do NO automatic routing.
96-
*/
97-
public bool $autoRoute = false;
98-
99108
/**
100109
* If TRUE, the system will look for attributes on controller
101110
* class and methods that can run before and after the

system/Helpers/url_helper.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use CodeIgniter\HTTP\URI;
1818
use CodeIgniter\Router\Exceptions\RouterException;
1919
use Config\App;
20+
use Config\Hostnames;
2021

2122
// CodeIgniter URL Helpers
2223

@@ -534,3 +535,52 @@ function url_is(string $path): bool
534535
return (bool) preg_match("|^{$path}$|", $currentPath, $matches);
535536
}
536537
}
538+
539+
if (! function_exists('parse_subdomain')) {
540+
/**
541+
* Parses the subdomain from the current host name.
542+
*
543+
* @param string|null $host The hostname to parse. If null, uses the current request's host.
544+
*
545+
* @return string The subdomain, or an empty string if none exists.
546+
*/
547+
function parse_subdomain(?string $host = null): string
548+
{
549+
if ($host === null) {
550+
$host = service('request')->getUri()->getHost();
551+
}
552+
553+
// Handle localhost and IP addresses - they don't have subdomains
554+
if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) {
555+
return '';
556+
}
557+
558+
$parts = explode('.', $host);
559+
$partCount = count($parts);
560+
561+
// Need at least 3 parts for a subdomain (subdomain.domain.tld)
562+
// e.g., api.example.com
563+
if ($partCount < 3) {
564+
return '';
565+
}
566+
567+
// Check if we have a two-part TLD (e.g., co.uk, com.au)
568+
$lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1];
569+
570+
if (in_array($lastTwoParts, Hostnames::TWO_PART_TLDS, true)) {
571+
// For two-part TLD, need at least 4 parts for subdomain
572+
// e.g., api.example.co.uk (4 parts)
573+
if ($partCount < 4) {
574+
return ''; // No subdomain, just domain.co.uk
575+
}
576+
577+
// Remove the two-part TLD and domain name (last 3 parts)
578+
// e.g., admin.api.example.co.uk -> admin.api
579+
return implode('.', array_slice($parts, 0, $partCount - 3));
580+
}
581+
582+
// Standard TLD: Remove TLD and domain (last 2 parts)
583+
// e.g., admin.api.example.com -> admin.api
584+
return implode('.', array_slice($parts, 0, $partCount - 2));
585+
}
586+
}

system/Language/en/Router.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@
1717
'missingDefaultRoute' => 'Unable to determine what should be displayed. A default route has not been specified in the routing file.',
1818
'invalidDynamicController' => 'A dynamic controller is not allowed for security reasons. Route handler: "{0}"',
1919
'invalidControllerName' => 'The namespace delimiter is a backslash (\\), not a slash (/). Route handler: "{0}"',
20+
'noRoutingAvailable' => 'No routing is available. Both auto-routing and defined routes are disabled.',
2021
];

system/Router/Attributes/Restrict.php

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -42,38 +42,6 @@
4242
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
4343
class Restrict implements RouteAttributeInterface
4444
{
45-
private const TWO_PART_TLDS = [
46-
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
47-
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
48-
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
49-
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
50-
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
51-
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
52-
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
53-
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
54-
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
55-
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
56-
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
57-
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
58-
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
59-
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
60-
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
61-
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
62-
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
63-
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
64-
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
65-
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
66-
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
67-
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
68-
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
69-
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
70-
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
71-
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
72-
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
73-
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
74-
'gob.cl',
75-
];
76-
7745
public function __construct(
7846
public array|string|null $environment = null,
7947
public array|string|null $hostname = null,
@@ -145,7 +113,7 @@ private function checkSubdomain(RequestInterface $request): void
145113
return;
146114
}
147115

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

151119
// If no subdomain exists but one is required
@@ -158,40 +126,4 @@ private function checkSubdomain(RequestInterface $request): void
158126
throw new PageNotFoundException('Access denied: subdomain is blocked.');
159127
}
160128
}
161-
162-
private function getSubdomain(RequestInterface $request): string
163-
{
164-
$host = strtolower($request->getUri()->getHost());
165-
166-
// Handle localhost and IP addresses - they don't have subdomains
167-
if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) {
168-
return '';
169-
}
170-
171-
$parts = explode('.', $host);
172-
$partCount = count($parts);
173-
174-
// Need at least 3 parts for a subdomain (subdomain.domain.tld)
175-
// e.g., api.example.com
176-
if ($partCount < 3) {
177-
return '';
178-
}
179-
// Check if we have a two-part TLD (e.g., co.uk, com.au)
180-
$lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1];
181-
if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) {
182-
// For two-part TLD, need at least 4 parts for subdomain
183-
// e.g., api.example.co.uk (4 parts)
184-
if ($partCount < 4) {
185-
return ''; // No subdomain, just domain.co.uk
186-
}
187-
188-
// Remove the two-part TLD and domain name (last 3 parts)
189-
// e.g., admin.api.example.co.uk -> admin.api
190-
return implode('.', array_slice($parts, 0, $partCount - 3));
191-
}
192-
193-
// Standard TLD: Remove TLD and domain (last 2 parts)
194-
// e.g., admin.api.example.com -> admin.api
195-
return implode('.', array_slice($parts, 0, $partCount - 2));
196-
}
197129
}

system/Router/Exceptions/RouterException.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,12 @@ public static function forInvalidControllerName(string $handler)
8080
{
8181
return new static(lang('Router.invalidControllerName', [$handler]));
8282
}
83+
84+
/**
85+
* Throw when no routing is available (both autoRoute and definedRoutes are false).
86+
*/
87+
public static function forNoRoutingAvailable(): self
88+
{
89+
return new static(lang('Router.noRoutingAvailable'));
90+
}
8391
}

0 commit comments

Comments
 (0)