├── .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, = $name ?>!
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 |
--------------------------------------------------------------------------------