├── .env.example ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── cli.php ├── composer.json ├── composer.lock ├── database.sql ├── public ├── .htaccess ├── about.php ├── assets │ └── main.css └── index.php ├── src ├── App │ ├── Config │ │ ├── Middleware.php │ │ ├── Paths.php │ │ └── Routes.php │ ├── Controllers │ │ ├── AboutController.php │ │ ├── AuthController.php │ │ ├── ErrorController.php │ │ ├── HomeController.php │ │ ├── ReceiptController.php │ │ └── TransactionController.php │ ├── Exceptions │ │ └── SessionException.php │ ├── Middleware │ │ ├── AuthRequiredMiddleware.php │ │ ├── CsrfGuardMiddleware.php │ │ ├── CsrfTokenMiddleware.php │ │ ├── FlashMiddleware.php │ │ ├── GuestOnlyMiddleware.php │ │ ├── SessionMiddleware.php │ │ ├── TemplateDataMiddleware.php │ │ └── ValidationExceptionMiddleware.php │ ├── Services │ │ ├── ReceiptService.php │ │ ├── TransactionService.php │ │ ├── UserService.php │ │ └── ValidatorService.php │ ├── bootstrap.php │ ├── container-definitions.php │ ├── functions.php │ └── views │ │ ├── about.php │ │ ├── errors │ │ └── not-found.php │ │ ├── index.php │ │ ├── login.php │ │ ├── partials │ │ ├── _csrf.php │ │ ├── _footer.php │ │ └── _header.php │ │ ├── receipts │ │ └── create.php │ │ ├── register.php │ │ └── transactions │ │ ├── create.php │ │ └── edit.php └── Framework │ ├── App.php │ ├── Container.php │ ├── Contracts │ ├── MiddlewareInterface.php │ └── RuleInterface.php │ ├── Database.php │ ├── Exceptions │ ├── ContainerException.php │ └── ValidationException.php │ ├── Http.php │ ├── Router.php │ ├── Rules │ ├── DateFormatRule.php │ ├── EmailRule.php │ ├── InRule.php │ ├── LengthMaxRule.php │ ├── MatchRule.php │ ├── MinRule.php │ ├── NumericRule.php │ ├── RequiredRule.php │ └── UrlRule.php │ ├── TemplateEngine.php │ └── Validator.php └── storage └── uploads └── .gitkeep /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV= 2 | DB_DRIVER= 3 | DB_HOST= 4 | DB_PORT= 5 | DB_NAME= 6 | DB_USER= 7 | DB_PASS= -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | imaginary-file.txt 4 | .env 5 | storage/uploads/* 6 | !storage/uploads/.gitkeep 7 | 8 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 9 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 10 | # composer.lock 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 luis 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 | # phpiggy 2 | A PHP application for tracking expenses. 3 | -------------------------------------------------------------------------------- /cli.php: -------------------------------------------------------------------------------- 1 | load(); 11 | 12 | $db = new Database($_ENV['DB_DRIVER'], [ 13 | 'host' => $_ENV['DB_HOST'], 14 | 'port' => $_ENV['DB_PORT'], 15 | 'dbname' => $_ENV['DB_NAME'] 16 | ], $_ENV['DB_USER'], $_ENV['DB_PASS']); 17 | 18 | $sqlFile = file_get_contents("./database.sql"); 19 | 20 | $db->query($sqlFile); 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luis/phpiggy", 3 | "description": "A PHP application for tracking expenses.", 4 | "type": "project", 5 | "require": { 6 | "vlucas/phpdotenv": "^5.5" 7 | }, 8 | "autoload": { 9 | "psr-4": { 10 | "Framework\\": "src/Framework", 11 | "App\\": "src/App" 12 | }, 13 | "files": [ 14 | "src/App/Config/Routes.php", 15 | "src/App/Config/Middleware.php" 16 | ] 17 | }, 18 | "scripts": { 19 | "phpiggy": "php cli.php" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "436207e0822a27298bda4f8ca95a780a", 8 | "packages": [ 9 | { 10 | "name": "graham-campbell/result-type", 11 | "version": "v1.1.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/GrahamCampbell/Result-Type.git", 15 | "reference": "672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831", 20 | "reference": "672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": "^7.2.5 || ^8.0", 25 | "phpoption/phpoption": "^1.9.1" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^8.5.32 || ^9.6.3 || ^10.0.12" 29 | }, 30 | "type": "library", 31 | "autoload": { 32 | "psr-4": { 33 | "GrahamCampbell\\ResultType\\": "src/" 34 | } 35 | }, 36 | "notification-url": "https://packagist.org/downloads/", 37 | "license": [ 38 | "MIT" 39 | ], 40 | "authors": [ 41 | { 42 | "name": "Graham Campbell", 43 | "email": "hello@gjcampbell.co.uk", 44 | "homepage": "https://github.com/GrahamCampbell" 45 | } 46 | ], 47 | "description": "An Implementation Of The Result Type", 48 | "keywords": [ 49 | "Graham Campbell", 50 | "GrahamCampbell", 51 | "Result Type", 52 | "Result-Type", 53 | "result" 54 | ], 55 | "support": { 56 | "issues": "https://github.com/GrahamCampbell/Result-Type/issues", 57 | "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.1" 58 | }, 59 | "funding": [ 60 | { 61 | "url": "https://github.com/GrahamCampbell", 62 | "type": "github" 63 | }, 64 | { 65 | "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", 66 | "type": "tidelift" 67 | } 68 | ], 69 | "time": "2023-02-25T20:23:15+00:00" 70 | }, 71 | { 72 | "name": "phpoption/phpoption", 73 | "version": "1.9.1", 74 | "source": { 75 | "type": "git", 76 | "url": "https://github.com/schmittjoh/php-option.git", 77 | "reference": "dd3a383e599f49777d8b628dadbb90cae435b87e" 78 | }, 79 | "dist": { 80 | "type": "zip", 81 | "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/dd3a383e599f49777d8b628dadbb90cae435b87e", 82 | "reference": "dd3a383e599f49777d8b628dadbb90cae435b87e", 83 | "shasum": "" 84 | }, 85 | "require": { 86 | "php": "^7.2.5 || ^8.0" 87 | }, 88 | "require-dev": { 89 | "bamarni/composer-bin-plugin": "^1.8.2", 90 | "phpunit/phpunit": "^8.5.32 || ^9.6.3 || ^10.0.12" 91 | }, 92 | "type": "library", 93 | "extra": { 94 | "bamarni-bin": { 95 | "bin-links": true, 96 | "forward-command": true 97 | }, 98 | "branch-alias": { 99 | "dev-master": "1.9-dev" 100 | } 101 | }, 102 | "autoload": { 103 | "psr-4": { 104 | "PhpOption\\": "src/PhpOption/" 105 | } 106 | }, 107 | "notification-url": "https://packagist.org/downloads/", 108 | "license": [ 109 | "Apache-2.0" 110 | ], 111 | "authors": [ 112 | { 113 | "name": "Johannes M. Schmitt", 114 | "email": "schmittjoh@gmail.com", 115 | "homepage": "https://github.com/schmittjoh" 116 | }, 117 | { 118 | "name": "Graham Campbell", 119 | "email": "hello@gjcampbell.co.uk", 120 | "homepage": "https://github.com/GrahamCampbell" 121 | } 122 | ], 123 | "description": "Option Type for PHP", 124 | "keywords": [ 125 | "language", 126 | "option", 127 | "php", 128 | "type" 129 | ], 130 | "support": { 131 | "issues": "https://github.com/schmittjoh/php-option/issues", 132 | "source": "https://github.com/schmittjoh/php-option/tree/1.9.1" 133 | }, 134 | "funding": [ 135 | { 136 | "url": "https://github.com/GrahamCampbell", 137 | "type": "github" 138 | }, 139 | { 140 | "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", 141 | "type": "tidelift" 142 | } 143 | ], 144 | "time": "2023-02-25T19:38:58+00:00" 145 | }, 146 | { 147 | "name": "symfony/polyfill-ctype", 148 | "version": "v1.27.0", 149 | "source": { 150 | "type": "git", 151 | "url": "https://github.com/symfony/polyfill-ctype.git", 152 | "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" 153 | }, 154 | "dist": { 155 | "type": "zip", 156 | "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", 157 | "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", 158 | "shasum": "" 159 | }, 160 | "require": { 161 | "php": ">=7.1" 162 | }, 163 | "provide": { 164 | "ext-ctype": "*" 165 | }, 166 | "suggest": { 167 | "ext-ctype": "For best performance" 168 | }, 169 | "type": "library", 170 | "extra": { 171 | "branch-alias": { 172 | "dev-main": "1.27-dev" 173 | }, 174 | "thanks": { 175 | "name": "symfony/polyfill", 176 | "url": "https://github.com/symfony/polyfill" 177 | } 178 | }, 179 | "autoload": { 180 | "files": [ 181 | "bootstrap.php" 182 | ], 183 | "psr-4": { 184 | "Symfony\\Polyfill\\Ctype\\": "" 185 | } 186 | }, 187 | "notification-url": "https://packagist.org/downloads/", 188 | "license": [ 189 | "MIT" 190 | ], 191 | "authors": [ 192 | { 193 | "name": "Gert de Pagter", 194 | "email": "BackEndTea@gmail.com" 195 | }, 196 | { 197 | "name": "Symfony Community", 198 | "homepage": "https://symfony.com/contributors" 199 | } 200 | ], 201 | "description": "Symfony polyfill for ctype functions", 202 | "homepage": "https://symfony.com", 203 | "keywords": [ 204 | "compatibility", 205 | "ctype", 206 | "polyfill", 207 | "portable" 208 | ], 209 | "support": { 210 | "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" 211 | }, 212 | "funding": [ 213 | { 214 | "url": "https://symfony.com/sponsor", 215 | "type": "custom" 216 | }, 217 | { 218 | "url": "https://github.com/fabpot", 219 | "type": "github" 220 | }, 221 | { 222 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 223 | "type": "tidelift" 224 | } 225 | ], 226 | "time": "2022-11-03T14:55:06+00:00" 227 | }, 228 | { 229 | "name": "symfony/polyfill-mbstring", 230 | "version": "v1.27.0", 231 | "source": { 232 | "type": "git", 233 | "url": "https://github.com/symfony/polyfill-mbstring.git", 234 | "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" 235 | }, 236 | "dist": { 237 | "type": "zip", 238 | "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", 239 | "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", 240 | "shasum": "" 241 | }, 242 | "require": { 243 | "php": ">=7.1" 244 | }, 245 | "provide": { 246 | "ext-mbstring": "*" 247 | }, 248 | "suggest": { 249 | "ext-mbstring": "For best performance" 250 | }, 251 | "type": "library", 252 | "extra": { 253 | "branch-alias": { 254 | "dev-main": "1.27-dev" 255 | }, 256 | "thanks": { 257 | "name": "symfony/polyfill", 258 | "url": "https://github.com/symfony/polyfill" 259 | } 260 | }, 261 | "autoload": { 262 | "files": [ 263 | "bootstrap.php" 264 | ], 265 | "psr-4": { 266 | "Symfony\\Polyfill\\Mbstring\\": "" 267 | } 268 | }, 269 | "notification-url": "https://packagist.org/downloads/", 270 | "license": [ 271 | "MIT" 272 | ], 273 | "authors": [ 274 | { 275 | "name": "Nicolas Grekas", 276 | "email": "p@tchwork.com" 277 | }, 278 | { 279 | "name": "Symfony Community", 280 | "homepage": "https://symfony.com/contributors" 281 | } 282 | ], 283 | "description": "Symfony polyfill for the Mbstring extension", 284 | "homepage": "https://symfony.com", 285 | "keywords": [ 286 | "compatibility", 287 | "mbstring", 288 | "polyfill", 289 | "portable", 290 | "shim" 291 | ], 292 | "support": { 293 | "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" 294 | }, 295 | "funding": [ 296 | { 297 | "url": "https://symfony.com/sponsor", 298 | "type": "custom" 299 | }, 300 | { 301 | "url": "https://github.com/fabpot", 302 | "type": "github" 303 | }, 304 | { 305 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 306 | "type": "tidelift" 307 | } 308 | ], 309 | "time": "2022-11-03T14:55:06+00:00" 310 | }, 311 | { 312 | "name": "symfony/polyfill-php80", 313 | "version": "v1.27.0", 314 | "source": { 315 | "type": "git", 316 | "url": "https://github.com/symfony/polyfill-php80.git", 317 | "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" 318 | }, 319 | "dist": { 320 | "type": "zip", 321 | "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", 322 | "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", 323 | "shasum": "" 324 | }, 325 | "require": { 326 | "php": ">=7.1" 327 | }, 328 | "type": "library", 329 | "extra": { 330 | "branch-alias": { 331 | "dev-main": "1.27-dev" 332 | }, 333 | "thanks": { 334 | "name": "symfony/polyfill", 335 | "url": "https://github.com/symfony/polyfill" 336 | } 337 | }, 338 | "autoload": { 339 | "files": [ 340 | "bootstrap.php" 341 | ], 342 | "psr-4": { 343 | "Symfony\\Polyfill\\Php80\\": "" 344 | }, 345 | "classmap": [ 346 | "Resources/stubs" 347 | ] 348 | }, 349 | "notification-url": "https://packagist.org/downloads/", 350 | "license": [ 351 | "MIT" 352 | ], 353 | "authors": [ 354 | { 355 | "name": "Ion Bazan", 356 | "email": "ion.bazan@gmail.com" 357 | }, 358 | { 359 | "name": "Nicolas Grekas", 360 | "email": "p@tchwork.com" 361 | }, 362 | { 363 | "name": "Symfony Community", 364 | "homepage": "https://symfony.com/contributors" 365 | } 366 | ], 367 | "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", 368 | "homepage": "https://symfony.com", 369 | "keywords": [ 370 | "compatibility", 371 | "polyfill", 372 | "portable", 373 | "shim" 374 | ], 375 | "support": { 376 | "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" 377 | }, 378 | "funding": [ 379 | { 380 | "url": "https://symfony.com/sponsor", 381 | "type": "custom" 382 | }, 383 | { 384 | "url": "https://github.com/fabpot", 385 | "type": "github" 386 | }, 387 | { 388 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 389 | "type": "tidelift" 390 | } 391 | ], 392 | "time": "2022-11-03T14:55:06+00:00" 393 | }, 394 | { 395 | "name": "vlucas/phpdotenv", 396 | "version": "v5.5.0", 397 | "source": { 398 | "type": "git", 399 | "url": "https://github.com/vlucas/phpdotenv.git", 400 | "reference": "1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7" 401 | }, 402 | "dist": { 403 | "type": "zip", 404 | "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7", 405 | "reference": "1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7", 406 | "shasum": "" 407 | }, 408 | "require": { 409 | "ext-pcre": "*", 410 | "graham-campbell/result-type": "^1.0.2", 411 | "php": "^7.1.3 || ^8.0", 412 | "phpoption/phpoption": "^1.8", 413 | "symfony/polyfill-ctype": "^1.23", 414 | "symfony/polyfill-mbstring": "^1.23.1", 415 | "symfony/polyfill-php80": "^1.23.1" 416 | }, 417 | "require-dev": { 418 | "bamarni/composer-bin-plugin": "^1.4.1", 419 | "ext-filter": "*", 420 | "phpunit/phpunit": "^7.5.20 || ^8.5.30 || ^9.5.25" 421 | }, 422 | "suggest": { 423 | "ext-filter": "Required to use the boolean validator." 424 | }, 425 | "type": "library", 426 | "extra": { 427 | "bamarni-bin": { 428 | "bin-links": true, 429 | "forward-command": true 430 | }, 431 | "branch-alias": { 432 | "dev-master": "5.5-dev" 433 | } 434 | }, 435 | "autoload": { 436 | "psr-4": { 437 | "Dotenv\\": "src/" 438 | } 439 | }, 440 | "notification-url": "https://packagist.org/downloads/", 441 | "license": [ 442 | "BSD-3-Clause" 443 | ], 444 | "authors": [ 445 | { 446 | "name": "Graham Campbell", 447 | "email": "hello@gjcampbell.co.uk", 448 | "homepage": "https://github.com/GrahamCampbell" 449 | }, 450 | { 451 | "name": "Vance Lucas", 452 | "email": "vance@vancelucas.com", 453 | "homepage": "https://github.com/vlucas" 454 | } 455 | ], 456 | "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", 457 | "keywords": [ 458 | "dotenv", 459 | "env", 460 | "environment" 461 | ], 462 | "support": { 463 | "issues": "https://github.com/vlucas/phpdotenv/issues", 464 | "source": "https://github.com/vlucas/phpdotenv/tree/v5.5.0" 465 | }, 466 | "funding": [ 467 | { 468 | "url": "https://github.com/GrahamCampbell", 469 | "type": "github" 470 | }, 471 | { 472 | "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", 473 | "type": "tidelift" 474 | } 475 | ], 476 | "time": "2022-10-16T01:01:54+00:00" 477 | } 478 | ], 479 | "packages-dev": [], 480 | "aliases": [], 481 | "minimum-stability": "stable", 482 | "stability-flags": [], 483 | "prefer-stable": false, 484 | "prefer-lowest": false, 485 | "platform": [], 486 | "platform-dev": [], 487 | "plugin-api-version": "2.3.0" 488 | } 489 | -------------------------------------------------------------------------------- /database.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users( 2 | id bigint(20) unsigned NOT NULL AUTO_INCREMENT, 3 | email varchar(255) NOT NULL, 4 | password varchar(255) NOT NULL, 5 | age tinyint(3) unsigned NOT NULL, 6 | country varchar(255) NOT NULL, 7 | social_media_url varchar(255) NOT NULL, 8 | created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), 9 | updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), 10 | PRIMARY KEY(id), 11 | UNIQUE KEY(email) 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS transactions( 15 | id bigint(20) NOT NULL AUTO_INCREMENT, 16 | description varchar(255) NOT NULL, 17 | amount decimal(10,2) NOT NULL, 18 | date datetime NOT NULL, 19 | created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), 20 | updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), 21 | user_id bigint(20) unsigned NOT NULL, 22 | PRIMARY KEY(id), 23 | FOREIGN KEY(user_id) REFERENCES users(id) 24 | ); 25 | 26 | CREATE TABLE IF NOT EXISTS receipts( 27 | id bigint(20) unsigned NOT NULL AUTO_INCREMENT, 28 | original_filename varchar(255) NOT NULL, 29 | storage_filename varchar(255) NOT NULL, 30 | media_type varchar(255) NOT NULL, 31 | transaction_id bigint(20) NOT NULL, 32 | PRIMARY KEY (id), 33 | FOREIGN KEY(transaction_id) REFERENCES transactions (id) ON DELETE CASCADE 34 | ); -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | 3 | RewriteCond %{REQUEST_FILENAME} !-f 4 | RewriteCond %{REQUEST_FILENAME} !-d 5 | 6 | RewriteRule ^ /index.php [L] -------------------------------------------------------------------------------- /public/about.php: -------------------------------------------------------------------------------- 1 | About page -------------------------------------------------------------------------------- /public/assets/main.css: -------------------------------------------------------------------------------- 1 | *, 2 | :before, 3 | :after { 4 | box-sizing: border-box; 5 | border-width: 0; 6 | border-style: solid; 7 | border-color: #e5e7eb; 8 | } 9 | :before, 10 | :after { 11 | --tw-content: ""; 12 | } 13 | html { 14 | line-height: 1.5; 15 | -webkit-text-size-adjust: 100%; 16 | -moz-tab-size: 4; 17 | -o-tab-size: 4; 18 | tab-size: 4; 19 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 20 | Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, 21 | "Apple Color Emoji", "Segoe UI Emoji", Segoe UI Symbol, "Noto Color Emoji"; 22 | font-feature-settings: normal; 23 | font-variation-settings: normal; 24 | } 25 | body { 26 | margin: 0; 27 | line-height: inherit; 28 | } 29 | hr { 30 | height: 0; 31 | color: inherit; 32 | border-top-width: 1px; 33 | } 34 | abbr:where([title]) { 35 | -webkit-text-decoration: underline dotted; 36 | text-decoration: underline dotted; 37 | } 38 | h1, 39 | h2, 40 | h3, 41 | h4, 42 | h5, 43 | h6 { 44 | font-size: inherit; 45 | font-weight: inherit; 46 | } 47 | a { 48 | color: inherit; 49 | text-decoration: inherit; 50 | } 51 | b, 52 | strong { 53 | font-weight: bolder; 54 | } 55 | code, 56 | kbd, 57 | samp, 58 | pre { 59 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 60 | Liberation Mono, Courier New, monospace; 61 | font-size: 1em; 62 | } 63 | small { 64 | font-size: 80%; 65 | } 66 | sub, 67 | sup { 68 | font-size: 75%; 69 | line-height: 0; 70 | position: relative; 71 | vertical-align: baseline; 72 | } 73 | sub { 74 | bottom: -0.25em; 75 | } 76 | sup { 77 | top: -0.5em; 78 | } 79 | table { 80 | text-indent: 0; 81 | border-color: inherit; 82 | border-collapse: collapse; 83 | } 84 | button, 85 | input, 86 | optgroup, 87 | select, 88 | textarea { 89 | font-family: inherit; 90 | font-size: 100%; 91 | font-weight: inherit; 92 | line-height: inherit; 93 | color: inherit; 94 | margin: 0; 95 | padding: 0; 96 | } 97 | button, 98 | select { 99 | text-transform: none; 100 | } 101 | button, 102 | [type="button"], 103 | [type="reset"], 104 | [type="submit"] { 105 | -webkit-appearance: button; 106 | background-color: transparent; 107 | background-image: none; 108 | } 109 | :-moz-focusring { 110 | outline: auto; 111 | } 112 | :-moz-ui-invalid { 113 | box-shadow: none; 114 | } 115 | progress { 116 | vertical-align: baseline; 117 | } 118 | ::-webkit-inner-spin-button, 119 | ::-webkit-outer-spin-button { 120 | height: auto; 121 | } 122 | [type="search"] { 123 | -webkit-appearance: textfield; 124 | outline-offset: -2px; 125 | } 126 | ::-webkit-search-decoration { 127 | -webkit-appearance: none; 128 | } 129 | ::-webkit-file-upload-button { 130 | -webkit-appearance: button; 131 | font: inherit; 132 | } 133 | summary { 134 | display: list-item; 135 | } 136 | blockquote, 137 | dl, 138 | dd, 139 | h1, 140 | h2, 141 | h3, 142 | h4, 143 | h5, 144 | h6, 145 | hr, 146 | figure, 147 | p, 148 | pre { 149 | margin: 0; 150 | } 151 | fieldset { 152 | margin: 0; 153 | padding: 0; 154 | } 155 | legend { 156 | padding: 0; 157 | } 158 | ol, 159 | ul, 160 | menu { 161 | list-style: none; 162 | margin: 0; 163 | padding: 0; 164 | } 165 | textarea { 166 | resize: vertical; 167 | } 168 | input::-moz-placeholder, 169 | textarea::-moz-placeholder { 170 | opacity: 1; 171 | color: #9ca3af; 172 | } 173 | input::placeholder, 174 | textarea::placeholder { 175 | opacity: 1; 176 | color: #9ca3af; 177 | } 178 | button, 179 | [role="button"] { 180 | cursor: pointer; 181 | } 182 | :disabled { 183 | cursor: default; 184 | } 185 | img, 186 | svg, 187 | video, 188 | canvas, 189 | audio, 190 | iframe, 191 | embed, 192 | object { 193 | display: block; 194 | vertical-align: middle; 195 | } 196 | img, 197 | video { 198 | max-width: 100%; 199 | height: auto; 200 | } 201 | [hidden] { 202 | display: none; 203 | } 204 | [type="text"], 205 | [type="email"], 206 | [type="url"], 207 | [type="password"], 208 | [type="number"], 209 | [type="date"], 210 | [type="datetime-local"], 211 | [type="month"], 212 | [type="search"], 213 | [type="tel"], 214 | [type="time"], 215 | [type="week"], 216 | [multiple], 217 | textarea, 218 | select { 219 | -webkit-appearance: none; 220 | -moz-appearance: none; 221 | appearance: none; 222 | background-color: #fff; 223 | border-color: #6b7280; 224 | border-width: 1px; 225 | border-radius: 0; 226 | padding: 0.5rem 0.75rem; 227 | font-size: 1rem; 228 | line-height: 1.5rem; 229 | --tw-shadow: 0 0 #0000; 230 | } 231 | [type="text"]:focus, 232 | [type="email"]:focus, 233 | [type="url"]:focus, 234 | [type="password"]:focus, 235 | [type="number"]:focus, 236 | [type="date"]:focus, 237 | [type="datetime-local"]:focus, 238 | [type="month"]:focus, 239 | [type="search"]:focus, 240 | [type="tel"]:focus, 241 | [type="time"]:focus, 242 | [type="week"]:focus, 243 | [multiple]:focus, 244 | textarea:focus, 245 | select:focus { 246 | outline: 2px solid transparent; 247 | outline-offset: 2px; 248 | --tw-ring-inset: var(--tw-empty); 249 | --tw-ring-offset-width: 0px; 250 | --tw-ring-offset-color: #fff; 251 | --tw-ring-color: #2563eb; 252 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 253 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 254 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 255 | calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 256 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 257 | var(--tw-shadow); 258 | border-color: #2563eb; 259 | } 260 | input::-moz-placeholder, 261 | textarea::-moz-placeholder { 262 | color: #6b7280; 263 | opacity: 1; 264 | } 265 | input::placeholder, 266 | textarea::placeholder { 267 | color: #6b7280; 268 | opacity: 1; 269 | } 270 | ::-webkit-datetime-edit-fields-wrapper { 271 | padding: 0; 272 | } 273 | ::-webkit-date-and-time-value { 274 | min-height: 1.5em; 275 | } 276 | ::-webkit-datetime-edit, 277 | ::-webkit-datetime-edit-year-field, 278 | ::-webkit-datetime-edit-month-field, 279 | ::-webkit-datetime-edit-day-field, 280 | ::-webkit-datetime-edit-hour-field, 281 | ::-webkit-datetime-edit-minute-field, 282 | ::-webkit-datetime-edit-second-field, 283 | ::-webkit-datetime-edit-millisecond-field, 284 | ::-webkit-datetime-edit-meridiem-field { 285 | padding-top: 0; 286 | padding-bottom: 0; 287 | } 288 | select { 289 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 290 | background-position: right 0.5rem center; 291 | background-repeat: no-repeat; 292 | background-size: 1.5em 1.5em; 293 | padding-right: 2.5rem; 294 | -webkit-print-color-adjust: exact; 295 | print-color-adjust: exact; 296 | } 297 | [multiple] { 298 | background-image: initial; 299 | background-position: initial; 300 | background-repeat: unset; 301 | background-size: initial; 302 | padding-right: 0.75rem; 303 | -webkit-print-color-adjust: unset; 304 | print-color-adjust: unset; 305 | } 306 | [type="checkbox"], 307 | [type="radio"] { 308 | -webkit-appearance: none; 309 | -moz-appearance: none; 310 | appearance: none; 311 | padding: 0; 312 | -webkit-print-color-adjust: exact; 313 | print-color-adjust: exact; 314 | display: inline-block; 315 | vertical-align: middle; 316 | background-origin: border-box; 317 | -webkit-user-select: none; 318 | -moz-user-select: none; 319 | user-select: none; 320 | flex-shrink: 0; 321 | height: 1rem; 322 | width: 1rem; 323 | color: #2563eb; 324 | background-color: #fff; 325 | border-color: #6b7280; 326 | border-width: 1px; 327 | --tw-shadow: 0 0 #0000; 328 | } 329 | [type="checkbox"] { 330 | border-radius: 0; 331 | } 332 | [type="radio"] { 333 | border-radius: 100%; 334 | } 335 | [type="checkbox"]:focus, 336 | [type="radio"]:focus { 337 | outline: 2px solid transparent; 338 | outline-offset: 2px; 339 | --tw-ring-inset: var(--tw-empty); 340 | --tw-ring-offset-width: 2px; 341 | --tw-ring-offset-color: #fff; 342 | --tw-ring-color: #2563eb; 343 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 344 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 345 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 346 | calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 347 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 348 | var(--tw-shadow); 349 | } 350 | [type="checkbox"]:checked, 351 | [type="radio"]:checked { 352 | border-color: transparent; 353 | background-color: currentColor; 354 | background-size: 100% 100%; 355 | background-position: center; 356 | background-repeat: no-repeat; 357 | } 358 | [type="checkbox"]:checked { 359 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 360 | } 361 | [type="radio"]:checked { 362 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 363 | } 364 | [type="checkbox"]:checked:hover, 365 | [type="checkbox"]:checked:focus, 366 | [type="radio"]:checked:hover, 367 | [type="radio"]:checked:focus { 368 | border-color: transparent; 369 | background-color: currentColor; 370 | } 371 | [type="checkbox"]:indeterminate { 372 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 373 | border-color: transparent; 374 | background-color: currentColor; 375 | background-size: 100% 100%; 376 | background-position: center; 377 | background-repeat: no-repeat; 378 | } 379 | [type="checkbox"]:indeterminate:hover, 380 | [type="checkbox"]:indeterminate:focus { 381 | border-color: transparent; 382 | background-color: currentColor; 383 | } 384 | [type="file"] { 385 | background: unset; 386 | border-color: inherit; 387 | border-width: 0; 388 | border-radius: 0; 389 | padding: 0; 390 | font-size: unset; 391 | line-height: inherit; 392 | } 393 | [type="file"]:focus { 394 | outline: 1px solid ButtonText; 395 | outline: 1px auto -webkit-focus-ring-color; 396 | } 397 | *, 398 | :before, 399 | :after { 400 | --tw-border-spacing-x: 0; 401 | --tw-border-spacing-y: 0; 402 | --tw-translate-x: 0; 403 | --tw-translate-y: 0; 404 | --tw-rotate: 0; 405 | --tw-skew-x: 0; 406 | --tw-skew-y: 0; 407 | --tw-scale-x: 1; 408 | --tw-scale-y: 1; 409 | --tw-pan-x: ; 410 | --tw-pan-y: ; 411 | --tw-pinch-zoom: ; 412 | --tw-scroll-snap-strictness: proximity; 413 | --tw-gradient-from-position: ; 414 | --tw-gradient-via-position: ; 415 | --tw-gradient-to-position: ; 416 | --tw-ordinal: ; 417 | --tw-slashed-zero: ; 418 | --tw-numeric-figure: ; 419 | --tw-numeric-spacing: ; 420 | --tw-numeric-fraction: ; 421 | --tw-ring-inset: ; 422 | --tw-ring-offset-width: 0px; 423 | --tw-ring-offset-color: #fff; 424 | --tw-ring-color: rgb(59 130 246 / 0.5); 425 | --tw-ring-offset-shadow: 0 0 #0000; 426 | --tw-ring-shadow: 0 0 #0000; 427 | --tw-shadow: 0 0 #0000; 428 | --tw-shadow-colored: 0 0 #0000; 429 | --tw-blur: ; 430 | --tw-brightness: ; 431 | --tw-contrast: ; 432 | --tw-grayscale: ; 433 | --tw-hue-rotate: ; 434 | --tw-invert: ; 435 | --tw-saturate: ; 436 | --tw-sepia: ; 437 | --tw-drop-shadow: ; 438 | --tw-backdrop-blur: ; 439 | --tw-backdrop-brightness: ; 440 | --tw-backdrop-contrast: ; 441 | --tw-backdrop-grayscale: ; 442 | --tw-backdrop-hue-rotate: ; 443 | --tw-backdrop-invert: ; 444 | --tw-backdrop-opacity: ; 445 | --tw-backdrop-saturate: ; 446 | --tw-backdrop-sepia: ; 447 | } 448 | ::backdrop { 449 | --tw-border-spacing-x: 0; 450 | --tw-border-spacing-y: 0; 451 | --tw-translate-x: 0; 452 | --tw-translate-y: 0; 453 | --tw-rotate: 0; 454 | --tw-skew-x: 0; 455 | --tw-skew-y: 0; 456 | --tw-scale-x: 1; 457 | --tw-scale-y: 1; 458 | --tw-pan-x: ; 459 | --tw-pan-y: ; 460 | --tw-pinch-zoom: ; 461 | --tw-scroll-snap-strictness: proximity; 462 | --tw-gradient-from-position: ; 463 | --tw-gradient-via-position: ; 464 | --tw-gradient-to-position: ; 465 | --tw-ordinal: ; 466 | --tw-slashed-zero: ; 467 | --tw-numeric-figure: ; 468 | --tw-numeric-spacing: ; 469 | --tw-numeric-fraction: ; 470 | --tw-ring-inset: ; 471 | --tw-ring-offset-width: 0px; 472 | --tw-ring-offset-color: #fff; 473 | --tw-ring-color: rgb(59 130 246 / 0.5); 474 | --tw-ring-offset-shadow: 0 0 #0000; 475 | --tw-ring-shadow: 0 0 #0000; 476 | --tw-shadow: 0 0 #0000; 477 | --tw-shadow-colored: 0 0 #0000; 478 | --tw-blur: ; 479 | --tw-brightness: ; 480 | --tw-contrast: ; 481 | --tw-grayscale: ; 482 | --tw-hue-rotate: ; 483 | --tw-invert: ; 484 | --tw-saturate: ; 485 | --tw-sepia: ; 486 | --tw-drop-shadow: ; 487 | --tw-backdrop-blur: ; 488 | --tw-backdrop-brightness: ; 489 | --tw-backdrop-contrast: ; 490 | --tw-backdrop-grayscale: ; 491 | --tw-backdrop-hue-rotate: ; 492 | --tw-backdrop-invert: ; 493 | --tw-backdrop-opacity: ; 494 | --tw-backdrop-saturate: ; 495 | --tw-backdrop-sepia: ; 496 | } 497 | .container { 498 | width: 100%; 499 | } 500 | @media (min-width: 640px) { 501 | .container { 502 | max-width: 640px; 503 | } 504 | } 505 | @media (min-width: 768px) { 506 | .container { 507 | max-width: 768px; 508 | } 509 | } 510 | @media (min-width: 1024px) { 511 | .container { 512 | max-width: 1024px; 513 | } 514 | } 515 | @media (min-width: 1280px) { 516 | .container { 517 | max-width: 1280px; 518 | } 519 | } 520 | @media (min-width: 1536px) { 521 | .container { 522 | max-width: 1536px; 523 | } 524 | } 525 | .absolute { 526 | position: absolute; 527 | } 528 | .relative { 529 | position: relative; 530 | } 531 | .-right-1 { 532 | right: -0.25rem; 533 | } 534 | .-top-1 { 535 | top: -0.25rem; 536 | } 537 | .-m-1 { 538 | margin: -0.25rem; 539 | } 540 | .-m-1\.5 { 541 | margin: -0.375rem; 542 | } 543 | .mx-auto { 544 | margin-left: auto; 545 | margin-right: auto; 546 | } 547 | .my-12 { 548 | margin-top: 3rem; 549 | margin-bottom: 3rem; 550 | } 551 | .my-6 { 552 | margin-top: 1.5rem; 553 | margin-bottom: 1.5rem; 554 | } 555 | .-mt-px { 556 | margin-top: -1px; 557 | } 558 | .ml-2 { 559 | margin-left: 0.5rem; 560 | } 561 | .ml-3 { 562 | margin-left: 0.75rem; 563 | } 564 | .ml-4 { 565 | margin-left: 1rem; 566 | } 567 | .mr-3 { 568 | margin-right: 0.75rem; 569 | } 570 | .mt-1 { 571 | margin-top: 0.25rem; 572 | } 573 | .mt-12 { 574 | margin-top: 3rem; 575 | } 576 | .mt-2 { 577 | margin-top: 0.5rem; 578 | } 579 | .mt-4 { 580 | margin-top: 1rem; 581 | } 582 | .mt-6 { 583 | margin-top: 1.5rem; 584 | } 585 | .block { 586 | display: block; 587 | } 588 | .inline-block { 589 | display: inline-block; 590 | } 591 | .flex { 592 | display: flex; 593 | } 594 | .inline-flex { 595 | display: inline-flex; 596 | } 597 | .table { 598 | display: table; 599 | } 600 | .grid { 601 | display: grid; 602 | } 603 | .hidden { 604 | display: none; 605 | } 606 | .h-10 { 607 | height: 2.5rem; 608 | } 609 | .h-4 { 610 | height: 1rem; 611 | } 612 | .h-5 { 613 | height: 1.25rem; 614 | } 615 | .h-6 { 616 | height: 1.5rem; 617 | } 618 | .w-0 { 619 | width: 0px; 620 | } 621 | .w-10 { 622 | width: 2.5rem; 623 | } 624 | .w-4 { 625 | width: 1rem; 626 | } 627 | .w-5 { 628 | width: 1.25rem; 629 | } 630 | .w-6 { 631 | width: 1.5rem; 632 | } 633 | .w-full { 634 | width: 100%; 635 | } 636 | .min-w-full { 637 | min-width: 100%; 638 | } 639 | .max-w-2xl { 640 | max-width: 42rem; 641 | } 642 | .flex-1 { 643 | flex: 1 1 0%; 644 | } 645 | .table-auto { 646 | table-layout: auto; 647 | } 648 | .cursor-pointer { 649 | cursor: pointer; 650 | } 651 | .list-disc { 652 | list-style-type: disc; 653 | } 654 | .grid-cols-1 { 655 | grid-template-columns: repeat(1, minmax(0, 1fr)); 656 | } 657 | .items-center { 658 | align-items: center; 659 | } 660 | .justify-end { 661 | justify-content: flex-end; 662 | } 663 | .justify-center { 664 | justify-content: center; 665 | } 666 | .justify-between { 667 | justify-content: space-between; 668 | } 669 | .gap-6 { 670 | gap: 1.5rem; 671 | } 672 | .space-x-2 > :not([hidden]) ~ :not([hidden]) { 673 | --tw-space-x-reverse: 0; 674 | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); 675 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); 676 | } 677 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 678 | --tw-space-x-reverse: 0; 679 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 680 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 681 | } 682 | .divide-y > :not([hidden]) ~ :not([hidden]) { 683 | --tw-divide-y-reverse: 0; 684 | border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); 685 | border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); 686 | } 687 | .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { 688 | --tw-divide-opacity: 1; 689 | border-color: rgb(229 231 235 / var(--tw-divide-opacity)); 690 | } 691 | .divide-gray-300 > :not([hidden]) ~ :not([hidden]) { 692 | --tw-divide-opacity: 1; 693 | border-color: rgb(209 213 219 / var(--tw-divide-opacity)); 694 | } 695 | .rounded { 696 | border-radius: 0.25rem; 697 | } 698 | .rounded-md { 699 | border-radius: 0.375rem; 700 | } 701 | .rounded-l-md { 702 | border-top-left-radius: 0.375rem; 703 | border-bottom-left-radius: 0.375rem; 704 | } 705 | .rounded-r-md { 706 | border-top-right-radius: 0.375rem; 707 | border-bottom-right-radius: 0.375rem; 708 | } 709 | .border { 710 | border-width: 1px; 711 | } 712 | .border-0 { 713 | border-width: 0px; 714 | } 715 | .border-b { 716 | border-bottom-width: 1px; 717 | } 718 | .border-t { 719 | border-top-width: 1px; 720 | } 721 | .border-t-2 { 722 | border-top-width: 2px; 723 | } 724 | .border-gray-200 { 725 | --tw-border-opacity: 1; 726 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 727 | } 728 | .border-gray-300 { 729 | --tw-border-opacity: 1; 730 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 731 | } 732 | .border-indigo-500 { 733 | --tw-border-opacity: 1; 734 | border-color: rgb(99 102 241 / var(--tw-border-opacity)); 735 | } 736 | .border-transparent { 737 | border-color: transparent; 738 | } 739 | .bg-amber-50 { 740 | --tw-bg-opacity: 1; 741 | background-color: rgb(255 251 235 / var(--tw-bg-opacity)); 742 | } 743 | .bg-emerald-50 { 744 | --tw-bg-opacity: 1; 745 | background-color: rgb(236 253 245 / var(--tw-bg-opacity)); 746 | } 747 | .bg-gray-100 { 748 | --tw-bg-opacity: 1; 749 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 750 | } 751 | .bg-gray-50 { 752 | --tw-bg-opacity: 1; 753 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 754 | } 755 | .bg-indigo-50 { 756 | --tw-bg-opacity: 1; 757 | background-color: rgb(238 242 255 / var(--tw-bg-opacity)); 758 | } 759 | .bg-indigo-600 { 760 | --tw-bg-opacity: 1; 761 | background-color: rgb(79 70 229 / var(--tw-bg-opacity)); 762 | } 763 | .bg-indigo-900 { 764 | --tw-bg-opacity: 1; 765 | background-color: rgb(49 46 129 / var(--tw-bg-opacity)); 766 | } 767 | .bg-orange-400 { 768 | --tw-bg-opacity: 1; 769 | background-color: rgb(251 146 60 / var(--tw-bg-opacity)); 770 | } 771 | .bg-red-50 { 772 | --tw-bg-opacity: 1; 773 | background-color: rgb(254 242 242 / var(--tw-bg-opacity)); 774 | } 775 | .bg-sky-50 { 776 | --tw-bg-opacity: 1; 777 | background-color: rgb(240 249 255 / var(--tw-bg-opacity)); 778 | } 779 | .bg-white { 780 | --tw-bg-opacity: 1; 781 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 782 | } 783 | .p-1 { 784 | padding: 0.25rem; 785 | } 786 | .p-1\.5 { 787 | padding: 0.375rem; 788 | } 789 | .p-2 { 790 | padding: 0.5rem; 791 | } 792 | .p-4 { 793 | padding: 1rem; 794 | } 795 | .px-3 { 796 | padding-left: 0.75rem; 797 | padding-right: 0.75rem; 798 | } 799 | .px-3\.5 { 800 | padding-left: 0.875rem; 801 | padding-right: 0.875rem; 802 | } 803 | .px-4 { 804 | padding-left: 1rem; 805 | padding-right: 1rem; 806 | } 807 | .py-2 { 808 | padding-top: 0.5rem; 809 | padding-bottom: 0.5rem; 810 | } 811 | .py-2\.5 { 812 | padding-top: 0.625rem; 813 | padding-bottom: 0.625rem; 814 | } 815 | .py-4 { 816 | padding-top: 1rem; 817 | padding-bottom: 1rem; 818 | } 819 | .pb-4 { 820 | padding-bottom: 1rem; 821 | } 822 | .pl-1 { 823 | padding-left: 0.25rem; 824 | } 825 | .pr-1 { 826 | padding-right: 0.25rem; 827 | } 828 | .pt-4 { 829 | padding-top: 1rem; 830 | } 831 | .text-left { 832 | text-align: left; 833 | } 834 | .text-center { 835 | text-align: center; 836 | } 837 | .font-\[\'Outfit\'\] { 838 | font-family: Outfit; 839 | } 840 | .text-2xl { 841 | font-size: 1.5rem; 842 | line-height: 2rem; 843 | } 844 | .text-9xl { 845 | font-size: 8rem; 846 | line-height: 1; 847 | } 848 | .text-sm { 849 | font-size: 0.875rem; 850 | line-height: 1.25rem; 851 | } 852 | .text-xl { 853 | font-size: 1.25rem; 854 | line-height: 1.75rem; 855 | } 856 | .text-xs { 857 | font-size: 0.75rem; 858 | line-height: 1rem; 859 | } 860 | .font-black { 861 | font-weight: 900; 862 | } 863 | .font-bold { 864 | font-weight: 700; 865 | } 866 | .font-medium { 867 | font-weight: 500; 868 | } 869 | .font-semibold { 870 | font-weight: 600; 871 | } 872 | .text-amber-900 { 873 | --tw-text-opacity: 1; 874 | color: rgb(120 53 15 / var(--tw-text-opacity)); 875 | } 876 | .text-emerald-900 { 877 | --tw-text-opacity: 1; 878 | color: rgb(6 78 59 / var(--tw-text-opacity)); 879 | } 880 | .text-gray-300 { 881 | --tw-text-opacity: 1; 882 | color: rgb(209 213 219 / var(--tw-text-opacity)); 883 | } 884 | .text-gray-400 { 885 | --tw-text-opacity: 1; 886 | color: rgb(156 163 175 / var(--tw-text-opacity)); 887 | } 888 | .text-gray-500 { 889 | --tw-text-opacity: 1; 890 | color: rgb(107 114 128 / var(--tw-text-opacity)); 891 | } 892 | .text-gray-600 { 893 | --tw-text-opacity: 1; 894 | color: rgb(75 85 99 / var(--tw-text-opacity)); 895 | } 896 | .text-gray-700 { 897 | --tw-text-opacity: 1; 898 | color: rgb(55 65 81 / var(--tw-text-opacity)); 899 | } 900 | .text-gray-900 { 901 | --tw-text-opacity: 1; 902 | color: rgb(17 24 39 / var(--tw-text-opacity)); 903 | } 904 | .text-indigo-600 { 905 | --tw-text-opacity: 1; 906 | color: rgb(79 70 229 / var(--tw-text-opacity)); 907 | } 908 | .text-indigo-900 { 909 | --tw-text-opacity: 1; 910 | color: rgb(49 46 129 / var(--tw-text-opacity)); 911 | } 912 | .text-red-500 { 913 | --tw-text-opacity: 1; 914 | color: rgb(239 68 68 / var(--tw-text-opacity)); 915 | } 916 | .text-red-900 { 917 | --tw-text-opacity: 1; 918 | color: rgb(127 29 29 / var(--tw-text-opacity)); 919 | } 920 | .text-sky-900 { 921 | --tw-text-opacity: 1; 922 | color: rgb(12 74 110 / var(--tw-text-opacity)); 923 | } 924 | .text-slate-500 { 925 | --tw-text-opacity: 1; 926 | color: rgb(100 116 139 / var(--tw-text-opacity)); 927 | } 928 | .text-white { 929 | --tw-text-opacity: 1; 930 | color: rgb(255 255 255 / var(--tw-text-opacity)); 931 | } 932 | .shadow-md { 933 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 934 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 935 | 0 2px 4px -2px var(--tw-shadow-color); 936 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 937 | var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 938 | } 939 | .shadow-sm { 940 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 941 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 942 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 943 | var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 944 | } 945 | .ring-1 { 946 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 947 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 948 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 949 | calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 950 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 951 | var(--tw-shadow, 0 0 #0000); 952 | } 953 | .ring-inset { 954 | --tw-ring-inset: inset; 955 | } 956 | .ring-gray-300 { 957 | --tw-ring-opacity: 1; 958 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); 959 | } 960 | .transition { 961 | transition-property: color, background-color, border-color, 962 | text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, 963 | -webkit-backdrop-filter; 964 | transition-property: color, background-color, border-color, 965 | text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, 966 | backdrop-filter; 967 | transition-property: color, background-color, border-color, 968 | text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, 969 | backdrop-filter, -webkit-backdrop-filter; 970 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 971 | transition-duration: 0.15s; 972 | } 973 | html { 974 | font-size: 18px; 975 | } 976 | h3, 977 | h4 { 978 | margin-top: 0.25rem; 979 | margin-bottom: 0.25rem; 980 | } 981 | h3 { 982 | margin-bottom: 1rem; 983 | font-size: 1.5rem; 984 | line-height: 2rem; 985 | font-weight: 500; 986 | } 987 | h3:first-child { 988 | margin-bottom: 0.5rem; 989 | } 990 | hr { 991 | margin-top: 1.5rem; 992 | margin-bottom: 1.5rem; 993 | } 994 | .file\:mr-4::file-selector-button { 995 | margin-right: 1rem; 996 | } 997 | .file\:border-0::file-selector-button { 998 | border-width: 0px; 999 | } 1000 | .file\:bg-violet-100::file-selector-button { 1001 | --tw-bg-opacity: 1; 1002 | background-color: rgb(237 233 254 / var(--tw-bg-opacity)); 1003 | } 1004 | .file\:px-8::file-selector-button { 1005 | padding-left: 2rem; 1006 | padding-right: 2rem; 1007 | } 1008 | .file\:py-2::file-selector-button { 1009 | padding-top: 0.5rem; 1010 | padding-bottom: 0.5rem; 1011 | } 1012 | .file\:text-sm::file-selector-button { 1013 | font-size: 0.875rem; 1014 | line-height: 1.25rem; 1015 | } 1016 | .file\:font-semibold::file-selector-button { 1017 | font-weight: 600; 1018 | } 1019 | .file\:text-violet-700::file-selector-button { 1020 | --tw-text-opacity: 1; 1021 | color: rgb(109 40 217 / var(--tw-text-opacity)); 1022 | } 1023 | .placeholder\:text-gray-400::-moz-placeholder { 1024 | --tw-text-opacity: 1; 1025 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1026 | } 1027 | .placeholder\:text-gray-400::placeholder { 1028 | --tw-text-opacity: 1; 1029 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1030 | } 1031 | .hover\:border-gray-300:hover { 1032 | --tw-border-opacity: 1; 1033 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1034 | } 1035 | .hover\:bg-amber-500:hover { 1036 | --tw-bg-opacity: 1; 1037 | background-color: rgb(245 158 11 / var(--tw-bg-opacity)); 1038 | } 1039 | .hover\:bg-emerald-500:hover { 1040 | --tw-bg-opacity: 1; 1041 | background-color: rgb(16 185 129 / var(--tw-bg-opacity)); 1042 | } 1043 | .hover\:bg-indigo-500:hover { 1044 | --tw-bg-opacity: 1; 1045 | background-color: rgb(99 102 241 / var(--tw-bg-opacity)); 1046 | } 1047 | .hover\:bg-red-500:hover { 1048 | --tw-bg-opacity: 1; 1049 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 1050 | } 1051 | .hover\:bg-sky-500:hover { 1052 | --tw-bg-opacity: 1; 1053 | background-color: rgb(14 165 233 / var(--tw-bg-opacity)); 1054 | } 1055 | .hover\:text-gray-700:hover { 1056 | --tw-text-opacity: 1; 1057 | color: rgb(55 65 81 / var(--tw-text-opacity)); 1058 | } 1059 | .hover\:text-white:hover { 1060 | --tw-text-opacity: 1; 1061 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1062 | } 1063 | .hover\:file\:bg-violet-200::file-selector-button:hover { 1064 | --tw-bg-opacity: 1; 1065 | background-color: rgb(221 214 254 / var(--tw-bg-opacity)); 1066 | } 1067 | .focus\:border-indigo-300:focus { 1068 | --tw-border-opacity: 1; 1069 | border-color: rgb(165 180 252 / var(--tw-border-opacity)); 1070 | } 1071 | .focus\:ring:focus { 1072 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 1073 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1074 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 1075 | calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1076 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 1077 | var(--tw-shadow, 0 0 #0000); 1078 | } 1079 | .focus\:ring-2:focus { 1080 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 1081 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1082 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 1083 | calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1084 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 1085 | var(--tw-shadow, 0 0 #0000); 1086 | } 1087 | .focus\:ring-inset:focus { 1088 | --tw-ring-inset: inset; 1089 | } 1090 | .focus\:ring-indigo-200:focus { 1091 | --tw-ring-opacity: 1; 1092 | --tw-ring-color: rgb(199 210 254 / var(--tw-ring-opacity)); 1093 | } 1094 | .focus\:ring-indigo-600:focus { 1095 | --tw-ring-opacity: 1; 1096 | --tw-ring-color: rgb(79 70 229 / var(--tw-ring-opacity)); 1097 | } 1098 | .focus\:ring-opacity-50:focus { 1099 | --tw-ring-opacity: 0.5; 1100 | } 1101 | .focus\:ring-offset-0:focus { 1102 | --tw-ring-offset-width: 0px; 1103 | } 1104 | .focus-visible\:outline:focus-visible { 1105 | outline-style: solid; 1106 | } 1107 | .focus-visible\:outline-2:focus-visible { 1108 | outline-width: 2px; 1109 | } 1110 | .focus-visible\:outline-offset-2:focus-visible { 1111 | outline-offset: 2px; 1112 | } 1113 | .focus-visible\:outline-indigo-600:focus-visible { 1114 | outline-color: #4f46e5; 1115 | } 1116 | @media (min-width: 640px) { 1117 | .sm\:px-0 { 1118 | padding-left: 0; 1119 | padding-right: 0; 1120 | } 1121 | .sm\:text-sm { 1122 | font-size: 0.875rem; 1123 | line-height: 1.25rem; 1124 | } 1125 | .sm\:leading-6 { 1126 | line-height: 1.5rem; 1127 | } 1128 | } 1129 | @media (min-width: 768px) { 1130 | .md\:-mt-px { 1131 | margin-top: -1px; 1132 | } 1133 | .md\:flex { 1134 | display: flex; 1135 | } 1136 | } 1137 | @media (min-width: 1024px) { 1138 | .lg\:gap-x-10 { 1139 | -moz-column-gap: 2.5rem; 1140 | column-gap: 2.5rem; 1141 | } 1142 | } 1143 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 8 | -------------------------------------------------------------------------------- /src/App/Config/Middleware.php: -------------------------------------------------------------------------------- 1 | addMiddleware(CsrfGuardMiddleware::class); 20 | $app->addMiddleware(CsrfTokenMiddleware::class); 21 | $app->addMiddleware(TemplateDataMiddleware::class); 22 | $app->addMiddleware(ValidationExceptionMiddleware::class); 23 | $app->addMiddleware(FlashMiddleware::class); 24 | $app->addMiddleware(SessionMiddleware::class); 25 | } 26 | -------------------------------------------------------------------------------- /src/App/Config/Paths.php: -------------------------------------------------------------------------------- 1 | get('/', [HomeController::class, 'home'])->add(AuthRequiredMiddleware::class); 21 | $app->get('/about', [AboutController::class, 'about']); 22 | $app->get('/register', [AuthController::class, 'registerView'])->add(GuestOnlyMiddleware::class); 23 | $app->post('/register', [AuthController::class, 'register'])->add(GuestOnlyMiddleware::class); 24 | $app->get('/login', [AuthController::class, 'loginView'])->add(GuestOnlyMiddleware::class); 25 | $app->post('/login', [AuthController::class, 'login'])->add(GuestOnlyMiddleware::class); 26 | $app->get('/logout', [AuthController::class, 'logout'])->add(AuthRequiredMiddleware::class); 27 | $app->get('/transaction', [TransactionController::class, 'createView'])->add(AuthRequiredMiddleware::class); 28 | $app->post('/transaction', [TransactionController::class, 'create'])->add(AuthRequiredMiddleware::class); 29 | $app->get('/transaction/{transaction}', [TransactionController::class, 'editView'])->add(AuthRequiredMiddleware::class); 30 | $app->post('/transaction/{transaction}', [TransactionController::class, 'edit'])->add(AuthRequiredMiddleware::class); 31 | $app->delete('/transaction/{transaction}', [TransactionController::class, 'delete'])->add(AuthRequiredMiddleware::class); 32 | $app->get('/transaction/{transaction}/receipt', [ReceiptController::class, 'uploadView'])->add(AuthRequiredMiddleware::class); 33 | $app->post('/transaction/{transaction}/receipt', [ReceiptController::class, 'upload'])->add(AuthRequiredMiddleware::class); 34 | $app->get('/transaction/{transaction}/receipt/{receipt}', [ReceiptController::class, 'download'])->add(AuthRequiredMiddleware::class); 35 | $app->delete('/transaction/{transaction}/receipt/{receipt}', [ReceiptController::class, 'delete'])->add(AuthRequiredMiddleware::class); 36 | 37 | $app->setErrorHandler([ErrorController::class, 'notFound']); 38 | } 39 | -------------------------------------------------------------------------------- /src/App/Controllers/AboutController.php: -------------------------------------------------------------------------------- 1 | view->render('about.php', [ 19 | 'title' => 'About', 20 | 'dangerousData' => '' 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/App/Controllers/AuthController.php: -------------------------------------------------------------------------------- 1 | view->render("register.php"); 22 | } 23 | 24 | public function register() 25 | { 26 | $this->validatorService->validateRegister($_POST); 27 | 28 | $this->userService->isEmailTaken($_POST['email']); 29 | 30 | $this->userService->create($_POST); 31 | 32 | redirectTo('/'); 33 | } 34 | 35 | public function loginView() 36 | { 37 | echo $this->view->render("login.php"); 38 | } 39 | 40 | public function login() 41 | { 42 | $this->validatorService->validateLogin($_POST); 43 | 44 | $this->userService->login($_POST); 45 | 46 | redirectTo('/'); 47 | } 48 | 49 | public function logout() 50 | { 51 | $this->userService->logout(); 52 | 53 | redirectTo('/login'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/App/Controllers/ErrorController.php: -------------------------------------------------------------------------------- 1 | view->render("errors/not-found.php"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/App/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | transactionService->getUserTransactions( 27 | $length, 28 | $offset 29 | ); 30 | 31 | $lastPage = ceil($count / $length); 32 | $pages = $lastPage ? range(1, $lastPage) : []; 33 | 34 | $pageLinks = array_map( 35 | fn ($pageNum) => http_build_query([ 36 | 'p' => $pageNum, 37 | 's' => $searchTerm 38 | ]), 39 | $pages 40 | ); 41 | 42 | echo $this->view->render("index.php", [ 43 | 'transactions' => $transactions, 44 | 'currentPage' => $page, 45 | 'previousPageQuery' => http_build_query([ 46 | 'p' => $page - 1, 47 | 's' => $searchTerm 48 | ]), 49 | 'lastPage' => $lastPage, 50 | 'nextPageQuery' => http_build_query([ 51 | 'p' => $page + 1, 52 | 's' => $searchTerm 53 | ]), 54 | 'pageLinks' => $pageLinks, 55 | 'searchTerm' => $searchTerm 56 | ]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/App/Controllers/ReceiptController.php: -------------------------------------------------------------------------------- 1 | transactionService->getUserTransaction($params['transaction']); 22 | 23 | if (!$transaction) { 24 | redirectTo("/"); 25 | } 26 | 27 | echo $this->view->render("receipts/create.php"); 28 | } 29 | 30 | public function upload(array $params) 31 | { 32 | $transaction = $this->transactionService->getUserTransaction($params['transaction']); 33 | 34 | if (!$transaction) { 35 | redirectTo("/"); 36 | } 37 | 38 | $receiptFile = $_FILES['receipt'] ?? null; 39 | 40 | $this->receiptService->validateFile($receiptFile); 41 | 42 | $this->receiptService->upload($receiptFile, $transaction['id']); 43 | 44 | redirectTo("/"); 45 | } 46 | 47 | public function download(array $params) 48 | { 49 | $transaction = $this->transactionService->getUserTransaction( 50 | $params['transaction'] 51 | ); 52 | 53 | if (empty($transaction)) { 54 | redirectTo('/'); 55 | } 56 | 57 | $receipt = $this->receiptService->getReceipt($params['receipt']); 58 | 59 | if (empty($receipt)) { 60 | redirectTo('/'); 61 | } 62 | 63 | if ($receipt['transaction_id'] !== $transaction['id']) { 64 | redirectTo('/'); 65 | } 66 | 67 | $this->receiptService->read($receipt); 68 | } 69 | 70 | public function delete(array $params) 71 | { 72 | $transaction = $this->transactionService->getUserTransaction( 73 | $params['transaction'] 74 | ); 75 | 76 | if (empty($transaction)) { 77 | redirectTo('/'); 78 | } 79 | 80 | $receipt = $this->receiptService->getReceipt($params['receipt']); 81 | 82 | if (empty($receipt)) { 83 | redirectTo('/'); 84 | } 85 | 86 | if ($receipt['transaction_id'] !== $transaction['id']) { 87 | redirectTo('/'); 88 | } 89 | 90 | $this->receiptService->delete($receipt); 91 | 92 | redirectTo('/'); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/App/Controllers/TransactionController.php: -------------------------------------------------------------------------------- 1 | view->render("transactions/create.php"); 22 | } 23 | 24 | public function create() 25 | { 26 | $this->validatorService->validateTransaction($_POST); 27 | 28 | $this->transactionService->create($_POST); 29 | 30 | redirectTo('/'); 31 | } 32 | 33 | public function editView(array $params) 34 | { 35 | $transaction = $this->transactionService->getUserTransaction( 36 | $params['transaction'] 37 | ); 38 | 39 | if (!$transaction) { 40 | redirectTo('/'); 41 | } 42 | 43 | echo $this->view->render('transactions/edit.php', [ 44 | 'transaction' => $transaction 45 | ]); 46 | } 47 | 48 | public function edit(array $params) 49 | { 50 | $transaction = $this->transactionService->getUserTransaction( 51 | $params['transaction'] 52 | ); 53 | 54 | if (!$transaction) { 55 | redirectTo('/'); 56 | } 57 | 58 | $this->validatorService->validateTransaction($_POST); 59 | 60 | $this->transactionService->update($_POST, $transaction['id']); 61 | 62 | redirectTo($_SERVER['HTTP_REFERER']); 63 | } 64 | 65 | public function delete(array $params) 66 | { 67 | $this->transactionService->delete((int) $params['transaction']); 68 | 69 | redirectTo('/'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/App/Exceptions/SessionException.php: -------------------------------------------------------------------------------- 1 | view->addGlobal('csrfToken', $_SESSION['token']); 21 | 22 | $next(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/App/Middleware/FlashMiddleware.php: -------------------------------------------------------------------------------- 1 | view->addGlobal('errors', $_SESSION['errors'] ?? []); 19 | 20 | unset($_SESSION['errors']); 21 | 22 | $this->view->addGlobal('oldFormData', $_SESSION['oldFormData'] ?? []); 23 | 24 | unset($_SESSION['oldFormData']); 25 | 26 | $next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/App/Middleware/GuestOnlyMiddleware.php: -------------------------------------------------------------------------------- 1 | $_ENV['APP_ENV'] === "production", 24 | 'httponly' => true, 25 | 'samesite' => 'lax' 26 | ]); 27 | 28 | session_start(); 29 | 30 | $next(); 31 | 32 | session_write_close(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/App/Middleware/TemplateDataMiddleware.php: -------------------------------------------------------------------------------- 1 | view->addGlobal('title', 'Expense Tracking App'); 19 | 20 | $next(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/App/Middleware/ValidationExceptionMiddleware.php: -------------------------------------------------------------------------------- 1 | errors; 26 | $_SESSION['oldFormData'] = $formattedFormData; 27 | 28 | $referer = $_SERVER['HTTP_REFERER']; 29 | redirectTo($referer); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/App/Services/ReceiptService.php: -------------------------------------------------------------------------------- 1 | ['Failed to upload file'] 22 | ]); 23 | } 24 | 25 | $maxFileSizeMB = 3 * 1024 * 1024; 26 | 27 | if ($file['size'] > $maxFileSizeMB) { 28 | throw new ValidationException([ 29 | 'receipt' => ['File upload is too large'] 30 | ]); 31 | } 32 | 33 | $originalFileName = $file['name']; 34 | 35 | if (!preg_match('/^[A-za-z0-9\s._-]+$/', $originalFileName)) { 36 | throw new ValidationException([ 37 | 'receipt' => ['Invalid filename'] 38 | ]); 39 | } 40 | 41 | $clientMimeType = $file['type']; 42 | $allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf']; 43 | 44 | if (!in_array($clientMimeType, $allowedMimeTypes)) { 45 | throw new ValidationException([ 46 | 'receipt' => ['Invalid file type'] 47 | ]); 48 | } 49 | } 50 | 51 | public function upload(array $file, int $transaction) 52 | { 53 | $fileExtension = pathinfo($file['name'], PATHINFO_EXTENSION); 54 | $newFilename = bin2hex(random_bytes(16)) . "." . $fileExtension; 55 | 56 | $uploadPath = Paths::STORAGE_UPLOADS . "/" . $newFilename; 57 | 58 | if (!move_uploaded_file($file['tmp_name'], $uploadPath)) { 59 | throw new ValidationException(['receipt' => ['Failed to upload file']]); 60 | } 61 | 62 | $this->db->query( 63 | "INSERT INTO receipts( 64 | transaction_id, original_filename, storage_filename, media_type 65 | ) 66 | VALUES(:transaction_id, :original_filename, :storage_filename, :media_type)", 67 | [ 68 | 'transaction_id' => $transaction, 69 | 'original_filename' => $file['name'], 70 | 'storage_filename' => $newFilename, 71 | 'media_type' => $file['type'] 72 | ] 73 | ); 74 | } 75 | 76 | public function getReceipt(string $id) 77 | { 78 | $receipt = $this->db->query( 79 | "SELECT * FROM receipts WHERE id = :id", 80 | ['id' => $id] 81 | )->find(); 82 | 83 | return $receipt; 84 | } 85 | 86 | public function read(array $receipt) 87 | { 88 | $filePath = Paths::STORAGE_UPLOADS . '/' . $receipt['storage_filename']; 89 | 90 | if (!file_exists($filePath)) { 91 | redirectTo('/'); 92 | } 93 | 94 | header("Content-Disposition: inline;filename={$receipt['original_filename']}"); 95 | header("Content-Type: {$receipt['media_type']}"); 96 | 97 | readfile($filePath); 98 | } 99 | 100 | public function delete(array $receipt) 101 | { 102 | $filePath = Paths::STORAGE_UPLOADS . "/" . $receipt['storage_filename']; 103 | 104 | unlink($filePath); 105 | 106 | $this->db->query("DELETE FROM receipts WHERE id = :id", [ 107 | 'id' => $receipt['id'] 108 | ]); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/App/Services/TransactionService.php: -------------------------------------------------------------------------------- 1 | db->query( 20 | "INSERT INTO transactions(user_id, description, amount, date) 21 | VALUES(:user_id, :description, :amount, :date)", 22 | [ 23 | 'user_id' => $_SESSION['user'], 24 | 'description' => $formData['description'], 25 | 'amount' => $formData['amount'], 26 | 'date' => $formattedDate 27 | ] 28 | ); 29 | } 30 | 31 | public function getUserTransactions(int $length, int $offset) 32 | { 33 | $searchTerm = addcslashes($_GET['s'] ?? '', '%_'); 34 | $params = [ 35 | 'user_id' => $_SESSION['user'], 36 | 'description' => "%{$searchTerm}%" 37 | ]; 38 | 39 | $transactions = $this->db->query( 40 | "SELECT *, DATE_FORMAT(date, '%Y-%m-%d') as formatted_date 41 | FROM transactions 42 | WHERE user_id = :user_id 43 | AND description LIKE :description 44 | LIMIT {$length} OFFSET {$offset}", 45 | $params 46 | )->findAll(); 47 | 48 | $transactions = array_map(function (array $transaction) { 49 | $transaction['receipts'] = $this->db->query( 50 | "SELECT * FROM receipts WHERE transaction_id = :transaction_id", 51 | ['transaction_id' => $transaction['id']] 52 | )->findAll(); 53 | 54 | return $transaction; 55 | }, $transactions); 56 | 57 | $transactionCount = $this->db->query( 58 | "SELECT COUNT(*) 59 | FROM transactions 60 | WHERE user_id = :user_id 61 | AND description LIKE :description", 62 | $params 63 | )->count(); 64 | 65 | return [$transactions, $transactionCount]; 66 | } 67 | 68 | public function getUserTransaction(string $id) 69 | { 70 | return $this->db->query( 71 | "SELECT *, DATE_FORMAT(date, '%Y-%m-%d') as formatted_date 72 | FROM transactions 73 | WHERE id = :id AND user_id = :user_id", 74 | [ 75 | 'id' => $id, 76 | 'user_id' => $_SESSION['user'] 77 | ] 78 | )->find(); 79 | } 80 | 81 | public function update(array $formData, int $id) 82 | { 83 | $formattedDate = "{$formData['date']} 00:00:00"; 84 | 85 | $this->db->query( 86 | "UPDATE transactions 87 | SET description = :description, 88 | amount = :amount, 89 | date = :date 90 | WHERE id = :id 91 | AND user_id = :user_id", 92 | [ 93 | 'description' => $formData['description'], 94 | 'amount' => $formData['amount'], 95 | 'date' => $formattedDate, 96 | 'id' => $id, 97 | 'user_id' => $_SESSION['user'] 98 | ] 99 | ); 100 | } 101 | 102 | public function delete(int $id) 103 | { 104 | $this->db->query( 105 | "DELETE FROM transactions WHERE id = :id AND user_id = :user_id", 106 | [ 107 | 'id' => $id, 108 | 'user_id' => $_SESSION['user'] 109 | ] 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/App/Services/UserService.php: -------------------------------------------------------------------------------- 1 | db->query( 19 | "SELECT COUNT(*) FROM users WHERE email = :email", 20 | [ 21 | 'email' => $email 22 | ] 23 | )->count(); 24 | 25 | if ($emailCount > 0) { 26 | throw new ValidationException(['email' => ['Email taken']]); 27 | } 28 | } 29 | 30 | public function create(array $formData) 31 | { 32 | $password = password_hash($formData['password'], PASSWORD_BCRYPT, ['cost' => 12]); 33 | 34 | $this->db->query( 35 | "INSERT INTO users(email,password,age,country,social_media_url) 36 | VALUES(:email, :password, :age, :country, :url)", 37 | [ 38 | 'email' => $formData['email'], 39 | 'password' => $password, 40 | 'age' => $formData['age'], 41 | 'country' => $formData['country'], 42 | 'url' => $formData['socialMediaURL'] 43 | ] 44 | ); 45 | 46 | session_regenerate_id(); 47 | 48 | $_SESSION['user'] = $this->db->id(); 49 | } 50 | 51 | public function login(array $formData) 52 | { 53 | $user = $this->db->query("SELECT * FROM users WHERE email = :email", [ 54 | 'email' => $formData['email'] 55 | ])->find(); 56 | 57 | $passwordsMatch = password_verify( 58 | $formData['password'], 59 | $user['password'] ?? '' 60 | ); 61 | 62 | if (!$user || !$passwordsMatch) { 63 | throw new ValidationException(['password' => ['Invalid credentials']]); 64 | } 65 | 66 | session_regenerate_id(); 67 | 68 | $_SESSION['user'] = $user['id']; 69 | } 70 | 71 | public function logout() 72 | { 73 | // unset($_SESSION['user']); 74 | session_destroy(); 75 | 76 | // session_regenerate_id(); 77 | $params = session_get_cookie_params(); 78 | setcookie( 79 | 'PHPSESSID', 80 | '', 81 | time() - 3600, 82 | $params['path'], 83 | $params['domain'], 84 | $params['secure'], 85 | $params['httponly'] 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/App/Services/ValidatorService.php: -------------------------------------------------------------------------------- 1 | validator = new Validator(); 27 | 28 | $this->validator->add('required', new RequiredRule()); 29 | $this->validator->add('email', new EmailRule()); 30 | $this->validator->add('min', new MinRule()); 31 | $this->validator->add('in', new InRule()); 32 | $this->validator->add('url', new UrlRule()); 33 | $this->validator->add('match', new MatchRule()); 34 | $this->validator->add('lengthMax', new LengthMaxRule()); 35 | $this->validator->add('numeric', new NumericRule()); 36 | $this->validator->add('dateFormat', new DateFormatRule()); 37 | } 38 | 39 | public function validateRegister(array $formData) 40 | { 41 | $this->validator->validate($formData, [ 42 | 'email' => ['required', 'email'], 43 | 'age' => ['required', 'min:18'], 44 | 'country' => ['required', 'in:USA,Canada,Mexico'], 45 | 'socialMediaURL' => ['required', 'url'], 46 | 'password' => ['required'], 47 | 'confirmPassword' => ['required', 'match:password'], 48 | 'tos' => ['required'] 49 | ]); 50 | } 51 | 52 | public function validateLogin(array $formData) 53 | { 54 | $this->validator->validate($formData, [ 55 | 'email' => ['required', 'email'], 56 | 'password' => ['required'] 57 | ]); 58 | } 59 | 60 | public function validateTransaction(array $formData) 61 | { 62 | $this->validator->validate($formData, [ 63 | 'description' => ['required', 'lengthMax:255'], 64 | 'amount' => ['required', 'numeric'], 65 | 'date' => ['required', 'dateFormat:Y-m-d'] 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/App/bootstrap.php: -------------------------------------------------------------------------------- 1 | load(); 15 | 16 | $app = new App(Paths::SOURCE . "App/container-definitions.php"); 17 | 18 | registerRoutes($app); 19 | registerMiddleware($app); 20 | 21 | return $app; 22 | -------------------------------------------------------------------------------- /src/App/container-definitions.php: -------------------------------------------------------------------------------- 1 | fn () => new TemplateEngine(Paths::VIEW), 16 | ValidatorService::class => fn () => new ValidatorService(), 17 | Database::class => fn () => new Database($_ENV['DB_DRIVER'], [ 18 | 'host' => $_ENV['DB_HOST'], 19 | 'port' => $_ENV['DB_PORT'], 20 | 'dbname' => $_ENV['DB_NAME'] 21 | ], $_ENV['DB_USER'], $_ENV['DB_PASS']), 22 | UserService::class => function (Container $container) { 23 | $db = $container->get(Database::class); 24 | 25 | return new UserService($db); 26 | }, 27 | TransactionService::class => function (Container $container) { 28 | $db = $container->get(Database::class); 29 | 30 | return new TransactionService($db); 31 | }, 32 | ReceiptService::class => function (Container $container) { 33 | $db = $container->get(Database::class); 34 | 35 | return new ReceiptService($db); 36 | } 37 | ]; 38 | -------------------------------------------------------------------------------- /src/App/functions.php: -------------------------------------------------------------------------------- 1 | "; 10 | var_dump($value); 11 | echo ""; 12 | die(); 13 | } 14 | 15 | function e(mixed $value): string 16 | { 17 | return htmlspecialchars((string) $value); 18 | } 19 | 20 | function redirectTo(string $path) 21 | { 22 | header("Location: {$path}"); 23 | http_response_code(Http::REDIRECT_STATUS_CODE); 24 | exit; 25 | } 26 | -------------------------------------------------------------------------------- /src/App/views/about.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 | 4 |
5 | 6 |

About Page

7 | 8 |
9 | 10 | 11 |

Escaping Data:

12 |
13 | 14 | 15 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/errors/not-found.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 | 4 |
5 |

404

6 |

7 | The page you are looking for was moved, removed renamed or might never 8 | existed. 9 |

10 | Go Home 11 |
12 | 13 | 14 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/index.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 | 4 |
5 |
6 |

Transaction List

7 | 16 |
17 | 18 |
19 |
20 | 21 | 24 |
25 |
26 | 27 | 28 | 29 | 30 | 33 | 36 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 57 | 58 | 78 | 79 | 82 | 83 | 106 | 107 | 108 | 109 |
31 | Description 32 | 34 | Amount 35 | 37 | Receipt(s) 38 | 40 | Date 41 | Actions
51 | 52 | 55 | 56 | 59 | 60 |
61 | 62 | 63 | 64 | 65 | 66 |
67 | resolve("partials/_csrf.php"); ?> 68 | 69 | 74 |
75 |
76 | 77 |
80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 | 96 | 97 | resolve("partials/_csrf.php"); ?> 98 | 99 | 104 |
105 |
110 | 143 |
144 | 145 | 146 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/login.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 |
4 |
5 | resolve('partials/_csrf.php'); ?> 6 | 7 | 16 | 25 | 28 |
29 |
30 | 31 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/partials/_csrf.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App/views/partials/_footer.php: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/App/views/partials/_header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <?php echo e($title); ?> - PHPiggy 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 37 |
38 | -------------------------------------------------------------------------------- /src/App/views/receipts/create.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 |
4 |
5 | resolve("partials/_csrf.php"); ?> 6 | 15 | 18 |
19 |
20 | 21 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/register.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 |
4 |
5 | resolve('partials/_csrf.php'); ?> 6 | 7 | 8 | 17 | 18 | 27 | 28 | 42 | 43 | 52 | 53 | 62 | 63 | 72 | 73 |
74 |
75 |
76 | 80 | 81 |
82 | 83 |
84 | 85 |
86 |
87 |
88 | 91 |
92 |
93 | 94 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/transactions/create.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 |
4 |
5 | resolve("partials/_csrf.php"); ?> 6 | 7 | 16 | 25 | 34 | 37 |
38 |
39 | 40 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/App/views/transactions/edit.php: -------------------------------------------------------------------------------- 1 | resolve("partials/_header.php"); ?> 2 | 3 |
4 |
5 | resolve("partials/_csrf.php"); ?> 6 | 7 | 17 | 26 | 35 | 38 |
39 |
40 | 41 | resolve("partials/_footer.php"); ?> -------------------------------------------------------------------------------- /src/Framework/App.php: -------------------------------------------------------------------------------- 1 | router = new Router(); 15 | $this->container = new Container(); 16 | 17 | if ($containerDefinitionsPath) { 18 | $containerDefinitions = include $containerDefinitionsPath; 19 | $this->container->addDefinitions($containerDefinitions); 20 | } 21 | } 22 | 23 | public function run() 24 | { 25 | $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); 26 | $method = $_SERVER['REQUEST_METHOD']; 27 | 28 | $this->router->dispatch($path, $method, $this->container); 29 | } 30 | 31 | public function get(string $path, array $controller): App 32 | { 33 | $this->router->add('GET', $path, $controller); 34 | 35 | return $this; 36 | } 37 | 38 | public function post(string $path, array $controller): App 39 | { 40 | $this->router->add('POST', $path, $controller); 41 | 42 | return $this; 43 | } 44 | 45 | public function delete(string $path, array $controller): App 46 | { 47 | $this->router->add('DELETE', $path, $controller); 48 | 49 | return $this; 50 | } 51 | 52 | public function addMiddleware(string $middleware) 53 | { 54 | $this->router->addMiddleware($middleware); 55 | } 56 | 57 | public function add(string $middleware) 58 | { 59 | $this->router->addRouteMiddleware($middleware); 60 | } 61 | 62 | public function setErrorHandler(array $controller) 63 | { 64 | $this->router->setErrorHandler($controller); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Framework/Container.php: -------------------------------------------------------------------------------- 1 | definitions = [...$this->definitions, ...$newDefinitions]; 18 | } 19 | 20 | public function resolve(string $className) 21 | { 22 | $reflectionClass = new ReflectionClass($className); 23 | 24 | if (!$reflectionClass->isInstantiable()) { 25 | throw new ContainerException("Class {$className} is not instantiable"); 26 | } 27 | 28 | $constructor = $reflectionClass->getConstructor(); 29 | 30 | if (!$constructor) { 31 | return new $className; 32 | } 33 | 34 | $params = $constructor->getParameters(); 35 | 36 | if (count($params) === 0) { 37 | return new $className; 38 | } 39 | 40 | $dependencies = []; 41 | 42 | foreach ($params as $param) { 43 | $name = $param->getName(); 44 | $type = $param->getType(); 45 | 46 | if (!$type) { 47 | throw new ContainerException("Failed to resolve class {$className} because param {$name} is missing a type hint."); 48 | } 49 | 50 | if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { 51 | throw new ContainerException("Failed to resolve class {$className} because invalid param name."); 52 | } 53 | 54 | $dependencies[] = $this->get($type->getName()); 55 | } 56 | 57 | return $reflectionClass->newInstanceArgs($dependencies); 58 | } 59 | 60 | public function get(string $id) 61 | { 62 | if (!array_key_exists($id, $this->definitions)) { 63 | throw new ContainerException("Class {$id} does not exist in container."); 64 | } 65 | 66 | if (array_key_exists($id, $this->resolved)) { 67 | return $this->resolved[$id]; 68 | } 69 | 70 | $factory = $this->definitions[$id]; 71 | $dependency = $factory($this); 72 | 73 | $this->resolved[$id] = $dependency; 74 | 75 | return $dependency; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Framework/Contracts/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | connection = new PDO($dsn, $username, $password, [ 26 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC 27 | ]); 28 | } catch (PDOException $e) { 29 | die("Unable to connect to database"); 30 | } 31 | } 32 | 33 | public function query(string $query, array $params = []): Database 34 | { 35 | $this->stmt = $this->connection->prepare($query); 36 | 37 | $this->stmt->execute($params); 38 | 39 | return $this; 40 | } 41 | 42 | public function count() 43 | { 44 | return $this->stmt->fetchColumn(); 45 | } 46 | 47 | public function find() 48 | { 49 | return $this->stmt->fetch(); 50 | } 51 | 52 | public function id() 53 | { 54 | return $this->connection->lastInsertId(); 55 | } 56 | 57 | public function findAll() 58 | { 59 | return $this->stmt->fetchAll(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Framework/Exceptions/ContainerException.php: -------------------------------------------------------------------------------- 1 | normalizePath($path); 16 | 17 | $regexPath = preg_replace('#{[^/]+}#', '([^/]+)', $path); 18 | 19 | $this->routes[] = [ 20 | 'path' => $path, 21 | 'method' => strtoupper($method), 22 | 'controller' => $controller, 23 | 'middlewares' => [], 24 | 'regexPath' => $regexPath 25 | ]; 26 | } 27 | 28 | private function normalizePath(string $path): string 29 | { 30 | $path = trim($path, '/'); 31 | $path = "/{$path}/"; 32 | $path = preg_replace('#[/]{2,}#', '/', $path); 33 | 34 | return $path; 35 | } 36 | 37 | public function dispatch(string $path, string $method, Container $container = null) 38 | { 39 | $path = $this->normalizePath($path); 40 | $method = strtoupper($_POST['_METHOD'] ?? $method); 41 | 42 | foreach ($this->routes as $route) { 43 | if ( 44 | !preg_match("#^{$route['regexPath']}$#", $path, $paramValues) || 45 | $route['method'] !== $method 46 | ) { 47 | continue; 48 | } 49 | 50 | array_shift($paramValues); 51 | 52 | preg_match_all('#{([^/]+)}#', $route['path'], $paramKeys); 53 | 54 | $paramKeys = $paramKeys[1]; 55 | 56 | $params = array_combine($paramKeys, $paramValues); 57 | 58 | [$class, $function] = $route['controller']; 59 | 60 | $controllerInstance = $container ? 61 | $container->resolve($class) : 62 | new $class; 63 | 64 | $action = fn () => $controllerInstance->{$function}($params); 65 | 66 | $allMiddleware = [...$route['middlewares'], ...$this->middlewares]; 67 | 68 | foreach ($allMiddleware as $middleware) { 69 | $middlewareInstance = $container ? 70 | $container->resolve($middleware) : 71 | new $middleware; 72 | $action = fn () => $middlewareInstance->process($action); 73 | } 74 | 75 | $action(); 76 | 77 | return; 78 | } 79 | 80 | $this->dispatchNotFound($container); 81 | } 82 | 83 | public function addMiddleware(string $middleware) 84 | { 85 | $this->middlewares[] = $middleware; 86 | } 87 | 88 | public function addRouteMiddleware(string $middleware) 89 | { 90 | $lastRouteKey = array_key_last($this->routes); 91 | $this->routes[$lastRouteKey]['middlewares'][] = $middleware; 92 | } 93 | 94 | public function setErrorHandler(array $controller) 95 | { 96 | $this->errorHandler = $controller; 97 | } 98 | 99 | public function dispatchNotFound(?Container $container) 100 | { 101 | [$class, $function] = $this->errorHandler; 102 | 103 | $controllerInstance = $container ? $container->resolve($class) : new $class; 104 | 105 | $action = fn () => $controllerInstance->$function(); 106 | 107 | foreach ($this->middlewares as $middleware) { 108 | $middlewareInstance = $container ? $container->resolve($middleware) : new $middleware; 109 | $action = fn () => $middlewareInstance->process($action); 110 | } 111 | 112 | $action(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Framework/Rules/DateFormatRule.php: -------------------------------------------------------------------------------- 1 | = $length; 20 | } 21 | public function getMessage(array $data, string $field, array $params): string 22 | { 23 | return "Must be at least {$params[0]}"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Framework/Rules/NumericRule.php: -------------------------------------------------------------------------------- 1 | globalTemplateData, EXTR_SKIP); 19 | 20 | ob_start(); 21 | 22 | include $this->resolve($template); 23 | 24 | $output = ob_get_contents(); 25 | 26 | ob_end_clean(); 27 | 28 | return $output; 29 | } 30 | 31 | public function resolve(string $path) 32 | { 33 | return "{$this->basePath}/{$path}"; 34 | } 35 | 36 | public function addGlobal(string $key, mixed $value) 37 | { 38 | $this->globalTemplateData[$key] = $value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Framework/Validator.php: -------------------------------------------------------------------------------- 1 | rules[$alias] = $rule; 17 | } 18 | 19 | public function validate(array $formData, array $fields) 20 | { 21 | $errors = []; 22 | 23 | foreach ($fields as $fieldName => $rules) { 24 | foreach ($rules as $rule) { 25 | $ruleParams = []; 26 | 27 | if (str_contains($rule, ':')) { 28 | [$rule, $ruleParams] = explode(':', $rule); 29 | $ruleParams = explode(',', $ruleParams); 30 | } 31 | 32 | $ruleValidator = $this->rules[$rule]; 33 | 34 | if ($ruleValidator->validate($formData, $fieldName, $ruleParams)) { 35 | continue; 36 | } 37 | 38 | $errors[$fieldName][] = $ruleValidator->getMessage( 39 | $formData, 40 | $fieldName, 41 | $ruleParams 42 | ); 43 | } 44 | } 45 | 46 | if (count($errors)) { 47 | throw new ValidationException($errors); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /storage/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZTMLuisRamirez/phpiggy/e685129d42cf5f8a1e537fd6bf3ffba53e40af7a/storage/uploads/.gitkeep --------------------------------------------------------------------------------