├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── App.php ├── Cache.php ├── CacheHelper.php ├── Config.php ├── Database.php ├── ErrorHandler.php ├── Job.php ├── JobOption.php ├── Log.php ├── Request.php ├── Response.php ├── Route.php ├── RouteUtility.php ├── Router.php └── Utility.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.idea 4 | /.vscode 5 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 June So 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alight 2 | Alight is a light-weight PHP framework. Easily and quickly build high performance RESTful web applications. Out-of-the-box built-in routing, database, caching, error handling, logging and job scheduling libraries. Focus on creating solutions for the core process of web applications. Keep simple and extensible. 3 | 4 | ## Alight Family 5 | 6 | | Project | Description | 7 | | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | 8 | | [Alight](https://github.com/juneszh/alight) | Basic framework built-in routing, database, caching, etc. | 9 | | [Alight-Admin](https://github.com/juneszh/alight-admin) | A full admin panel extension based on Alight. No front-end coding required. | 10 | | [Alight-Project](https://github.com/juneszh/alight-project) | A template for beginner to easily create web applications by Alight/Alight-Admin. | 11 | 12 | ## Requirements 13 | PHP 7.4+ 14 | 15 | ## Getting Started 16 | * [Installation](#installation) 17 | * [Configuration](#configuration) 18 | * [Routing](#routing) 19 | * [Database](#database) 20 | * [Caching](#caching) 21 | * [Error Handling](#error-handling) 22 | * [Job Scheduling](#job-scheduling) 23 | * [Helpers](#helpers) 24 | 25 | ## Installation 26 | ### Step 1: Install Composer 27 | Don’t have Composer? [Install Composer](https://getcomposer.org/download/) first. 28 | 29 | ### Step 2: Creating Project 30 | #### Using template with create-project 31 | ```bash 32 | $ composer create-project juneszh/alight-project {PROJECT_DIRECTORY} 33 | ``` 34 | The project template contains common folder structure, suitable for MVC pattern, please refer to: [Alight-Project](https://github.com/juneszh/alight-project). 35 | 36 | *It is easy to customize folders by modifying the configuration. But the following tutorials are based on the template configuration.* 37 | 38 | ### Step 3: Configuring a Web Server 39 | Nginx example (Nginx 1.17.10, PHP 7.4.3, Ubuntu 20.04.3): 40 | ```nginx 41 | server { 42 | listen 80; 43 | listen [::]:80; 44 | 45 | root /var/www/{PROJECT_DIRECTORY}/public; 46 | 47 | index index.php; 48 | 49 | server_name {YOUR_DOMAIN}; 50 | 51 | location / { 52 | try_files $uri $uri/ /index.php?$query_string; 53 | } 54 | 55 | location ~ \.php$ { 56 | include snippets/fastcgi-php.conf; 57 | fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; 58 | } 59 | } 60 | ``` 61 | 62 | ## Configuration 63 | All of the configuration options for the Alight framework will be imported from the file 'config/app.php', which you need to create yourself. For example: 64 | 65 | File: config/app.php 66 | ```php 67 | [ 71 | 'debug' => false, 72 | 'timezone' => 'Europe/Kiev', 73 | 'storagePath' => 'storage', 74 | 'domainLevel' => 2, 75 | 'corsDomain' => null, 76 | 'corsHeaders' => null, 77 | 'corsMethods' => null, 78 | 'cacheAdapter' => null, 79 | 'errorHandler' => null, 80 | 'errorPageHandler' => null, 81 | ], 82 | 'route' => 'config/route/web.php', 83 | 'database' => [ 84 | 'type' => 'mysql', 85 | 'host' => '127.0.0.1', 86 | 'database' => 'alight', 87 | 'username' => 'root', 88 | 'password' => '', 89 | ], 90 | 'cache' => [ 91 | 'type' => 'file', 92 | ], 93 | 'job' => 'config/job.php', 94 | ]; 95 | ``` 96 | 97 | ### Get some items in the config 98 | ```php 99 | 'config/route/web.php' 122 | // Also supports multiple files 123 | // 'route' => ['config/route/web.php', config/route/api.php'] 124 | ]; 125 | ``` 126 | 127 | By the way, the route configuration supports importing specified files for **subdomains**: 128 | ```php 129 | [ 133 | //Import on any request 134 | '*' => 'config/route/web.php', 135 | //Import when requesting admin.yourdomain.com 136 | 'admin' => 'config/route/admin.php', 137 | //Import multiple files when requesting api.yourdomain.com 138 | 'api' => ['config/route/api.php', 'config/route/api_mobile.php'], 139 | ] 140 | ]; 141 | ``` 142 | 143 | ### Basic Usage 144 | ```php 145 | Alight\Route::get($pattern, $handler); 146 | // Example 147 | Alight\Route::get('/', 'Controller::index'); 148 | Alight\Route::get('/', ['Controller', 'index']); 149 | // Or try this to easy trigger hints from IDE 150 | Alight\Route::get('/', [Controller::class, 'index']); 151 | // With default args 152 | Alight\Route::get('post/list[/{page}]', [Controller::class, 'list'], ['page' => 1]); 153 | 154 | // Common HTTP request methods 155 | Alight\Route::options('/', 'handler'); 156 | Alight\Route::head('/', 'handler'); 157 | Alight\Route::post('/', 'handler'); 158 | Alight\Route::delete('/', 'handler'); 159 | Alight\Route::put('/', 'handler'); 160 | Alight\Route::patch('/', 'handler'); 161 | 162 | // Map for Custom methods 163 | Alight\Route::map(['GET', 'POST'], 'test', 'handler'); 164 | 165 | // Any for all common methods 166 | Alight\Route::any('test', 'handler'); 167 | ``` 168 | 169 | ### Regular Expressions 170 | ```php 171 | // Matches /user/42, but not /user/xyz 172 | Alight\Route::get('user/{id:\d+}', 'handler'); 173 | 174 | // Matches /user/foobar, but not /user/foo/bar 175 | Alight\Route::get('user/{name}', 'handler'); 176 | 177 | // Matches /user/foo/bar as well, using wildcards 178 | Alight\Route::get('user/{name:.+}', 'handler'); 179 | 180 | // The /{name} suffix is optional 181 | Alight\Route::get('user[/{name}]', 'handler'); 182 | 183 | // Root wildcards for single page app 184 | Alight\Route::get('/{path:.*}', 'handler'); 185 | ``` 186 | **nikic/fast-route** handles all regular expressions in the routing path. See [FastRoute Usage](https://github.com/nikic/FastRoute#defining-routes) for details. 187 | 188 | ### Options 189 | 190 | #### Group 191 | ```php 192 | Alight\Route::group('admin'); 193 | // Matches /admin/role/list 194 | Alight\Route::get('role/list', 'handler'); 195 | // Matches /admin/role/info 196 | Alight\Route::get('role/info', 'handler'); 197 | 198 | // Override the group 199 | Alight\Route::group('api'); 200 | // Matches /api/news/list 201 | Alight\Route::get('news/list', 'handler'); 202 | ``` 203 | 204 | #### Customize 'any' 205 | You can customize the methods contained in `Alight\Route::any()`. 206 | ```php 207 | Alight\Route::setAnyMethods(['GET', 'POST']); 208 | Alight\Route::any('only/get/and/post', 'handler'); 209 | ``` 210 | 211 | #### Before handler 212 | If you want to run some common code before route's handler. 213 | ```php 214 | // For example log every hit request 215 | Alight\Route::beforeHandler([svc\Request::class, 'log']); 216 | 217 | Alight\Route::get('test', 'handler'); 218 | Alight\Route::post('test', 'handler'); 219 | ``` 220 | 221 | 222 | 223 | #### Disable route caching 224 | Not recommended, but if your code requires: 225 | ```php 226 | // Effective in the current route file 227 | Alight\Route::disableCache(); 228 | ``` 229 | 230 | #### Life cycle 231 | All routing options only take effect in the current file and will be auto reset by `Alight\Route::init()` before the next file is imported. For example: 232 | 233 | File: config/admin.php 234 | ```php 235 | Alight\Route::group('admin'); 236 | Alight\Route::setAnyMethods(['GET', 'POST']); 237 | 238 | // Matches '/admin/login' by methods 'GET', 'POST' 239 | Alight\Route::any('login', 'handler'); 240 | ``` 241 | 242 | File: config/web.php 243 | ```php 244 | // Matches '/login' by methods 'GET', 'POST', 'PUT', 'DELETE', etc 245 | Alight\Route::any('login', 'handler'); 246 | ``` 247 | 248 | ### Utilities 249 | #### Cache-Control header 250 | Send a Cache-Control header to control caching in browsers and shared caches (CDN) in order to optimize the speed of access to unmodified data. 251 | ```php 252 | // Cache one day 253 | Alight\Route::get('about/us', 'handler')->cache(86400); 254 | // Or force disable cache 255 | Alight\Route::put('user/info', 'handler')->cache(0); 256 | ``` 257 | 258 | #### Handling user authorization 259 | We provide a simple authorization handler to manage user login status. 260 | ```php 261 | // Define a global authorization verification handler 262 | Alight\Route::authHandler([\svc\Auth::class, 'verify']); 263 | 264 | // Enable verification in routes 265 | Alight\Route::get('user/info', 'handler')->auth(); 266 | Alight\Route::get('user/password', 'handler')->auth(); 267 | 268 | // No verification by default 269 | Alight\Route::get('about/us', 'handler'); 270 | 271 | // In general, routing with authorization will not use browser cache 272 | // So auth() has built-in cache(0) to force disable cache 273 | // Please add cache(n) after auth() to override the configuration if you need 274 | Alight\Route::get('user/rank/list', 'handler')->auth()->cache(3600); 275 | ``` 276 | File: app/service/Auth.php 277 | ```php 278 | namespace svc; 279 | 280 | class Auth 281 | { 282 | public static function verify() 283 | { 284 | // Some codes about get user session from cookie or anywhere 285 | // Returns the user id if authorization is valid 286 | // Otherwise returns 0 or something else for failure 287 | // Then use Router::getAuthId() in the route handler to get this id again 288 | return $userId; 289 | } 290 | } 291 | ``` 292 | 293 | #### Request cooldown 294 | Many times the data submitted by the user takes time to process, and we don't want to receive the same data before it's processed. So we need to set the request cooldown time. The user will receive a 429 error when requesting again within the cooldown. 295 | ```php 296 | // Cooldown only takes effect when authorized 297 | Alight\Route::put('user/info', 'handler')->auth()->cd(2); 298 | Alight\Route::post('user/status', 'handler')->auth()->cd(2); 299 | ``` 300 | 301 | #### Cross-Origin Resource Sharing (CORS) 302 | When your API needs to be used for Ajax requests by a third-party website (or your project has multiple domains), you need to send a set of CORS headers. For specific reasons, please refer to: [Mozilla docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). 303 | 304 | ```php 305 | // Domains in config will receive the common cors header 306 | Alight\Route::put('share/config', 'handler')->cors(); 307 | 308 | // The specified domain will receive the common cors header 309 | Alight\Route::put('share/specified', 'handler')->cors('abc.com'); 310 | 311 | // The specified domain will receive the specified cors header 312 | Alight\Route::put('share/specified2', 'handler')->cors('abc.com', 'Authorization', ['GET', 'POST']); 313 | 314 | // All domains will receive a 'Access-Control-Allow-Origin: *' header 315 | Alight\Route::put('share/all/http', 'handler')->cors('*'); 316 | 317 | // All domains will receive a 'Access-Control-Allow-Origin: [From Origin]' header 318 | Alight\Route::put('share/all/https', 'handler')->cors('origin'); 319 | ``` 320 | *If your website is using CDN, please use this utility carefully. To avoid request failure after the header is cached by CDN.* 321 | 322 | 323 | ## Database 324 | Alight passes the 'database' configuration to the **catfan/medoo** directly. For specific configuration options, please refer to [Medoo Get Started](https://medoo.in/api/new). For example: 325 | 326 | File: config/app.php 327 | ```php 328 | [ 332 | 'type' => 'mysql', 333 | 'host' => '127.0.0.1', 334 | 'database' => 'alight', 335 | 'username' => 'root', 336 | 'password' => '', 337 | ], 338 | // Multiple databases (The first database is default) 339 | // 'database' => [ 340 | // 'main' => [ 341 | // 'type' => 'mysql', 342 | // 'host' => '127.0.0.1', 343 | // 'database' => 'alight', 344 | // 'username' => 'root', 345 | // 'password' => '', 346 | // ], 347 | // 'remote' => [ 348 | // 'type' => 'mysql', 349 | // 'host' => '1.1.1.1', 350 | // 'database' => 'alight', 351 | // 'username' => 'root', 352 | // 'password' => '', 353 | // ], 354 | // ] 355 | ]; 356 | ``` 357 | 358 | ### Basic Usage 359 | `Alight\Database::init()` is a static and single instance implementation of `new Medoo\Medoo()`, so it inherits all functions of `Medoo()`. Single instance makes each request connect to the database only once and reuse it, effectively reducing the number of database connections. 360 | ```php 361 | // Initializes the default database 362 | $db = \Alight\Database::init(); 363 | // Initializes others database with key 364 | $db2 = \Alight\Database::init('remote'); 365 | 366 | $userList = $db->select('user', '*', ['role' => 1]); 367 | $userInfo = $db->get('user', '*', ['id' => 1]); 368 | 369 | $db->insert('user', ['name' => 'anonymous', 'role' => 2]); 370 | $id = $db->id(); 371 | 372 | $result = $db->update('user', ['name' => 'alight'], ['id' => $id]); 373 | $result->rowCount(); 374 | 375 | ``` 376 | 377 | See [Medoo Documentation](https://medoo.in/doc) for usage details. 378 | 379 | ## Caching 380 | Alight supports multiple cache drivers and multiple cache interfaces with **symfony/cache**. The configuration options 'dsn' and 'options' will be passed to the cache adapter, more details please refer to [Available Cache Adapters](https://symfony.com/doc/current/components/cache.html#available-cache-adapters). For example: 381 | 382 | File: config/app.php 383 | ```php 384 | [ 388 | 'type' => 'file', 389 | ], 390 | // Multiple cache (The first cache is the default) 391 | // 'cache' => [ 392 | // 'file' => [ 393 | // 'type' => 'file', 394 | // ], 395 | // 'memcached' => [ 396 | // 'type' => 'memcached', 397 | // 'dsn' => 'memcached://localhost', 398 | // 'options' => [], 399 | // ], 400 | // 'redis' => [ 401 | // 'type' => 'redis', 402 | // 'dsn' => 'redis://localhost', 403 | // 'options' => [], 404 | // ], 405 | // ] 406 | ]; 407 | ``` 408 | 409 | ### Basic Usage (PSR-16) 410 | Like database, `Alight\Cache::init()` is a static and single instance implementation of the cache client to improve concurrent request performance. 411 | 412 | ```php 413 | // Initializes the default cache 414 | $cache = \Alight\Cache::init(); 415 | // Initializes others cache with key 416 | $cache2 = \Alight\Cache::init('redis'); 417 | 418 | // Use SimpleCache(PSR-16) interface 419 | if (!$cache->has('test')){ 420 | $cache->set('test', 'hello world!', 3600); 421 | } 422 | $cacheData = $cache->get('test'); 423 | $cache->delete('test'); 424 | ``` 425 | 426 | ### PSR-6 Interface 427 | ```php 428 | $cache6 = \Alight\Cache::psr6('memcached'); 429 | $cacheItem = $cache6->getItem('test'); 430 | if (!$cacheItem->isHit()){ 431 | $cacheItem->expiresAfter(3600); 432 | $cacheItem->set('hello world!'); 433 | // Bind to a tag 434 | $cacheItem->tag('alight'); 435 | } 436 | $cacheData = $cacheItem->get(); 437 | $cache6->deleteItem('test'); 438 | // Delete all cached items in the same tag 439 | $cache6->invalidateTags('alight') 440 | 441 | // Or symfony/cache adapter style 442 | $cacheData = $cache6->get('test', function ($item){ 443 | $item->expiresAfter(3600); 444 | return 'hello world!'; 445 | }); 446 | $cache6->delete('test'); 447 | ``` 448 | 449 | ### Native Interface 450 | Also supports memcached or redis native interfaces for using advanced caching: 451 | ```php 452 | $memcached = \Alight\Cache::memcached('memcached'); 453 | $memcached->increment('increment'); 454 | 455 | $redis = \Alight\Cache::redis('redis'); 456 | $redis->lPush('list', 'first'); 457 | ``` 458 | 459 | ### More Adapter 460 | **symfony/cache** supports more than 10 adapters, but we only have built-in 3 commonly used, such as filesystem, memcached, redis. If you need more adapters, you can expand it. For example: 461 | 462 | File: config/app.php 463 | ```php 464 | [ 468 | 'cacheAdapter' => [svc\Cache::class, 'adapter'], 469 | ], 470 | 'cache' => [ 471 | // ... 472 | 'apcu' => [ 473 | 'type' => 'apcu' 474 | ], 475 | 'array' => [ 476 | 'type' => 'array', 477 | 'defaultLifetime' => 3600 478 | ] 479 | ] 480 | ]; 481 | 482 | ``` 483 | 484 | File: app/service/Cache.php 485 | ```php 486 | namespace svc; 487 | 488 | use Symfony\Component\Cache\Adapter\ApcuAdapter; 489 | use Symfony\Component\Cache\Adapter\ArrayAdapter; 490 | use Symfony\Component\Cache\Adapter\NullAdapter; 491 | 492 | class Cache 493 | { 494 | public static function adapter(array $config) 495 | { 496 | switch ($config['type']) { 497 | case 'apcu': 498 | return new ApcuAdapter(); 499 | break; 500 | case 'array': 501 | return new ArrayAdapter($config['defaultLifetime']); 502 | default: 503 | return new NullAdapter(); 504 | break; 505 | } 506 | } 507 | } 508 | ``` 509 | 510 | See [Symfony Cache Component](https://symfony.com/doc/current/components/cache.html) for more information. 511 | 512 | ## Error Handling 513 | Alight catches all errors via `Alight\App::start()`. When turn on 'debug' in the app configuration, errors will be output in pretty html (by **filp/whoops**) or JSON. 514 | 515 | File: config/app.php 516 | ```php 517 | [ 521 | 'debug' => true, 522 | ] 523 | ]; 524 | 525 | ``` 526 | 527 | ### Custom Handler 528 | When turn off 'debug' in production environment, Alight just logs errors to file and outputs HTTP status. 529 | You can override these default behaviors by app configuration. For example: 530 | 531 | File: config/app.php 532 | ```php 533 | [ 537 | 'errorHandler' => [svc\Error::class, 'catch'], 538 | 'errorPageHandler' => [svc\Error::class, 'page'], 539 | ] 540 | ]; 541 | 542 | ``` 543 | 544 | File: app/service/Error.php 545 | ```php 546 | namespace svc; 547 | 548 | class Error 549 | { 550 | public static function catch(Throwable $exception) 551 | { 552 | // Some code like sending an email or using Sentry or something 553 | } 554 | 555 | public static function page(int $status) 556 | { 557 | switch ($status) { 558 | case 400: 559 | // Page code... 560 | break; 561 | case 401: 562 | // Page code... 563 | break; 564 | case 403: 565 | // Page code... 566 | break; 567 | case 404: 568 | // Page code... 569 | break; 570 | case 500: 571 | // Page code... 572 | break; 573 | default: 574 | // Page code... 575 | break; 576 | } 577 | } 578 | } 579 | ``` 580 | 581 | ## Job Scheduling 582 | 583 | If you need to run php scripts in the background periodically. 584 | ### Step 1: Setting Up CRON 585 | ```bash 586 | $ sudo contab -e 587 | ``` 588 | Add the following to the end line: 589 | ```bash 590 | * * * * * sudo -u www-data /usr/bin/php /var/www/{PROJECT_DIRECTORY}/app/scheduler.php >> /dev/null 2>&1 591 | ``` 592 | 593 | ### Step 2: Create Jobs 594 | File: config/job.php 595 | ```php 596 | Alight\Job::call('handler')->minutely(); 597 | Alight\Job::call('handler')->hourly(); 598 | Alight\Job::call('handler')->daily(); 599 | Alight\Job::call('handler')->weekly(); 600 | Alight\Job::call('handler')->monthly(); 601 | Alight\Job::call('handler')->yearly(); 602 | Alight\Job::call('handler')->everyMinutes(5); 603 | Alight\Job::call('handler')->everyHours(2); 604 | Alight\Job::call('handler')->date('2022-08-02 22:00'); 605 | ``` 606 | 607 | ### Tips 608 | Each handler runs only one process at a time, and the default max runtime of a process is 1 hour. If your handler needs a longer runtime, use timeLimit(). 609 | ```php 610 | Alight\Job::call('handler')->hourly()->timeLimit(7200);// 7200 seconds 611 | ``` 612 | 613 | ## Helpers 614 | 615 | ### Project Root Path 616 | Alight provides `Alight\App::root()` to standardize the format of file paths in project. 617 | 618 | ```php 619 | // Suppose the absolute path of the project is /var/www/my_project/ 620 | \Alight\App::root('public/favicon.ico'); // /var/www/my_project/public/favicon.ico 621 | 622 | // Of course, you can also use absolute path files with the first character '/' 623 | \Alight\App::root('/var/data/config/web.php'); 624 | ``` 625 | 626 | The file paths in the configuration are all based on the `Alight\App::root()`. For example: 627 | ```php 628 | Alight\App::start([ 629 | 'route' => 'config/route/web.php', // /var/www/my_project/config/route/web.php 630 | 'job' => 'config/job.php' // /var/www/my_project/config/job.php 631 | ]); 632 | ``` 633 | ### API Response 634 | Alight provides `Alight\Response::api()` to standardize the format of API Response. 635 | ```php 636 | HTTP 200 OK 637 | 638 | { 639 | "error": 0, // API error code 640 | "message": "OK", // API status description 641 | "data": {} // Object data 642 | } 643 | ``` 644 | Status Definition: 645 | | HTTP Status | API Error | Description | 646 | | ----------: | --------: | ------------------------------------------------------------ | 647 | | 200 | 0 | OK | 648 | | 200 | 1xxx | General business errors, only display message to user | 649 | | 200 | 2xxx | Special business errors, need to define next action for user | 650 | | 4xx | 4xx | Client errors | 651 | | 5xx | 5xx | Server errors | 652 | 653 | For example: 654 | ```php 655 | \Alight\Response::api(0, null, ['name' => 'alight']); 656 | // Response: 657 | // HTTP 200 OK 658 | // 659 | // { 660 | // "error": 0, 661 | // "message": "OK", 662 | // "data": { 663 | // "name": "alight" 664 | // } 665 | // } 666 | 667 | \Alight\Response::api(1001, 'Invalid request parameter.'); 668 | // Response: 669 | // HTTP 200 OK 670 | // 671 | // { 672 | // "error": 1001, 673 | // "message": "Invalid request parameter.", 674 | // "data": {} 675 | // } 676 | 677 | \Alight\Response::api(500, 'Unable to connect database.'); 678 | // Response: 679 | // HTTP 500 Internal Server Error 680 | // 681 | // { 682 | // "error": 500, 683 | // "message": "Unable to connect database.", 684 | // "data": {} 685 | // } 686 | ``` 687 | 688 | 689 | 690 | ### Views 691 | Alight provides `Alight\Response::render()` to display a view template call the render method with the path of the template file and optional template data: 692 | 693 | File: app/controller/Pages.php 694 | ```php 695 | namespace ctr; 696 | class Pages 697 | { 698 | public static function index() 699 | { 700 | \Alight\Response::render('hello.php', ['name' => 'Ben']); 701 | } 702 | } 703 | ``` 704 | 705 | File: app/view/hello.php 706 | ```php 707 |

Hello, !

708 | ``` 709 | 710 | File: config/route/web.php 711 | ```php 712 | Alight\Route::get('/', [ctr\Pages::class, 'index']); 713 | ``` 714 | 715 | The project's homepage output would be: 716 | ```php 717 | Hello, Ben! 718 | ``` 719 | 720 | ### Others 721 | There are also some useful helpers placed in different namespaces. Please click the file for details: 722 | 723 | | Namespace | File | 724 | | --------------- | ---------------------------------- | 725 | | Alight\Request | [Request.php](./src/Request.php) | 726 | | Alight\Response | [Response.php](./src/Response.php) | 727 | | Alight\Utility | [Utility.php](./src/Utility.php) | 728 | 729 | ## Credits 730 | * Composer requires 731 | * [nikic/fast-route](https://github.com/nikic/FastRoute) 732 | * [catfan/medoo](https://medoo.in) 733 | * [symfony/cache](https://symfony.com/doc/current/components/cache.html) 734 | * [monolog/monolog](https://github.com/Seldaek/monolog) 735 | * [filp/whoops](https://github.com/filp/whoops) 736 | * [voku/html-min](https://github.com/voku/HtmlMin) 737 | * Special thanks 738 | * [mikecao/flight](https://flightphp.com) 739 | 740 | 741 | ## License 742 | * [MIT license](./LICENSE) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juneszh/alight", 3 | "description": "Alight is a light-weight PHP framework. Easily and quickly build high performance RESTful web applications.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "php", 8 | "lightweight", 9 | "framework", 10 | "restful", 11 | "fast-route", 12 | "medoo", 13 | "simplecache", 14 | "psr-16", 15 | "monolog", 16 | "whoops", 17 | "job-scheduler" 18 | ], 19 | "authors": [ 20 | { 21 | "name": "June So", 22 | "email": "june@alight.cc" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "Alight\\": "src/" 28 | } 29 | }, 30 | "require": { 31 | "php": ">=7.4", 32 | "nikic/fast-route": "^1.3", 33 | "catfan/medoo": "^2.1", 34 | "symfony/cache": ">=5.4", 35 | "psr/simple-cache": ">=1.0", 36 | "monolog/monolog": ">=2.10", 37 | "filp/whoops": "^2.15", 38 | "voku/html-min": "^4.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/App.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | class App 17 | { 18 | /** 19 | * Starts the framework 20 | */ 21 | public static function start() 22 | { 23 | $timezone = Config::get('app', 'timezone'); 24 | if ($timezone) { 25 | date_default_timezone_set($timezone); 26 | } 27 | 28 | ErrorHandler::start(); 29 | 30 | Router::start(); 31 | } 32 | 33 | /** 34 | * Get a path relative to project's root 35 | * 36 | * @param string $path Relative to file system's root when first character is '/' 37 | * @return string 38 | */ 39 | public static function root(string $path = ''): ?string 40 | { 41 | if ($path === null) { 42 | return null; 43 | } elseif (($path[0] ?? '') === '/') { 44 | return rtrim($path, '/'); 45 | } else { 46 | static $rootPath = null; 47 | if ($rootPath === null) { 48 | $rootPath = dirname(__DIR__, 4); 49 | } 50 | return $rootPath . '/' . rtrim($path, '/'); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Cache.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | use Memcached; 18 | use Redis; 19 | use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; 20 | use Symfony\Component\Cache\Adapter\MemcachedAdapter; 21 | use Symfony\Component\Cache\Adapter\NullAdapter; 22 | use Symfony\Component\Cache\Adapter\RedisAdapter; 23 | use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter; 24 | use Symfony\Component\Cache\Adapter\TagAwareAdapter; 25 | use Symfony\Component\Cache\Psr16Cache; 26 | 27 | class Cache 28 | { 29 | public static array $instance = []; 30 | private const DEFAULT_CONFIG = [ 31 | 'type' => '', 32 | 'dsn' => '', 33 | 'options' => [], 34 | 'namespace' => '', 35 | 'defaultLifetime' => 0, 36 | ]; 37 | 38 | private function __construct() {} 39 | 40 | private function __destruct() {} 41 | 42 | private function __clone() {} 43 | 44 | /** 45 | * Initializes the instance (psr16 alias) 46 | * 47 | * @param string $configKey 48 | * @return Psr16Cache 49 | */ 50 | public static function init(string $configKey = ''): Psr16Cache 51 | { 52 | return self::psr16($configKey); 53 | } 54 | 55 | /** 56 | * Initializes the psr16 instance 57 | * 58 | * @param string $configKey 59 | * @return Psr16Cache 60 | */ 61 | public static function psr16(string $configKey = ''): Psr16Cache 62 | { 63 | if (isset(self::$instance[$configKey][__FUNCTION__])) { 64 | $psr16Cache = self::$instance[$configKey][__FUNCTION__]; 65 | } else { 66 | $psr16Cache = new Psr16Cache(self::psr6($configKey)); 67 | self::$instance[$configKey][__FUNCTION__] = $psr16Cache; 68 | } 69 | 70 | return $psr16Cache; 71 | } 72 | 73 | /** 74 | * Initializes the psr6 instance with tags 75 | * 76 | * @param string $configKey 77 | * @return TagAwareAdapter 78 | */ 79 | public static function psr6(string $configKey = '') 80 | { 81 | if (isset(self::$instance[$configKey][__FUNCTION__])) { 82 | $psr6Cache = self::$instance[$configKey][__FUNCTION__]; 83 | } else { 84 | $config = self::getConfig($configKey); 85 | if ($config['type']) { 86 | if ($config['type'] === 'file') { 87 | $directory = App::root(Config::get('app', 'storagePath') ?: 'storage') . '/cache'; 88 | $psr6Cache = new FilesystemTagAwareAdapter($config['namespace'], $config['defaultLifetime'], $directory); 89 | } elseif ($config['type'] === 'redis') { 90 | $client = self::redis($configKey); 91 | $psr6Cache = new RedisTagAwareAdapter($client, $config['namespace'], $config['defaultLifetime']); 92 | } elseif ($config['type'] === 'memcached') { 93 | $client = self::memcached($configKey); 94 | $psr6Cache = new TagAwareAdapter(new MemcachedAdapter($client, $config['namespace'], $config['defaultLifetime'])); 95 | } else { 96 | $customCacheAdapter = Config::get('app', 'cacheAdapter'); 97 | if (!is_callable($customCacheAdapter)) { 98 | throw new Exception('Invalid cacheAdapter specified.'); 99 | } 100 | $psr6Cache = new TagAwareAdapter(call_user_func($customCacheAdapter, $config)); 101 | } 102 | } else { 103 | $psr6Cache = new TagAwareAdapter(new NullAdapter); 104 | } 105 | self::$instance[$configKey][__FUNCTION__] = $psr6Cache; 106 | } 107 | 108 | return $psr6Cache; 109 | } 110 | 111 | /** 112 | * Initializes the memcached client 113 | * 114 | * @param string $configKey 115 | * @return Memcached 116 | */ 117 | public static function memcached(string $configKey = 'memcached'): Memcached 118 | { 119 | if (isset(self::$instance[$configKey]['client'])) { 120 | $client = self::$instance[$configKey]['client']; 121 | } else { 122 | $config = self::getConfig($configKey); 123 | if ($config['type'] !== 'memcached') { 124 | throw new Exception('Incorrect type in cache configuration \'' . $configKey . '\'.'); 125 | } 126 | $client = MemcachedAdapter::createConnection($config['dsn'], $config['options']); 127 | self::$instance[$configKey]['client'] = $client; 128 | } 129 | return $client; 130 | } 131 | 132 | /** 133 | * Initializes the redis client 134 | * 135 | * @param string $configKey 136 | * @return Redis 137 | */ 138 | public static function redis(string $configKey = 'redis'): Redis 139 | { 140 | if (isset(self::$instance[$configKey]['client'])) { 141 | $client = self::$instance[$configKey]['client']; 142 | } else { 143 | $config = self::getConfig($configKey); 144 | if ($config['type'] !== 'redis') { 145 | throw new Exception('Incorrect type in cache configuration \'' . $configKey . '\'.'); 146 | } 147 | $client = RedisAdapter::createConnection($config['dsn'], $config['options']); 148 | self::$instance[$configKey]['client'] = $client; 149 | } 150 | return $client; 151 | } 152 | 153 | /** 154 | * Get config values 155 | * 156 | * @param string $configKey 157 | * @return array 158 | */ 159 | private static function getConfig(string $configKey): array 160 | { 161 | $config = Config::get('cache'); 162 | if (!$config || !is_array($config)) { 163 | throw new Exception('Missing cache configuration.'); 164 | } 165 | 166 | if (isset($config['type']) && !is_array($config['type'])) { 167 | $configCache = $config; 168 | } else { 169 | if ($configKey) { 170 | if (!isset($config[$configKey]) || !is_array($config[$configKey])) { 171 | throw new Exception('Missing cache configuration about \'' . $configKey . '\'.'); 172 | } 173 | } else { 174 | $configKey = key($config); 175 | if (!is_array($config[$configKey])) { 176 | throw new Exception('Missing cache configuration.'); 177 | } 178 | } 179 | $configCache = $config[$configKey]; 180 | } 181 | 182 | return array_replace_recursive(self::DEFAULT_CONFIG, $configCache); 183 | } 184 | 185 | /** 186 | * Pruning expired cache items 187 | * 188 | * @param array $types 189 | * @see https://symfony.com/doc/current/components/cache/cache_pools.html#component-cache-cache-pool-prune 190 | */ 191 | public static function prune(array $types = ['file']) 192 | { 193 | $config = Config::get('cache'); 194 | if ($config && is_array($config)) { 195 | if (isset($config['type'])) { 196 | $config = ['' => $config]; 197 | } 198 | foreach ($config as $_key => $_config) { 199 | if (in_array($_config['type'] ?? '', $types)) { 200 | self::psr6($_key)->prune(); 201 | } 202 | } 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/CacheHelper.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Closure; 17 | use Symfony\Contracts\Cache\ItemInterface; 18 | 19 | class CacheHelper 20 | { 21 | /** 22 | * Get result with the cache helper 23 | * 24 | * @param array|string $key Set a string as the cache key, or set the args array to generate the cache key like: class.function.args 25 | * @param ?int $time Greater than 0 means caching for seconds; equal to 0 means permanent caching; less than 0 means deleting the cache; null means return the $value without using the cache 26 | * @param mixed $value If it is an anonymous function, it will be called only when the cache expires. Return null to not save the cache. 27 | * @param string $configKey 28 | * @return mixed 29 | */ 30 | public static function get($key = [], ?int $time, $value = null, string $configKey = '') 31 | { 32 | $return = null; 33 | 34 | if ($time === null) { 35 | $return = ($value instanceof Closure) ? call_user_func($value) : $value; 36 | } else { 37 | $key = is_array($key) ? self::key($key) : ($key ? [$key] : []); 38 | if ($key) { 39 | $cache = Cache::psr6($configKey); 40 | if ($time < 0) { 41 | $cache->delete($key[0]); 42 | } else { 43 | if ($value instanceof Closure) { 44 | $return = $cache->get($key[0], function (ItemInterface $item, &$save) use ($key, $time, $value) { 45 | $tags = array_slice($key, 1); 46 | if ($tags) { 47 | $item->tag($tags); 48 | } 49 | 50 | if ($time > 0) { 51 | $item->expiresAfter($time); 52 | } 53 | 54 | $return = call_user_func($value); 55 | if ($return === null) { 56 | $save = false; 57 | } 58 | 59 | return $return; 60 | }); 61 | } elseif ($value === null) { 62 | $item = $cache->getItem($key[0]); 63 | $return = $item->get(); 64 | } else { 65 | $item = $cache->getItem($key[0]); 66 | 67 | $tags = array_slice($key, 1); 68 | if ($tags) { 69 | $item->tag($tags); 70 | } 71 | 72 | if ($time > 0) { 73 | $item->expiresAfter($time); 74 | } 75 | 76 | $item->set($value); 77 | $cache->save($item); 78 | $return = $value; 79 | } 80 | } 81 | } 82 | } 83 | 84 | return $return; 85 | } 86 | 87 | /** 88 | * Generate keys 89 | * 90 | * @param array $args 91 | * @param null|callable $classFunction 92 | * @return array 93 | */ 94 | public static function key(array $args, ?callable $classFunction = null): array 95 | { 96 | $keyItems = []; 97 | $class = ''; 98 | $function = ''; 99 | $chars = str_split(ItemInterface::RESERVED_CHARACTERS); 100 | 101 | if (is_array($classFunction) && $classFunction) { 102 | $class = str_replace($chars, '_', is_object($classFunction[0]) ? get_class($classFunction[0]) : $classFunction[0]); 103 | $function = $classFunction[1] ?? ''; 104 | } else { 105 | $backtrace = debug_backtrace(); 106 | if (isset($backtrace[1])) { 107 | if (($backtrace[1]['class'] ?? '') !== __CLASS__) { 108 | $target = $backtrace[1]; 109 | } else { 110 | $target = $backtrace[2] ?? []; 111 | } 112 | 113 | if ($target) { 114 | $class = str_replace($chars, '_', $target['class'] ?? str_replace(App::root(), '', $target['file'])); 115 | $function = $target['function'] ?? ''; 116 | } 117 | } 118 | } 119 | 120 | foreach ($args as $_index => $_arg) { 121 | if (is_array($_arg)) { 122 | $args[$_index] = join('_', $_arg); 123 | } 124 | } 125 | 126 | if ($function) { 127 | $keyItems = [$class, $function, ...$args]; 128 | } else { 129 | $keyItems = [$class, ...$args]; 130 | } 131 | $return = [join('.', $keyItems)]; 132 | 133 | if ($class) { 134 | $return[] = $class; 135 | } 136 | 137 | if ($function && $args) { 138 | $return[] = join('.', [$class, $function]); 139 | } 140 | 141 | return $return; 142 | } 143 | 144 | /** 145 | * Batch clear cache by [class] or [class, function] 146 | * 147 | * @param callable $classFunction 148 | * @param string $configKey 149 | * @return bool 150 | */ 151 | public static function clear($classFunction, string $configKey = ''): bool 152 | { 153 | if (is_array($classFunction) && $classFunction) { 154 | $cache = Cache::psr6($configKey); 155 | $chars = str_split(ItemInterface::RESERVED_CHARACTERS); 156 | 157 | $class = is_object($classFunction[0]) ? get_class($classFunction[0]) : $classFunction[0]; 158 | $function = $classFunction[1] ?? ''; 159 | 160 | if ($function) { 161 | $key = str_replace($chars, '_', $class) . '.' . $function; 162 | $tags = [$key]; 163 | $cache->deleteItem($key); 164 | } else { 165 | $key = str_replace($chars, '_', $class); 166 | $tags = [$key]; 167 | } 168 | 169 | return $cache->invalidateTags($tags); 170 | } 171 | return false; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | 18 | class Config 19 | { 20 | public const FILE = 'config/app.php'; 21 | private static array $config = []; 22 | private static array $default = [ 23 | 'app' => [ 24 | 'debug' => false, // Whether to enable error message output 25 | 'timezone' => null, // Default timezone follows php.ini 26 | 'storagePath' => 'storage', // The storage path of the files generated at runtime by framework 27 | 'domainLevel' => 2, // Get subdomains for route. For example, set 3 to match 'a' when the domain is like 'a.b.co.jp' 28 | 'corsDomain' => null, // Set a default domain array for CORS, or follow 'origin' header when set 'origin' 29 | 'corsHeaders' => null, // Set a default header array for CORS 30 | 'corsMethods' => null, // Set a default header array for CORS 31 | 'cacheAdapter' => null, // Extended cache adapter based on symfony/cache 32 | 'errorHandler' => null, // Override error handler 33 | 'errorPageHandler' => null, // Override error page handler 34 | ], 35 | 36 | /* 37 | 'route' => 'config/route.php', 38 | 'route' => ['config/route/www.php', 'config/route/api.php'], 39 | 'route' => [ 40 | '*' => 'config/route/www.php', 41 | 'api' => ['config/route/api.php', 'config/route/api2.php'], 42 | ], 43 | */ 44 | 'route' => null, 45 | 46 | /* 47 | 'database' => [ 48 | 'type' => 'mysql', 49 | 'host' => '127.0.0.1', 50 | 'database' => 'alight', 51 | 'username' => '', 52 | 'password' => '', 53 | ], 54 | 'database' => [ 55 | 'main' => [ 56 | 'type' => 'mysql', 57 | 'host' => '127.0.0.1', 58 | 'database' => 'alight', 59 | 'username' => '', 60 | 'password' => '', 61 | ], 62 | 'remote' => [ 63 | 'type' => 'mysql', 64 | 'host' => '1.1.1.1', 65 | 'database' => 'alight_remote', 66 | 'username' => '', 67 | 'password' => '', 68 | ], 69 | ], 70 | */ 71 | 'database' => [], //More options see https://medoo.in/api/new 72 | 73 | /* 74 | 'cache' => [ 75 | 'file' => [ 76 | 'type' => 'file', 77 | ], 78 | 'memcached' => [ 79 | 'type' => 'memcached', 80 | 'dsn' => 'memcached://localhost', 81 | ], 82 | 'redis' => [ 83 | 'type' => 'redis', 84 | 'dsn' => 'redis://localhost', 85 | ], 86 | ], 87 | */ 88 | 'cache' => [], 89 | 90 | /* 91 | 'job' => 'config/job.php', 92 | */ 93 | 'job' => null 94 | ]; 95 | 96 | /** 97 | * Merge default configuration and user configuration 98 | * 99 | * @return array 100 | */ 101 | private static function init() 102 | { 103 | $configFile = App::root(self::FILE); 104 | if (!file_exists($configFile)) { 105 | throw new Exception('Missing configuration file: ' . self::FILE); 106 | } 107 | 108 | $userConfig = require $configFile; 109 | 110 | return array_replace_recursive(self::$default, $userConfig); 111 | } 112 | 113 | /** 114 | * Get config values 115 | * 116 | * @param string[] $keys 117 | * @return mixed 118 | */ 119 | public static function get(string ...$keys) 120 | { 121 | if (!self::$config) { 122 | self::$config = self::init(); 123 | } 124 | 125 | $value = self::$config; 126 | if ($keys) { 127 | foreach ($keys as $key) { 128 | if (isset($value[$key])) { 129 | $value = $value[$key]; 130 | } else { 131 | $value = null; 132 | break; 133 | } 134 | } 135 | } 136 | 137 | return $value; 138 | } 139 | 140 | /** 141 | * Set config values 142 | * 143 | * @param string $class 144 | * @param null|string $option 145 | * @param mixed $value 146 | */ 147 | public static function set(string $class, ?string $option, $value) 148 | { 149 | if (!self::$config) { 150 | self::$config = self::init(); 151 | } 152 | 153 | if ($option) { 154 | self::$config[$class][$option] = $value; 155 | } else { 156 | self::$config[$class] = $value; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Database.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | use Medoo\Medoo; 18 | use PDO; 19 | 20 | class Database 21 | { 22 | public static array $instance = []; 23 | 24 | private function __construct() {} 25 | 26 | private function __destruct() {} 27 | 28 | private function __clone() {} 29 | 30 | /** 31 | * Initializes the instance 32 | * 33 | * @param string $configKey 34 | * @return Medoo 35 | */ 36 | public static function init(string $configKey = ''): Medoo 37 | { 38 | if (!isset(self::$instance[$configKey])) { 39 | $config = self::getConfig($configKey); 40 | 41 | if (!isset($config['error'])) { 42 | $config['error'] = PDO::ERRMODE_EXCEPTION; 43 | } 44 | 45 | if ($config['type'] === 'mysql' && version_compare(PHP_VERSION, '8.1.0', '<')) { 46 | $config['option'][PDO::ATTR_EMULATE_PREPARES] = false; 47 | $config['option'][PDO::ATTR_STRINGIFY_FETCHES] = false; 48 | } 49 | 50 | self::$instance[$configKey] = new Medoo($config); 51 | } 52 | 53 | return self::$instance[$configKey]; 54 | } 55 | 56 | /** 57 | * Get config values 58 | * 59 | * @param string $configKey 60 | * @return array 61 | */ 62 | private static function getConfig(string $configKey): array 63 | { 64 | $config = Config::get('database'); 65 | if (!$config || !is_array($config)) { 66 | throw new Exception('Missing database configuration.'); 67 | } 68 | 69 | if (isset($config['type']) && !is_array($config['type'])) { 70 | $configDatabase = $config; 71 | } else { 72 | if ($configKey) { 73 | if (!isset($config[$configKey]) || !is_array($config[$configKey])) { 74 | throw new Exception('Missing database configuration about \'' . $configKey . '\'.'); 75 | } 76 | } else { 77 | $configKey = key($config); 78 | if (!is_array($config[$configKey])) { 79 | throw new Exception('Missing database configuration.'); 80 | } 81 | } 82 | $configDatabase = $config[$configKey]; 83 | } 84 | 85 | return $configDatabase; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Whoops\Run; 17 | use Whoops\Exception\Formatter; 18 | use Whoops\Handler\Handler; 19 | use Whoops\Handler\PrettyPageHandler; 20 | 21 | class ErrorHandler 22 | { 23 | /** 24 | * Initializes error handler 25 | */ 26 | public static function start() 27 | { 28 | $whoops = new Run; 29 | 30 | if (Request::method()) { 31 | if (Config::get('app', 'debug')) { 32 | if (Request::isAjax()) { 33 | $whoops->pushHandler(function ($exception, $inspector, $run) { 34 | Response::api(500, null, Formatter::formatExceptionAsDataArray($inspector, false)); 35 | return Handler::QUIT; 36 | }); 37 | } else { 38 | $whoops->pushHandler(new PrettyPageHandler); 39 | } 40 | } else { 41 | $whoops->pushHandler(function ($exception, $inspector, $run) { 42 | if (Request::isAjax()) { 43 | Response::api(500); 44 | } else { 45 | Response::errorPage(500); 46 | } 47 | return Handler::QUIT; 48 | }); 49 | } 50 | } 51 | 52 | $whoops->pushHandler(function ($exception, $inspector, $run) { 53 | $errorHandler = Config::get('app', 'errorHandler'); 54 | if (is_callable($errorHandler)) { 55 | call_user_func_array($errorHandler, [$exception]); 56 | } else { 57 | Log::error($exception); 58 | } 59 | return Handler::DONE; 60 | }); 61 | 62 | $whoops->register(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Job.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | 18 | class Job 19 | { 20 | public static array $config = []; 21 | private static int $index = 0; 22 | public static int $startTime; 23 | private const TIME_LIMIT = 3600; 24 | 25 | private function __construct() {} 26 | 27 | private function __destruct() {} 28 | 29 | private function __clone() {} 30 | 31 | 32 | /** 33 | * Start run with fork process 34 | * 35 | * @return never 36 | */ 37 | public static function start() 38 | { 39 | $timezone = Config::get('app', 'timezone'); 40 | if ($timezone) { 41 | date_default_timezone_set($timezone); 42 | } 43 | 44 | ErrorHandler::start(); 45 | 46 | self::$startTime = time(); 47 | 48 | $jobs = self::getJobs(); 49 | if ($jobs) { 50 | $lockPath = App::root(Config::get('app', 'storagePath') ?: 'storage') . '/job'; 51 | if (!is_dir($lockPath) && !@mkdir($lockPath, 0777, true)) { 52 | throw new Exception('Failed to create job lock directory.'); 53 | } 54 | 55 | $logger = Log::init('job/scheduler'); 56 | 57 | $start = microtime(true); 58 | $childPid = 0; 59 | $childCount = 0; 60 | foreach ($jobs as $_key => list($_handler, $_args, $_timeLimit)) { 61 | $lockFile = $lockPath . '/' . str_replace('\\', '.', $_key) . '.lock'; 62 | if (file_exists($lockFile)) { 63 | $lastProcess = @file_get_contents($lockFile); 64 | if ($lastProcess) { 65 | list($lastPid, $lastTime, $lastLimit) = explode('|', $lastProcess); 66 | if (posix_kill((int)$lastPid, 0)) { 67 | if ($lastLimit <= 0) { 68 | continue; 69 | } elseif (self::$startTime - $lastLimit <= $lastTime) { 70 | $logger->warning($_handler, ['Last running', ['lastTime' => date('Y-m-d H:i:s', (int)$lastTime), 'timeLimit' => $lastLimit]]); 71 | continue; 72 | } else { 73 | $logger->warning($_handler, ['Kill last', ['lastTime' => date('Y-m-d H:i:s', (int)$lastTime), 'timeLimit' => $lastLimit]]); 74 | posix_kill((int)$lastPid, SIGKILL); 75 | sleep(1); 76 | } 77 | } 78 | } 79 | } 80 | 81 | $childPid = pcntl_fork(); 82 | if ($childPid === -1) { 83 | $logger->critical('', ['Unable fork']); 84 | } else if ($childPid) { 85 | // main process 86 | if ($childCount === 0) { 87 | $logger->info('', ['Start']); 88 | } 89 | ++$childCount; 90 | } else { 91 | // child process 92 | $pid = posix_getpid(); 93 | 94 | file_put_contents($lockFile, $pid . '|' . self::$startTime . '|' . $_timeLimit, LOCK_EX); 95 | $logData = $_args ? ['args' => $_args] : []; 96 | if (is_callable($_handler)) { 97 | $logger->info($_handler, array_merge(['Run'], $logData ? [$logData] : [])); 98 | 99 | $_start = microtime(true); 100 | call_user_func_array($_handler, $_args); 101 | $logData['runTime'] = number_format((microtime(true) - $_start), 3); 102 | 103 | $logger->info($_handler, array_merge(['Done'], $logData ? [$logData] : [])); 104 | } else { 105 | $logger->error($_handler, array_merge(['Missing handler'], $logData ? [$logData] : [])); 106 | } 107 | // must break foreach in child process 108 | break; 109 | } 110 | } 111 | 112 | if ($childPid) { 113 | // main process 114 | $status = null; 115 | for ($i = 0; $i < $childCount; ++$i) { 116 | pcntl_wait($status, 0); 117 | } 118 | $logger->info('', ['End', ['startTime' => date('Y-m-d H:i:s', self::$startTime), 'runTime' => number_format((microtime(true) - $start), 3)]]); 119 | } 120 | } 121 | exit; 122 | } 123 | 124 | /** 125 | * Get the jobs to run this time 126 | * 127 | * @return array 128 | */ 129 | private static function getJobs(): array 130 | { 131 | $jobs = []; 132 | 133 | $configjob = Config::get('job'); 134 | if ($configjob) { 135 | $file = App::root($configjob); 136 | if (!is_file($file)) { 137 | throw new Exception('Missing job file: ' . $file . '.'); 138 | } 139 | require $file; 140 | } 141 | 142 | if (self::$config) { 143 | $rules = self::getRules(); 144 | foreach (self::$config as $_job) { 145 | if (isset($rules[$_job['rule']])) { 146 | $_handler = is_string($_job['handler']) ? $_job['handler'] : join('::', $_job['handler']); 147 | $_key = $_handler . ($_job['args'] ? '.' . md5(json_encode($_job['args'])) : ''); 148 | $_timeLimit = $_job['timeLimit'] ?? self::TIME_LIMIT; 149 | 150 | if (!isset($jobs[$_key])) { 151 | $jobs[$_key] = [ 152 | $_handler, 153 | $_job['args'], 154 | $_timeLimit 155 | ]; 156 | } elseif ($jobs[$_key]['timeLimit'] > $_timeLimit) { 157 | $jobs[$_key]['timeLimit'] = $_timeLimit; 158 | } 159 | } 160 | } 161 | } 162 | 163 | return $jobs; 164 | } 165 | 166 | /** 167 | * Get the rules to run this time 168 | * 169 | * @return array 170 | */ 171 | private static function getRules(): array 172 | { 173 | list($Y, $m, $d, $w, $H, $i) = explode(' ', date('Y m d w H i', self::$startTime)); 174 | 175 | $rules = [ 176 | '*' => true, 177 | '*/1' => true, 178 | '*/1:' . $i => true, 179 | $i => true, 180 | $H . ':' . $i => true, 181 | $w . ' ' . $H . ':' . $i => true, 182 | $d . ' ' . $H . ':' . $i => true, 183 | $m . '-' . $d . ' ' . $H . ':' . $i => true, 184 | $Y . '-' . $m . '-' . $d . ' ' . $H . ':' . $i => true, 185 | ]; 186 | 187 | for ($iSub = 2; $iSub <= 59; ++$iSub) { 188 | if ($i % $iSub === 0) { 189 | $rules['*/' . $iSub] = true; 190 | } 191 | } 192 | 193 | for ($hSub = 2; $hSub <= 23; ++$hSub) { 194 | if ($H % $hSub === 0) { 195 | $rules['*/' . $hSub . ':' . $i] = true; 196 | } 197 | } 198 | 199 | return $rules; 200 | } 201 | 202 | /** 203 | * Push a handler to scheduler (Default is minutely) 204 | * 205 | * @param callable $handler 206 | * @param array $args 207 | * @return JobOption 208 | */ 209 | public static function call($handler, array $args = []): JobOption 210 | { 211 | ++self::$index; 212 | self::$config[self::$index] = [ 213 | 'handler' => $handler, 214 | 'args' => $args, 215 | 'rule' => '*' 216 | ]; 217 | 218 | return new JobOption(self::$index); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/JobOption.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | class JobOption 17 | { 18 | private int $index; 19 | 20 | /** 21 | * 22 | * @param int $index 23 | * @return $this 24 | */ 25 | public function __construct(int $index) 26 | { 27 | $this->index = $index; 28 | return $this; 29 | } 30 | 31 | /** 32 | * Execute the job minutely 33 | * 34 | * @return JobOption 35 | */ 36 | public function minutely(): JobOption 37 | { 38 | return $this->setRule('*'); 39 | } 40 | 41 | /** 42 | * Execute the job hourly 43 | * 44 | * @param int $minute 45 | * @return JobOption 46 | */ 47 | public function hourly(int $minute = 0): JobOption 48 | { 49 | return $this->setRule(Utility::numberPad($minute)); 50 | } 51 | 52 | /** 53 | * Execute the job daily 54 | * 55 | * @param int $hour 56 | * @param int $minute 57 | * @return JobOption 58 | */ 59 | public function daily(int $hour = 0, int $minute = 0): JobOption 60 | { 61 | return $this->setRule(Utility::numberPad($hour) . ':' . Utility::numberPad($minute)); 62 | } 63 | 64 | /** 65 | * Execute the job weekly 66 | * 67 | * @param int $dayOfWeek Sunday is 0 68 | * @param int $hour 69 | * @param int $minute 70 | * @return JobOption 71 | */ 72 | public function weekly(int $dayOfWeek, int $hour = 0, int $minute = 0): JobOption 73 | { 74 | return $this->setRule($dayOfWeek . ' ' . Utility::numberPad($hour) . ':' . Utility::numberPad($minute)); 75 | } 76 | 77 | /** 78 | * Execute the job monthly 79 | * 80 | * @param int $dayOfMonth 81 | * @param int $hour 82 | * @param int $minute 83 | * @return JobOption 84 | */ 85 | public function monthly(int $dayOfMonth, int $hour = 0, int $minute = 0): JobOption 86 | { 87 | return $this->setRule(Utility::numberPad($dayOfMonth) . ' ' . Utility::numberPad($hour) . ':' . Utility::numberPad($minute)); 88 | } 89 | 90 | /** 91 | * Execute the job yearly 92 | * 93 | * @param int $month 94 | * @param int $dayOfMonth 95 | * @param int $hour 96 | * @param int $minute 97 | * @return JobOption 98 | */ 99 | public function yearly(int $month, int $dayOfMonth, int $hour = 0, int $minute = 0): JobOption 100 | { 101 | return $this->setRule(Utility::numberPad($month) . '-' . Utility::numberPad($dayOfMonth) . ' ' . Utility::numberPad($hour) . ':' . Utility::numberPad($minute)); 102 | } 103 | 104 | /** 105 | * Execute the job every {n} minutes 106 | * 107 | * @param int $minutes 108 | * @return JobOption 109 | */ 110 | public function everyMinutes(int $minutes): JobOption 111 | { 112 | return $this->setRule('*/' . $minutes); 113 | } 114 | 115 | /** 116 | * Execute the job every {n} hours 117 | * 118 | * @param int $hours 119 | * @param int $minute 120 | * @return JobOption 121 | */ 122 | public function everyHours(int $hours, int $minute = 0): JobOption 123 | { 124 | return $this->setRule('*/' . Utility::numberPad($hours) . ':' . Utility::numberPad($minute)); 125 | } 126 | 127 | /** 128 | * Execute the job once at the specified time 129 | * 130 | * @param string $date 131 | * @return JobOption 132 | */ 133 | public function date(string $date): JobOption 134 | { 135 | return $this->setRule(date('Y-m-d H:i', strtotime($date))); 136 | } 137 | 138 | /** 139 | * Set the job rule 140 | * 141 | * @param string $rule 142 | * @return JobOption 143 | */ 144 | private function setRule(string $rule): JobOption 145 | { 146 | Job::$config[$this->index]['rule'] = $rule; 147 | return $this; 148 | } 149 | 150 | /** 151 | * Set the maximum number of seconds to execute the job (Does not force quit until next same job starts) 152 | * 153 | * @param int $seconds The default is 3600, 0 for run persistently 154 | * @return JobOption 155 | */ 156 | public function timeLimit(int $seconds): JobOption 157 | { 158 | Job::$config[$this->index][__FUNCTION__] = $seconds; 159 | return $this; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Log.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | use Monolog\Logger; 18 | use Monolog\Handler\RotatingFileHandler; 19 | use Throwable; 20 | 21 | class Log 22 | { 23 | public static $instance = []; 24 | 25 | private function __construct() {} 26 | 27 | private function __destruct() {} 28 | 29 | private function __clone() {} 30 | 31 | /** 32 | * Initializes the instance 33 | * 34 | * @param string $logName 35 | * @param int $maxFiles 36 | * @param null|int $filePermission 37 | * @return Logger 38 | */ 39 | public static function init(string $logName, int $maxFiles = 7, ?int $filePermission = null): Logger 40 | { 41 | $logName = trim($logName, '/'); 42 | if (!isset(self::$instance[$logName]) || !(self::$instance[$logName] instanceof Logger)) { 43 | $configPath = App::root(Config::get('app', 'storagePath') ?: 'storage') . '/log'; 44 | if (!is_dir($configPath) && !@mkdir($configPath, 0777, true)) { 45 | throw new Exception('Failed to create log directory.'); 46 | } 47 | 48 | self::$instance[$logName] = new Logger($logName); 49 | self::$instance[$logName]->pushHandler(new RotatingFileHandler($configPath . '/' . $logName . '.log', $maxFiles, 'debug', true, $filePermission, true)); 50 | } 51 | 52 | return self::$instance[$logName]; 53 | } 54 | 55 | /** 56 | * Default error log 57 | * 58 | * @param Throwable $t 59 | */ 60 | public static function error(Throwable $t) 61 | { 62 | $logger = self::init('error/alight'); 63 | $trace = $t->getTrace(); 64 | if (isset($_SERVER['REQUEST_URI'])) { 65 | array_unshift($trace, ['uri' => $_SERVER['REQUEST_URI']]); 66 | } 67 | $logger->error($t->getMessage(), $trace); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | class Request 17 | { 18 | public const ALLOW_METHODS = ['OPTIONS', 'HEAD', 'GET', 'POST', 'DELETE', 'PUT', 'PATCH']; 19 | 20 | /** 21 | * Property getter 22 | * 23 | * @param string $property 24 | * @param string $key 25 | * @param mixed $default 26 | * @param mixed $set 27 | * @return mixed 28 | */ 29 | private static function getter(array &$property, string $key, $default, $set = null) 30 | { 31 | if ($key) { 32 | if ($set !== null) { 33 | $property[$key] = $set; 34 | } 35 | 36 | if (isset($property[$key])) { 37 | switch (gettype($default)) { 38 | case 'boolean': 39 | return (bool) $property[$key]; 40 | case 'integer': 41 | return (int) $property[$key]; 42 | case 'double': 43 | return (float) $property[$key]; 44 | case 'string': 45 | return (string) $property[$key]; 46 | case 'array': 47 | return (array) $property[$key]; 48 | default: 49 | return $property[$key]; 50 | } 51 | } else { 52 | return $default; 53 | } 54 | } else { 55 | return $property; 56 | } 57 | } 58 | 59 | /** 60 | * Get HTTP header value, or $default if unset 61 | * 62 | * @param string $key 63 | * @param mixed $default 64 | * @param mixed $set 65 | * @return mixed 66 | */ 67 | public static function header(string $key = '', $default = null, $set = null) 68 | { 69 | static $header = null; 70 | if ($header === null) { 71 | $header = apache_request_headers() ?: []; 72 | } 73 | return self::getter($header, $key, $default, $set); 74 | } 75 | 76 | /** 77 | * Get $_GET value, or $default if unset 78 | * 79 | * @param string $key 80 | * @param mixed $default 81 | * @param mixed $set 82 | * @return mixed 83 | */ 84 | public static function get(string $key = '', $default = null, $set = null) 85 | { 86 | static $get = null; 87 | if ($get === null) { 88 | $get = $_GET ?: []; 89 | } 90 | return self::getter($get, $key, $default, $set); 91 | } 92 | 93 | /** 94 | * Get $_POST value (including json body), or $default if unset 95 | * 96 | * @param string $key 97 | * @param mixed $default 98 | * @param mixed $set 99 | * @return mixed 100 | */ 101 | public static function post(string $key = '', $default = null, $set = null) 102 | { 103 | static $post = null; 104 | if ($post === null) { 105 | $post = $_POST ?: []; 106 | if (in_array(self::method(), ['POST', 'PUT', 'DELETE', 'PATCH']) && self::isJson()) { 107 | if (Utility::isJson(self::body())) { 108 | $post = json_decode(self::body(), true); 109 | } 110 | } 111 | } 112 | return self::getter($post, $key, $default, $set); 113 | } 114 | 115 | /** 116 | * Simulate $_REQUEST, contains the contents of get(), post() 117 | * 118 | * @param string $key 119 | * @param mixed $default 120 | * @param mixed $set 121 | * @return mixed 122 | */ 123 | public static function request(string $key = '', $default = null, $set = null) 124 | { 125 | static $request = null; 126 | if ($request === null) { 127 | $request = array_replace_recursive(self::get(), self::post()); 128 | } 129 | return self::getter($request, $key, $default, $set); 130 | } 131 | 132 | /** 133 | * Get $_COOKIE value, or $default if unset 134 | * 135 | * @param string $key 136 | * @param mixed $default 137 | * @param mixed $set 138 | * @return mixed 139 | */ 140 | public static function cookie(string $key = '', $default = null, $set = null) 141 | { 142 | static $cookie = null; 143 | if ($cookie === null) { 144 | $cookie = $_COOKIE ?: []; 145 | } 146 | return self::getter($cookie, $key, $default, $set); 147 | } 148 | 149 | /** 150 | * Get $_FILES value, or $default if unset 151 | * 152 | * @param string $key 153 | * @param mixed $default 154 | * @param mixed $set 155 | * @return mixed 156 | */ 157 | public static function file(string $key = '', $default = null, $set = null) 158 | { 159 | static $file = null; 160 | if ($file === null) { 161 | $file = $_FILES ?: []; 162 | } 163 | return self::getter($file, $key, $default, $set); 164 | } 165 | 166 | /** 167 | * Checks if it's an ajax request 168 | * 169 | * @return bool 170 | */ 171 | public static function isAjax(): bool 172 | { 173 | static $ajax = null; 174 | if ($ajax === null) { 175 | $ajax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; 176 | } 177 | 178 | return $ajax; 179 | } 180 | 181 | /** 182 | * Checks if it's an json request 183 | * 184 | * @return bool 185 | */ 186 | public static function isJson(): bool 187 | { 188 | static $json = null; 189 | if ($json === null) { 190 | $json = isset($_SERVER['CONTENT_TYPE']) && (strpos($_SERVER['CONTENT_TYPE'], 'application/json') !== false); 191 | } 192 | 193 | return $json; 194 | } 195 | 196 | /** 197 | * Get the client IP (compatible proxy) 198 | * 199 | * @return string 200 | */ 201 | public static function ip(): string 202 | { 203 | static $ip = null; 204 | if ($ip === null) { 205 | $ipProxy = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']) : []; 206 | $ip = $ipProxy[0] ?? ($_SERVER['REMOTE_ADDR'] ?? ($_SERVER['HTTP_CLIENT_IP'] ?? '')); 207 | } 208 | 209 | return $ip; 210 | } 211 | 212 | /** 213 | * Get the User-Agent 214 | * 215 | * @return string 216 | */ 217 | public static function userAgent(): string 218 | { 219 | return $_SERVER['HTTP_USER_AGENT'] ?? ''; 220 | } 221 | 222 | /** 223 | * Get the Referrer 224 | * 225 | * @return string 226 | */ 227 | public static function referrer(): string 228 | { 229 | return $_SERVER['HTTP_REFERER'] ?? ''; 230 | } 231 | 232 | /** 233 | * Get the request method 234 | * 235 | * @return string 236 | */ 237 | public static function method(): string 238 | { 239 | static $method = null; 240 | if ($method === null) { 241 | if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { 242 | $method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); 243 | } elseif (Request::post('_method')) { 244 | $method = strtoupper(Request::post('_method')); 245 | } elseif (Request::get('_method')) { 246 | $method = strtoupper(Request::get('_method')); 247 | } else { 248 | $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? ''); 249 | } 250 | } 251 | 252 | return $method; 253 | } 254 | 255 | /** 256 | * Get the request scheme 257 | * 258 | * @return string 259 | */ 260 | public static function scheme(): string 261 | { 262 | static $scheme = null; 263 | if ($scheme === null) { 264 | if ( 265 | (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on') 266 | || 267 | (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') 268 | || 269 | (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on') 270 | || 271 | (isset($_SERVER['REQUEST_SCHEME']) && $_SERVER['REQUEST_SCHEME'] === 'https') 272 | ) { 273 | $scheme = 'https'; 274 | } else { 275 | $scheme = 'http'; 276 | } 277 | } 278 | 279 | return $scheme; 280 | } 281 | 282 | /** 283 | * Get the request host 284 | * 285 | * @return string 286 | */ 287 | public static function host(): string 288 | { 289 | static $host = null; 290 | if ($host === null) { 291 | if (isset($_SERVER['HTTP_HOST'])) { 292 | $host = $_SERVER['HTTP_HOST']; 293 | } elseif (isset($_SERVER['SERVER_NAME'])) { 294 | $host = $_SERVER['SERVER_NAME']; 295 | } else { 296 | $host = ''; 297 | } 298 | } 299 | 300 | return $host; 301 | } 302 | 303 | /** 304 | * Get the request origin 305 | * 306 | * @return string 307 | */ 308 | public static function origin(): string 309 | { 310 | static $origin = null; 311 | if ($origin === null) { 312 | $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; 313 | } 314 | 315 | return $origin; 316 | } 317 | 318 | /** 319 | * Get the base url 320 | * 321 | * @return string 322 | */ 323 | public static function baseUrl(): string 324 | { 325 | return self::scheme() . '://' . self::host(); 326 | } 327 | 328 | /** 329 | * Get the request subdomain 330 | * 331 | * @return string 332 | */ 333 | public static function subdomain(): string 334 | { 335 | static $subdomain = null; 336 | if ($subdomain === null) { 337 | $configDomainLevel = (int) Config::get('app', 'domainLevel'); 338 | $subdomain = join('.', array_slice(explode('.', self::host()), 0, -$configDomainLevel)); 339 | } 340 | 341 | return $subdomain; 342 | } 343 | 344 | /** 345 | * Get the request path 346 | * 347 | * @return string 348 | */ 349 | public static function path(): string 350 | { 351 | static $path = null; 352 | if ($path === null) { 353 | $path = $_SERVER['REQUEST_URI'] ?? ''; 354 | if (false !== $pos = strpos($path, '?')) { 355 | $path = substr($path, 0, $pos); 356 | } 357 | } 358 | 359 | return $path; 360 | } 361 | 362 | /** 363 | * Get the request body 364 | * 365 | * @return string 366 | */ 367 | public static function body(): string 368 | { 369 | static $body = null; 370 | if ($body === null) { 371 | $body = file_get_contents('php://input') ?: ''; 372 | } 373 | 374 | return $body; 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use ArrayObject; 17 | use Exception; 18 | 19 | class Response 20 | { 21 | /** 22 | * HTTP response status codes 23 | * 24 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status 25 | */ 26 | public const HTTP_STATUS = [ 27 | 100 => 'Continue', 28 | 101 => 'Switching Protocols', 29 | 102 => 'Processing', 30 | 103 => 'Early Hints', 31 | 200 => 'OK', 32 | 201 => 'Created', 33 | 202 => 'Accepted', 34 | 203 => 'Non-Authoritative Information', 35 | 204 => 'No Content', 36 | 205 => 'Reset Content', 37 | 206 => 'Partial Content', 38 | 207 => 'Multi-status', 39 | 208 => 'Already Reported', 40 | 226 => 'IM Used', 41 | 300 => 'Multiple Choices', 42 | 301 => 'Moved Permanently', 43 | 302 => 'Found', 44 | 303 => 'See Other', 45 | 304 => 'Not Modified', 46 | 305 => 'Use Proxy', 47 | 306 => 'Switch Proxy', 48 | 307 => 'Temporary Redirect', 49 | 308 => 'Permanent Redirect', 50 | 400 => 'Bad Request', 51 | 401 => 'Unauthorized', 52 | 402 => 'Payment Required', 53 | 403 => 'Forbidden', 54 | 404 => 'Not Found', 55 | 405 => 'Method Not Allowed', 56 | 406 => 'Not Acceptable', 57 | 407 => 'Proxy Authentication Required', 58 | 408 => 'Request Time-out', 59 | 409 => 'Conflict', 60 | 410 => 'Gone', 61 | 411 => 'Length Required', 62 | 412 => 'Precondition Failed', 63 | 413 => 'Request Entity Too Large', 64 | 414 => 'Request-URI Too Large', 65 | 415 => 'Unsupported Media Type', 66 | 416 => 'Requested range not satisfiable', 67 | 417 => 'Expectation Failed', 68 | 418 => 'I\'m a teapot', 69 | 421 => 'Misdirected Request', 70 | 422 => 'Unprocessable Entity', 71 | 423 => 'Locked', 72 | 424 => 'Failed Dependency', 73 | 425 => 'Unordered Collection', 74 | 426 => 'Upgrade Required', 75 | 428 => 'Precondition Required', 76 | 429 => 'Too Many Requests', 77 | 431 => 'Request Header Fields Too Large', 78 | 451 => 'Unavailable For Legal Reasons', 79 | 500 => 'Internal Server Error', 80 | 501 => 'Not Implemented', 81 | 502 => 'Bad Gateway', 82 | 503 => 'Service Unavailable', 83 | 504 => 'Gateway Time-out', 84 | 505 => 'HTTP Version not supported', 85 | 506 => 'Variant Also Negotiates', 86 | 507 => 'Insufficient Storage', 87 | 508 => 'Loop Detected', 88 | 510 => 'Not Extended', 89 | 511 => 'Network Authentication Required', 90 | ]; 91 | 92 | /** 93 | * Common cors headers 94 | */ 95 | public const CORS_HEADERS = [ 96 | 'Content-Type', 97 | 'Origin', 98 | 'X-Requested-With', 99 | 'Authorization', 100 | ]; 101 | 102 | /** 103 | * Api response template base json/jsonp format 104 | * 105 | * @param int $error 106 | * @param null|string $message 107 | * @param null|array $data 108 | * @param string $charset 109 | */ 110 | public static function api(int $error = 0, ?string $message = null, ?array $data = null, string $charset = 'utf-8') 111 | { 112 | $status = isset(self::HTTP_STATUS[$error]) ? $error : 200; 113 | $json = [ 114 | 'error' => $error, 115 | 'message' => self::HTTP_STATUS[$status] ?? '', 116 | 'data' => new ArrayObject() 117 | ]; 118 | 119 | if ($message !== null) { 120 | $json['message'] = $message; 121 | } 122 | 123 | if ($data !== null) { 124 | $json['data'] = $data; 125 | } 126 | 127 | $jsonEncode = json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 128 | if (Request::get('jsonp')) { 129 | header('Content-Type: application/javascript; charset=' . $charset, true, $status); 130 | echo Request::get('jsonp') . '(' . $jsonEncode . ')'; 131 | } else { 132 | header('Content-Type: application/json; charset=' . $charset, true, $status); 133 | echo $jsonEncode; 134 | } 135 | } 136 | 137 | /** 138 | * Error page 139 | * 140 | * @param $status 141 | * @param null|string $message 142 | */ 143 | public static function errorPage($status, ?string $message = null) 144 | { 145 | $errorPageHandler = Config::get('app', 'errorPageHandler'); 146 | if (is_callable($errorPageHandler)) { 147 | call_user_func_array($errorPageHandler, [$status, $message]); 148 | } else { 149 | if (isset(self::HTTP_STATUS[$status])) { 150 | http_response_code((int) $status); 151 | } 152 | echo '

', $status, ' ', ($message ?: self::HTTP_STATUS[$status] ?? ''), '

'; 153 | } 154 | } 155 | 156 | /** 157 | * Simple template render 158 | * 159 | * @param string $file 160 | * @param array $data 161 | */ 162 | public static function render(string $file, array $data = []) 163 | { 164 | $template = App::root($file); 165 | if (!file_exists($template)) { 166 | throw new Exception("Template file not found: {$template}."); 167 | } 168 | 169 | if ($data) { 170 | extract($data); 171 | } 172 | 173 | require $template; 174 | } 175 | 176 | /** 177 | * Sent a redirect header and exit 178 | * 179 | * @param string $url 180 | * @param int $code 181 | */ 182 | public static function redirect(string $url, int $code = 303) 183 | { 184 | header('Location: ' . $url, true, $code); 185 | } 186 | 187 | 188 | /** 189 | * Send a set of Cache-Control headers 190 | * 191 | * @param int $maxAge 192 | * @param ?int $sMaxAge 193 | * @param array $options 194 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 195 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching 196 | */ 197 | public static function cache(int $maxAge, ?int $sMaxAge = null, array $options = []) 198 | { 199 | if (!$maxAge && !$sMaxAge) { 200 | $cacheControl = ['no-cache']; 201 | header('Pragma: no-cache'); 202 | } else { 203 | $cacheControl = ['max-age=' . $maxAge]; 204 | if ($maxAge === 0) { 205 | $cacheControl[] = 'must-revalidate'; 206 | } 207 | if ($sMaxAge !== null) { 208 | $cacheControl[] = 's-maxage=' . $sMaxAge; 209 | if ($sMaxAge === 0) { 210 | $cacheControl[] = 'proxy-revalidate'; 211 | } 212 | } 213 | header_remove('Pragma'); 214 | } 215 | if ($options) { 216 | $cacheControl = array_unique(array_merge($cacheControl, $options)); 217 | } 218 | header('Cache-Control: ' . join(', ', $cacheControl)); 219 | } 220 | 221 | /** 222 | * Send a set of CORS headers 223 | * 224 | * @param null|string|array $allowOrigin 225 | * @param null|array $allowHeaders 226 | * @param null|array $allowMethods 227 | * @return bool 228 | */ 229 | public static function cors($allowOrigin, ?array $allowHeaders = null, ?array $allowMethods = null) 230 | { 231 | $cors = false; 232 | 233 | $origin = Request::origin(); 234 | if ($allowOrigin && $origin) { 235 | $originHost = parse_url($origin)['host'] ?? ''; 236 | $host = Request::host(); 237 | if ($originHost && $originHost !== $host) { 238 | if ($allowOrigin === 'default') { 239 | $allowOrigin = Config::get('app', 'corsDomain'); 240 | } 241 | 242 | if (is_array($allowOrigin)) { 243 | foreach ($allowOrigin as $domain) { 244 | if ($domain && substr($originHost, -strlen($domain)) === $domain) { 245 | $allowOrigin = $origin; 246 | break; 247 | } 248 | } 249 | } elseif ($allowOrigin === '*') { 250 | $allowOrigin = '*'; 251 | } elseif ($allowOrigin === 'origin') { 252 | $allowOrigin = $origin; 253 | } elseif ($allowOrigin && substr($originHost, -strlen($allowOrigin)) === $allowOrigin) { 254 | $allowOrigin = $origin; 255 | } else { 256 | $allowOrigin = null; 257 | } 258 | 259 | if (is_string($allowOrigin)) { 260 | $cors = true; 261 | header('Access-Control-Allow-Origin: ' . $allowOrigin); 262 | header('Access-Control-Allow-Credentials: true'); 263 | header('Vary: Origin'); 264 | 265 | if (Request::method() === 'OPTIONS') { 266 | $allowHeaders = $allowHeaders ?: (Config::get('app', 'corsHeaders') ?: self::CORS_HEADERS); 267 | $allowMethods = $allowMethods ?: (Config::get('app', 'corsMethods') ?: Request::ALLOW_METHODS); 268 | header('Access-Control-Allow-Headers: ' . join(', ', $allowHeaders)); 269 | header('Access-Control-Allow-Methods: ' . join(', ', $allowMethods)); 270 | header('Access-Control-Max-Age: 86400'); 271 | self::cache(0); 272 | } 273 | } 274 | } 275 | } 276 | 277 | return $cors; 278 | } 279 | 280 | /** 281 | * Send a ETag header 282 | * @param string $etag 283 | */ 284 | public static function eTag(string $etag = '') 285 | { 286 | header('ETag: ' . ($etag ?: Utility::randomHex())); 287 | } 288 | 289 | /** 290 | * Send a Last-Modified header 291 | * @param null|int $timestamp 292 | */ 293 | public static function lastModified(?int $timestamp = null) 294 | { 295 | header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $timestamp === null ? time() : $timestamp) . ' GMT'); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | class Route 17 | { 18 | public static array $config = []; 19 | private static int $index = 0; 20 | private static string $group = ''; 21 | private static array $anyMethods = []; 22 | private static $authHandler; 23 | private static $beforeHandler; 24 | public static bool $disableCache = false; 25 | 26 | private function __construct() {} 27 | 28 | private function __destruct() {} 29 | 30 | private function __clone() {} 31 | 32 | 33 | /** 34 | * Initializes the variables 35 | */ 36 | public static function init() 37 | { 38 | self::$config = []; 39 | self::$index = 0; 40 | self::$group = ''; 41 | self::$anyMethods = []; 42 | self::$authHandler = null; 43 | self::$beforeHandler = null; 44 | self::$disableCache = false; 45 | } 46 | 47 | /** 48 | * Add route 49 | * 50 | * @param array $methods 51 | * @param string $pattern 52 | * @param callable $handler 53 | * @param array $args 54 | * @return RouteUtility 55 | */ 56 | private static function addRoute(array $methods, string $pattern, $handler, array $args): RouteUtility 57 | { 58 | ++self::$index; 59 | $pattern = (self::$group ? '/' . self::$group : '') . '/' . trim($pattern, '/'); 60 | 61 | $config = [ 62 | 'methods' => $methods, 63 | 'pattern' => rtrim($pattern, '/'), 64 | 'handler' => $handler, 65 | 'args' => $args, 66 | ]; 67 | 68 | if (self::$authHandler) { 69 | $config['authHandler'] = self::$authHandler; 70 | } 71 | 72 | if (self::$beforeHandler) { 73 | $config['beforeHandler'] = self::$beforeHandler; 74 | } 75 | 76 | self::$config[self::$index] = $config; 77 | 78 | return new RouteUtility(self::$index); 79 | } 80 | 81 | /** 82 | * Add 'OPTIONS' method route 83 | * 84 | * @param string $pattern 85 | * @param callable $handler 86 | * @param array $args 87 | * @return RouteUtility 88 | */ 89 | public static function options(string $pattern, $handler, array $args = []): RouteUtility 90 | { 91 | return self::addRoute(['OPTIONS'], $pattern, $handler, $args); 92 | } 93 | 94 | /** 95 | * Add 'HEAD' method route 96 | * 97 | * @param string $pattern 98 | * @param callable $handler 99 | * @param array $args 100 | * @return RouteUtility 101 | */ 102 | public static function head(string $pattern, $handler, array $args = []): RouteUtility 103 | { 104 | return self::addRoute(['HEAD'], $pattern, $handler, $args); 105 | } 106 | 107 | /** 108 | * Add 'GET' method route 109 | * 110 | * @param string $pattern 111 | * @param callable $handler 112 | * @param array $args 113 | * @return RouteUtility 114 | */ 115 | public static function get(string $pattern, $handler, array $args = []): RouteUtility 116 | { 117 | return self::addRoute(['GET'], $pattern, $handler, $args); 118 | } 119 | 120 | /** 121 | * Add 'POST' method route 122 | * 123 | * @param string $pattern 124 | * @param callable $handler 125 | * @param array $args 126 | * @return RouteUtility 127 | */ 128 | public static function post(string $pattern, $handler, array $args = []): RouteUtility 129 | { 130 | return self::addRoute(['POST'], $pattern, $handler, $args); 131 | } 132 | 133 | /** 134 | * Add 'DELETE' method route 135 | * 136 | * @param string $pattern 137 | * @param callable $handler 138 | * @param array $args 139 | * @return RouteUtility 140 | */ 141 | public static function delete(string $pattern, $handler, array $args = []): RouteUtility 142 | { 143 | return self::addRoute(['DELETE'], $pattern, $handler, $args); 144 | } 145 | 146 | /** 147 | * Add 'PUT' method route 148 | * 149 | * @param string $pattern 150 | * @param callable $handler 151 | * @param array $args 152 | * @return RouteUtility 153 | */ 154 | public static function put(string $pattern, $handler, array $args = []): RouteUtility 155 | { 156 | return self::addRoute(['PUT'], $pattern, $handler, $args); 157 | } 158 | 159 | /** 160 | * Add 'PATCH' method route 161 | * 162 | * @param string $pattern 163 | * @param callable $handler 164 | * @param array $args 165 | * @return RouteUtility 166 | */ 167 | public static function patch(string $pattern, $handler, array $args = []): RouteUtility 168 | { 169 | return self::addRoute(['PATCH'], $pattern, $handler, $args); 170 | } 171 | 172 | /** 173 | * Map some methods route 174 | * 175 | * @param array $methods 176 | * @param string $pattern 177 | * @param callable $handler 178 | * @param array $args 179 | * @return RouteUtility 180 | */ 181 | public static function map(array $methods, string $pattern, $handler, array $args = []): RouteUtility 182 | { 183 | return self::addRoute($methods, $pattern, $handler, $args); 184 | } 185 | 186 | /** 187 | * Add all methods route 188 | * 189 | * @param string $pattern 190 | * @param callable $handler 191 | * @return RouteUtility 192 | */ 193 | public static function any(string $pattern, $handler, array $args = []): RouteUtility 194 | { 195 | return self::addRoute(self::$anyMethods ?: Request::ALLOW_METHODS, $pattern, $handler, $args); 196 | } 197 | 198 | /** 199 | * Specifies the methods contained in 'any' 200 | * 201 | * @param array $methods 202 | */ 203 | public static function setAnyMethods(array $methods = []) 204 | { 205 | self::$anyMethods = $methods; 206 | } 207 | 208 | /** 209 | * Set a common prefix path 210 | * 211 | * @param string $pattern 212 | */ 213 | public static function group(string $pattern) 214 | { 215 | self::$group = trim($pattern, '/'); 216 | } 217 | 218 | /** 219 | * Call a handler before route handler be called 220 | * 221 | * @param callable $handler 222 | * @param array $args 223 | */ 224 | public static function beforeHandler($handler, array $args = []) 225 | { 226 | self::$beforeHandler = [$handler, $args]; 227 | } 228 | 229 | /** 230 | * Set the global authorization handler 231 | * 232 | * @param callable $handler 233 | * @param array $args 234 | */ 235 | public static function authHandler($handler, array $args = []) 236 | { 237 | self::$authHandler = [$handler, $args]; 238 | } 239 | 240 | /** 241 | * Disable route cache 242 | */ 243 | public static function disableCache() 244 | { 245 | self::$disableCache = true; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/RouteUtility.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | class RouteUtility 17 | { 18 | private int $index; 19 | 20 | public function __construct(int $index) 21 | { 22 | $this->index = $index; 23 | return $this; 24 | } 25 | 26 | /** 27 | * Enable authorization verification 28 | * 29 | * @return RouteUtility 30 | */ 31 | public function auth(): RouteUtility 32 | { 33 | Route::$config[$this->index][__FUNCTION__] = true; 34 | return $this; 35 | } 36 | 37 | /** 38 | * Send a Cache-Control header 39 | * 40 | * @param int $maxAge 41 | * @param ?int $sMaxAge 42 | * @param array $options 43 | * @return RouteUtility 44 | */ 45 | public function cache(int $maxAge, ?int $sMaxAge = null, array $options = []): RouteUtility 46 | { 47 | Route::$config[$this->index][__FUNCTION__] = $maxAge; 48 | Route::$config[$this->index][__FUNCTION__ . 'S'] = $sMaxAge; 49 | Route::$config[$this->index][__FUNCTION__ . 'Options'] = $options; 50 | return $this; 51 | } 52 | 53 | /** 54 | * Set CORS header for current method and 'OPTIONS' 55 | * 56 | * @param null|string|array $allowOrigin default|origin|*|{custom_origin}|[custom_origin1, custom_origin2] 57 | * @param null|array $allowHeaders 58 | * @param null|array $allowMethods 59 | * @return RouteUtility 60 | */ 61 | public function cors($allowOrigin = 'default', ?array $allowHeaders = null, ?array $allowMethods = null): RouteUtility 62 | { 63 | Route::$config[$this->index][__FUNCTION__] = [$allowOrigin, $allowHeaders, $allowMethods]; 64 | Route::options(Route::$config[$this->index]['pattern'], [Response::class, 'cors'], ['allowOrigin' => $allowOrigin, 'allowHeaders' => $allowHeaders, 'allowMethods' => $allowMethods]); 65 | return $this; 66 | } 67 | 68 | /** 69 | * Set the interval between 2 requests for each user (authorization required) 70 | * 71 | * @param int $second 72 | * @return RouteUtility 73 | */ 74 | public function debounce(int $second): RouteUtility 75 | { 76 | Route::$config[$this->index][__FUNCTION__] = $second; 77 | return $this; 78 | } 79 | 80 | /** 81 | * Compress/minify the HTML 82 | * 83 | * @return RouteUtility 84 | */ 85 | public function minify(): RouteUtility 86 | { 87 | Route::$config[$this->index][__FUNCTION__] = true; 88 | return $this; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | use FastRoute; 18 | use FastRoute\RouteCollector; 19 | use FastRoute\Dispatcher; 20 | use voku\helper\HtmlMin; 21 | 22 | class Router 23 | { 24 | private static $authId; 25 | 26 | private function __construct() {} 27 | 28 | private function __destruct() {} 29 | 30 | private function __clone() {} 31 | 32 | /** 33 | * Router start 34 | */ 35 | public static function start() 36 | { 37 | if (Request::method() === 'OPTIONS') { 38 | http_response_code(204); 39 | } 40 | 41 | $routeResult = self::dispatch(self::configFiles(), Request::method(), rtrim(Request::path(), '/')); 42 | if (!$routeResult || $routeResult[0] !== Dispatcher::FOUND) { 43 | if (Request::method() === 'OPTIONS') { 44 | if (!Response::cors('default')) { 45 | http_response_code(404); 46 | } 47 | } else if (Request::method() === 'HEAD') { 48 | http_response_code(404); 49 | } else if (Request::isAjax()) { 50 | Response::api(404); 51 | } else { 52 | Response::errorPage(404); 53 | } 54 | } else { 55 | $routeData = $routeResult[1]; 56 | $routeArgs = $routeData['args'] ? $routeResult[2] + $routeData['args'] : $routeResult[2]; 57 | 58 | if (isset($routeData['minify'])) { 59 | ob_start(); 60 | register_shutdown_function(function () { 61 | $htmlMin = new HtmlMin(); 62 | echo $htmlMin->minify(ob_get_clean()); 63 | }); 64 | } 65 | 66 | Response::eTag(); 67 | Response::lastModified(); 68 | Response::cors('default'); 69 | 70 | if (isset($routeData['cache'])) { 71 | Response::cache($routeData['cache'], $routeData['cacheS'] ?? null, $routeData['cacheOptions'] ?? []); 72 | } 73 | 74 | if (isset($routeData['beforeHandler'])) { 75 | if (!is_callable($routeData['beforeHandler'][0])) { 76 | throw new Exception('Invalid beforeHandler specified.'); 77 | } 78 | call_user_func_array($routeData['beforeHandler'][0], $routeData['beforeHandler'][1]); 79 | } 80 | 81 | if (isset($routeData['auth'])) { 82 | if (!isset($routeData['cache'])) { 83 | Response::cache(0); 84 | } 85 | 86 | if ($routeData['authHandler'] ?? []) { 87 | if (!is_callable($routeData['authHandler'][0])) { 88 | throw new Exception('Invalid authHandler specified.'); 89 | } 90 | self::$authId = call_user_func_array($routeData['authHandler'][0], $routeData['authHandler'][1]); 91 | } else { 92 | throw new Exception('Missing authHandler definition.'); 93 | } 94 | 95 | if (isset($routeData['debounce'])) { 96 | self::debounce($routeData['pattern'], $routeData['debounce']); 97 | } 98 | } 99 | 100 | if (isset($routeData['cors'])) { 101 | Response::cors($routeData['cors'][0], $routeData['cors'][1], $routeData['cors'][2]); 102 | } 103 | 104 | if (!is_callable($routeData['handler'])) { 105 | throw new Exception('Invalid handler specified.'); 106 | } 107 | 108 | call_user_func_array($routeData['handler'], $routeArgs); 109 | } 110 | } 111 | 112 | /** 113 | * Get the route configuration files 114 | * 115 | * @return array 116 | */ 117 | private static function configFiles(): array 118 | { 119 | $routeFiles = []; 120 | 121 | $subdomain = Request::subdomain() ?: '@'; 122 | 123 | $configRoute = Config::get('route'); 124 | if (is_string($configRoute)) { 125 | $configRoute = [$configRoute]; 126 | } 127 | 128 | foreach ($configRoute as $_subDomain => $_files) { 129 | if (is_string($_subDomain) && $_subDomain !== '*' && $_subDomain !== $subdomain) { 130 | continue; 131 | } 132 | 133 | if ($_files) { 134 | if (is_string($_files)) { 135 | $_files = [$_files]; 136 | } 137 | 138 | foreach ($_files as $_file) { 139 | $_file = App::root($_file); 140 | if (!is_file($_file)) { 141 | throw new Exception('Missing route file: ' . $_file . '.'); 142 | } 143 | 144 | $routeFiles[] = $_file; 145 | } 146 | } 147 | } 148 | 149 | return $routeFiles; 150 | } 151 | 152 | /** 153 | * Get the results from FastRoute dispatcher 154 | * 155 | * @param array $configFiles 156 | * @param string $method 157 | * @param string $path 158 | * @return array 159 | */ 160 | private static function dispatch(array $configFiles, string $method, string $path = ''): array 161 | { 162 | $result = []; 163 | 164 | if ($configFiles && in_array($method, Request::ALLOW_METHODS)) { 165 | foreach ($configFiles as $_configFile) { 166 | $configStorage = App::root(Config::get('app', 'storagePath') ?: 'storage') . '/route/' . basename($_configFile, '.php') . '/' . filemtime($_configFile); 167 | if (!is_dir($configStorage) && !@mkdir($configStorage, 0777, true)) { 168 | throw new Exception('Failed to create route directory.'); 169 | } 170 | 171 | $dispatcher = FastRoute\cachedDispatcher(function (RouteCollector $r) use ($method, $_configFile, $configStorage) { 172 | Route::init(); 173 | require $_configFile; 174 | foreach (Route::$config as $_route) { 175 | if (!isset($_route['methods'])) { 176 | continue; 177 | } 178 | 179 | if (!in_array($method, $_route['methods'])) { 180 | continue; 181 | } 182 | 183 | $r->addRoute($method, $_route['pattern'], $_route); 184 | } 185 | 186 | $oldCacheDirs = glob(dirname($configStorage) . '/*'); 187 | if ($oldCacheDirs) { 188 | $latestTime = substr($configStorage, -10); 189 | foreach ($oldCacheDirs as $_oldDir) { 190 | if (substr($_oldDir, -10) !== $latestTime) { 191 | foreach (Request::ALLOW_METHODS as $_method) { 192 | if (is_file($_oldDir . '/' . $_method . '.php')) { 193 | @unlink($_oldDir . '/' . $_method . '.php'); 194 | } 195 | } 196 | @rmdir($_oldDir); 197 | } 198 | } 199 | } 200 | }, [ 201 | 'cacheFile' => $configStorage . '/' . $method . '.php', 202 | 'cacheDisabled' => Route::$disableCache, 203 | ]); 204 | 205 | $result = $dispatcher->dispatch($method, $path); 206 | if ($result[0] === Dispatcher::FOUND) { 207 | break; 208 | } 209 | } 210 | } 211 | 212 | return $result; 213 | } 214 | 215 | 216 | /** 217 | * Limit request interval 218 | * 219 | * @param string $pattern 220 | * @param int $second 221 | */ 222 | private static function debounce(string $pattern, int $second) 223 | { 224 | if (self::$authId) { 225 | $cache6 = Cache::psr6(); 226 | $cacheKey = 'Alight_Router.debounce.' . md5(Request::method() . ' ' . $pattern) . '.' . self::$authId; 227 | $cacheItem = $cache6->getItem($cacheKey); 228 | if ($cacheItem->isHit()) { 229 | Response::api(429); 230 | exit; 231 | } else { 232 | $cacheItem->set(1); 233 | $cacheItem->expiresAfter($second); 234 | $cacheItem->tag(['Alight_Router', 'Alight_Router.debounce']); 235 | $cache6->save($cacheItem); 236 | } 237 | } 238 | } 239 | 240 | /** 241 | * Clear route cache 242 | */ 243 | public static function clearCache() 244 | { 245 | if (PHP_SAPI !== 'cli') { 246 | throw new Exception('PHP-CLI required.'); 247 | } 248 | 249 | exec('rm -rf ' . App::root(Config::get('app', 'storagePath') ?: 'storage') . '/route/'); 250 | } 251 | 252 | /** 253 | * Get authorized user id 254 | * 255 | * @return mixed 256 | */ 257 | public static function getAuthId() 258 | { 259 | return self::$authId; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/Utility.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Alight; 15 | 16 | use Exception; 17 | 18 | class Utility 19 | { 20 | /** 21 | * Create a random hex string 22 | * 23 | * @param int $length 24 | * @return string 25 | */ 26 | public static function randomHex(int $length = 32): string 27 | { 28 | if ($length % 2 !== 0) { 29 | throw new Exception('length must be even.'); 30 | } 31 | return bin2hex(random_bytes($length / 2)); 32 | } 33 | 34 | /** 35 | * Create a unique number string 36 | * 37 | * @param int $length 38 | * @return string 39 | */ 40 | public static function uniqueNumber(int $length = 16): string 41 | { 42 | if ($length < 16) { 43 | throw new Exception('Length must be greater than 15.'); 44 | } 45 | $dateTime = date('ymdHis'); 46 | $microTime = substr((string) floor(microtime(true) * 1000), -3); 47 | $randLength = $length - 15; 48 | $randNumber = str_pad((string) random_int(0, pow(10, $randLength) - 1), $randLength, '0', STR_PAD_LEFT); 49 | return $dateTime . $microTime . $randNumber; 50 | } 51 | 52 | /** 53 | * Checks if it's an json format 54 | * 55 | * @param mixed $content 56 | * @return bool 57 | */ 58 | public static function isJson($content): bool 59 | { 60 | if (!is_string($content) || !$content || is_numeric($content)) { 61 | return false; 62 | } 63 | 64 | $string = trim($content); 65 | if (!$string || !in_array($string[0], ['{', '['])) { 66 | return false; 67 | } 68 | 69 | $result = json_decode($content); 70 | return (json_last_error() === JSON_ERROR_NONE) && $result && $result !== $content; 71 | } 72 | 73 | /** 74 | * Two-dimensional array filter and enum maker 75 | * 76 | * @param array $array 77 | * @param array $filter 78 | * @param null|string $enumKey 79 | * @param null|string $enumValue 80 | * @return array 81 | */ 82 | public static function arrayFilter(array $array, array $filter = [], ?string $enumKey = null, ?string $enumValue = null): array 83 | { 84 | if ($array) { 85 | if ($filter) { 86 | $array = array_values(array_filter($array, function ($value) use ($filter) { 87 | foreach ($filter as $k => $v) { 88 | $symbol = ''; 89 | $bracketStart = strrpos($k, '['); 90 | if ($bracketStart) { 91 | $symbol = substr($k, $bracketStart + 1, -1); 92 | $k = substr($k, 0, $bracketStart); 93 | } 94 | 95 | if (!isset($value[$k])) { 96 | return false; 97 | } elseif (is_array($v)) { 98 | if ($symbol === '!') { 99 | if (in_array($value[$k], $v)) { 100 | return false; 101 | } 102 | } elseif ($symbol === '<>') { 103 | if ($value[$k] < $v[0] && $value[$k] > $v[0]) { 104 | return false; 105 | } 106 | } elseif ($symbol === '><') { 107 | if ($value[$k] >= $v[0] && $value[$k] <= $v[0]) { 108 | return false; 109 | } 110 | } else { 111 | if (!in_array($value[$k], $v)) { 112 | return false; 113 | } 114 | } 115 | } else { 116 | if ($symbol === '!') { 117 | if ($value[$k] == $v) { 118 | return false; 119 | } 120 | } elseif ($symbol === '>') { 121 | if ($value[$k] <= $v) { 122 | return false; 123 | } 124 | } elseif ($symbol === '>=') { 125 | if ($value[$k] < $v) { 126 | return false; 127 | } 128 | } elseif ($symbol === '<') { 129 | if ($value[$k] >= $v) { 130 | return false; 131 | } 132 | } elseif ($symbol === '<=') { 133 | if ($value[$k] > $v) { 134 | return false; 135 | } 136 | } else { 137 | if ($value[$k] != $v) { 138 | return false; 139 | } 140 | } 141 | } 142 | } 143 | return true; 144 | })); 145 | } 146 | 147 | if ($enumKey || $enumValue) { 148 | $array = array_column($array, $enumValue, $enumKey); 149 | } 150 | } 151 | 152 | return $array; 153 | } 154 | 155 | /** 156 | * Pad a leading zero to the number 157 | * 158 | * @param int $number 159 | * @param int $length 160 | * @return string 161 | */ 162 | public static function numberPad(int $number, int $length = 2): string 163 | { 164 | return str_pad((string)$number, $length, '0', STR_PAD_LEFT); 165 | } 166 | } 167 | --------------------------------------------------------------------------------