diff --git a/CHANGELOG.md b/CHANGELOG.md index 556b36a..f5c8533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -This project uses [Semantic Versioning](https://semver.org/) beginning with version `v0.1.7-alpha.2` (or `v0.1.7-beta.1`). +This project uses [Semantic Versioning](https://semver.org/) beginning with version `v0.1.7-alpha.2` (or `v0.1.7`). Earlier releases (`v0.1.0-alpha.1` to `v0.1.7-alpha.1`) were experimental and do not strictly follow SemVer conventions. --- @@ -59,7 +59,7 @@ These were single-shot development releases with no progressive alpha/beta cycle **From v0.1.7-alpha.2 onward, all releases will follow a structured, progressive SemVer pre-release cycle.** -## [v0.1.7-beta.1] - 2024-06-09 +## [v0.1.7] - 2025-07-18 ### Added - Dynamic route parameter support (e.g., `/user/{id}`, `/blog/{slug}`) for CRUD and flexible routing diff --git a/DOCS.md b/DOCS.md index 5a40dc0..0dec2a6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,6 +1,63 @@ # SproutPHP Documentation -## [v0.1.7-beta.1] - 2024-06-09 +> ⚠️ **Security Warning:** +> +> For security, you **must** set your web server's document root to the `public/` directory only. Never expose the project root or any directory above `public/` to the web. If misconfigured, sensitive files (like `.env`, `storage/`, `config/`, etc.) could be publicly accessible and compromise your application. +> +> See [Server Configuration](#server-configuration-apache--nginx) for deployment details. + +## Server Configuration (Apache & Nginx) + +### Apache (.htaccess) + +```apache +# Place this in your project root (not public/) +# Redirect all requests to public/ +RewriteEngine On +RewriteCond %{REQUEST_URI} !^/public/ +RewriteRule ^(.*)$ /public/$1 [L] + +# Deny access to sensitive files everywhere + + Order allow,deny + Deny from all + +``` + +### Nginx + +```nginx +server { + listen 80; + server_name yourdomain.com; + + # Set the root to the public directory + root /path/to/your/project/public; + + index index.php index.html; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Deny access to sensitive files + location ~ /\.(env|git|htaccess) { + deny all; + } + location ~* /(composer\.json|composer\.lock|config\.php) { + deny all; + } + + location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} +``` + +## [v0.1.7] - 2025-07-18 ### New Features & Improvements @@ -38,6 +95,26 @@ - **CLI** for scaffolding and project management - **Error pages** and basic debug tools +## Routing + +SproutPHP supports flexible route parameters (like most modern frameworks): + +| Pattern | Matches | Example URI | Notes | +| ------------------ | ------- | ------------------------ | --------------------------- | +| `/user/{id}` | Yes | `/user/123` | `{id}` = 123 | +| `/user/{id?}` | Yes | `/user/123`, `/user` | `{id}` = 123 or null | +| `/file/{path:.+}` | Yes | `/file/foo/bar` | `{path}` = foo/bar | +| `/file/{path?:.+}` | Yes | `/file`, `/file/foo/bar` | `{path}` = foo/bar or null | +| `/blog/{slug}` | Yes | `/blog/hello-world` | `{slug}` = hello-world | +| `/blog/{slug}` | No | `/blog/hello/world` | `{slug}` does not match `/` | + +- {param} — required parameter (matches anything except /) +- {param?} — optional parameter (trailing slash and parameter are optional) +- {param:regex} — required with custom regex +- {param?:regex} — optional with custom regex + +Optional parameters are passed as null if missing. For catch-all (wildcard) parameters, use a custom regex like {path:.+}. + ## Example: Hello World Route ```php @@ -311,395 +388,39 @@ Add this script (in your layout or navbar): You do **not** need to install or include HTMX or PicoCSS yourself—they are already downloaded and loaded in your base template: -```html - - ``` -## Twig Helper Functions - -SproutPHP uses a **hybrid system** for making PHP helper functions available in your Twig templates: +``` -- **Automatic Registration:** All user-defined functions in `core/Support/helpers.php` are automatically registered as Twig helpers. Just add your function to `helpers.php` and it will be available in your Twig views. -- **Explicit Registration (Optional):** You can also explicitly list additional helpers in the `twig_helpers` array in `config/view.php`. This is useful if you want to expose helpers from other files or override the default set. -- **Both lists are merged and deduplicated.** +## Testing Your SproutPHP App -### Usage +SproutPHP is compatible with [PHPUnit](https://phpunit.de/) and other popular PHP testing tools. -1. **Add a helper to `helpers.php`:** +1. **Install PHPUnit (dev only):** + ```sh + composer require --dev phpunit/phpunit + ``` +2. **Create a `tests/` directory** in your project root. +3. **Add a sample test:** ```php - // core/Support/helpers.php - if (!function_exists('my_custom_helper')) { - function my_custom_helper($arg) { - return strtoupper($arg); + // tests/ExampleTest.php + use PHPUnit\Framework\TestCase; + + class ExampleTest extends TestCase + { + public function testBasicAssertion() + { + $this->assertTrue(true); } } ``` - Now you can use it in Twig: - - ```twig - {{ my_custom_helper('hello') }} +4. **Run your tests:** + ```sh + ./vendor/bin/phpunit ``` -2. **(Optional) Add a helper to config:** - ```php - // config/view.php - 'twig_helpers' => [ - 'my_other_helper', - ], - ``` - -### How it works - -- All helpers in `helpers.php` are auto-registered. -- Any helpers listed in `twig_helpers` are also registered (even if not in `helpers.php`). -- If a helper exists in both, it is only registered once. - -**This means most of the time, you just add your helper to `helpers.php` and it works in Twig!** - -### Note on the `view()` Helper - -- The `view()` helper now supports a third parameter `$return` (default: `false`). -- If `$return` is `true`, it returns the rendered string instead of echoing it. This is used by the generic fragment helper to inject fragments into layouts. - -## HTMX/AJAX Fragment Rendering: Two Approaches - -SproutPHP supports two ways to handle routes that should return either a fragment (for HTMX/AJAX) or a full page (for normal requests): - -### 1. Generic Helper (Recommended) - -Use the `render_fragment_or_full` helper in your route. This will automatically detect if the request is HTMX/AJAX and return just the fragment, or wrap it in your layout for normal requests. - -By default, the fragment is injected into the `content` block of `layouts/base.twig`, so direct URL access always returns a full, styled page (navbar, footer, etc.). - -```php -Route::get('/my-fragment', function () { - $data = [/* ... */]; - render_fragment_or_full('partials/my-fragment', $data); // uses layouts/base.twig by default -}); -``` - -- **Best for most use cases.** -- Keeps your code DRY and consistent. -- Ensures direct URL access always returns a full page. -- You can customize the layout or block by passing additional arguments to the helper. -- **Note:** The default layout path is `layouts/base` (not just `base`). - -### 2. Manual Fragment Detection (Advanced) - -You can manually check for HTMX/AJAX requests and echo the fragment or full layout as needed: - -```php -Route::get('/my-fragment', function () { - $data = [/* ... */]; - if (is_htmx_request() || is_ajax_request()) { - echo view('partials/my-fragment', $data); - } else { - echo view('home', ['main_content' => view('partials/my-fragment', $data, true)]); - } -}); -``` - -- **Use when you need custom logic per route.** -- Useful for advanced scenarios or when you want to handle fragments differently. - -**Tip:** For most routes, use the generic helper. Use the manual method only if you need special handling. - -## Preventing HTMX from Handling Certain Links - -Sometimes you want a link to always trigger a full page reload (for example, your home link to "/"), rather than being handled by HTMX as a fragment swap. To do this, use one of the following approaches: - -- Add `hx-boost="false"` to the `` tag: - ```html - Home - ``` -- Or, add `target="_self"`: - ```html - Home - ``` -- Or, add `rel="external"`: - ```html - Home - ``` - -**Best Practice:** - -- Use these attributes for any link that should always reload the full page, such as your site home or links to external sites. -- This prevents issues where the page loses its CSS/JS context due to HTMX fragment swaps. - -## CORS Middleware - -SproutPHP includes a built-in CORS middleware, registered globally by default but disabled in config/security.php: - -- To enable CORS, set `'enabled' => true` in the `'cors'` section of `config/security.php` or set `CORS_ENABLED=true` in your `.env` file. -- Configure allowed origins, methods, and headers in the same config file or via environment variables. -- The middleware will automatically set the appropriate CORS headers and handle preflight (OPTIONS) requests. -- By default, CORS is **disabled** for security. Enable only if you need cross-origin requests (e.g., for APIs or frontend apps). - -**Example config/security.php:** - -```php -'cors' => [ - 'enabled' => env('CORS_ENABLED', false), - 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '*')), - 'allowed_methods' => explode(',', env('CORS_ALLOWED_METHODS', 'GET,POST,PUT,DELETE')), - 'allowed_headers' => explode(',', env('CORS_ALLOWED_HEADERS', 'Content-Type,Authorization')), -], -``` - -**Security Note:** - -- Only enable CORS for trusted origins in production. Use `*` for development only. - -## Content Security Policy (CSP) and External APIs/Images - -By default, SproutPHP sets a strict Content Security Policy (CSP) to maximize security: -- Only resources from your own domain are allowed (default-src 'self'). -- No external APIs (AJAX/fetch) or images are permitted by default. - -### Allowing External APIs (connect-src) -To allow your app to fetch data from external APIs (e.g., GitHub, third-party services), set the `CSP_CONNECT_SRC` variable in your `.env` file: - -``` -CSP_CONNECT_SRC=https://api.github.com,https://another.api.com -``` - -This will add the specified domains to the CSP `connect-src` directive, allowing JavaScript to make requests to those APIs. - -### Allowing External Images (img-src) -To allow your app to load images from external sources (e.g., shields.io, Gravatar), set the `CSP_IMG_SRC` variable in your `.env` file: - -``` -CSP_IMG_SRC=https://img.shields.io,https://www.gravatar.com -``` - -This will add the specified domains to the CSP `img-src` directive, allowing images from those sources. - -### Why is CSP strict by default? -- This prevents accidental data leaks and XSS attacks by only allowing resources from your own domain. -- You must explicitly allow any external domains you trust for APIs or images. - -### Where is this configured? -- See `config/security.php` for how these variables are loaded. -- The CSP header is set in the `XssProtection` middleware. - -**Tip:** Only add domains you trust and actually use. Never use `*` in production for these settings. - -## CLI Reference - -Run `php sprout` for all available commands, including: - -- `grow` — Start local dev server -- `make:controller`, `make:model`, `make:view`, `make:route`, `make:component`, `make:migration`, `migrate`, etc. -- `env` — Set environment -- `logs` — View error logs -- `info` — Show framework info - -## 🌿 Contributing & Future Growth - -SproutPHP is a living, growing project—just like its name! Contributions, ideas, and feedback are welcome. Here’s how you can help this sprout grow: - -1. **Fork the repo and clone it locally** -2. **Create a new branch** for your feature or fix -3. **Make your changes** (keep them minimal and in line with the project philosophy) -4. **Submit a pull request** -5. **Discuss and improve** with the community - -## PicoCSS Installer (Post-Install Script) - -SproutPHP includes a post-install script that lets you choose your preferred PicoCSS build right after running `composer install`. - -### How it Works - -- After installing dependencies, you'll be prompted to select a PicoCSS build: 0. Default Sprout Layout (Minimal PicoCSS) — just press Enter or choose 0 for the default - 1. Minimal (Standard) - 2. Classless - 3. Conditional - 4. Fluid Classless - 5. Color Theme (choose a color) - 6. Classless + Color Theme - 7. Conditional + Color Theme - 8. Fluid + Classless + Conditional + Color Theme - 9. Color Palette Only -- If you choose a color theme, you'll be prompted for the color (amber, blue, cyan, fuchsia, green, grey, indigo, jade, lime, orange, pink, pumpkin, purple, red, sand, slate, violet, yellow, zinc). -- You'll also be asked if you want the minified version (recommended for production). -- The script will download the latest PicoCSS file from the CDN and save it as `public/assets/css/sprout.min.css`. - -### Use Cases - -| Use Case | Choose This Option | -| -------------------------------------- | ---------------------------------------- | -| Default Sprout layout, minimal PicoCSS | 0 (or press Enter) | -| Simple blog, no layout classes | Classless | -| Full control, grid, utilities | Minimal (Standard) | -| Themed look + classless | Classless + Color Theme | -| Toggle light/dark with JS | Conditional or Conditional + Color Theme | -| Full-width layout, no classes | Fluid Classless | -| Define your own classes | Color Palette Only | - -### Changing PicoCSS Later - -- You can re-run the post-install script at any time: - ```bash - php core/Console/PostInstall.php - ``` -- Or, use the CLI command to update PicoCSS interactively: - ```bash - php sprout install:pico - ``` -- Or, manually download your preferred PicoCSS file from [jsdelivr PicoCSS CDN](https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/) and place it in `public/assets/css/sprout.min.css`. - -### Advanced - -- All PicoCSS builds and color themes are available. See the [PicoCSS documentation](https://picocss.com/docs/) for more details on each build type and theme. - -## Production Build (bloom Command) - -To prepare your SproutPHP app for production, use the `bloom` command: - -```bash -php sprout bloom -``` - -This will run the production build process (minifies, strips dev code, precompiles, etc.). - -- The old `build` command is now replaced by `bloom` for clarity and branding. -- Use this command before deploying your app to production. - -## File Upload & Storage (v0.1.7+) - -SproutPHP now includes a robust Storage helper for file uploads and URL generation, saving files in `storage/app/public` for web accessibility via a symlink. - -### Usage Example - -```php -use Core\Support\Storage; - -// In your controller -if ($request->hasFile('avatar')) { - $path = Storage::put($request->file('avatar'), 'avatars'); - $url = Storage::url($path); // /storage/avatars/filename.jpg -} -``` - -- Files are saved in `storage/app/public/{subdir}`. -- URLs are generated as `/storage/{subdir}/{filename}`. -- The `public/storage` directory is a symlink (or junction on Windows) to `storage/app/public`. - -### Storage Configuration - -In `config/storage.php`: - -```php -'public' => [ - 'root' => env('STORAGE_PUBLIC_ROOT', 'storage/app/public'), - 'url' => env('STORAGE_PUBLIC_LINK', '/storage'), - 'visibility' => 'public', -], -``` - -### Creating the Symlink - -To make uploaded files accessible via the web, create a symlink: - -```bash -php sprout symlink:create -``` - -- This links `public/storage` to `storage/app/public`. -- On Windows, a directory junction is created for compatibility. - -### Folder Structure - -``` -project-root/ -├── public/ -│ ├── assets/ -│ ├── index.php -│ └── storage/ # symlink → ../storage/app/public -├── storage/ -│ └── app/ -│ └── public/ -│ └── avatars/ -│ └── uploadedfile.jpg -``` - -### Accessing Uploaded Files - -- After upload, files are accessible at `/storage/avatars/filename.jpg`. -- The `Storage::url($path)` helper generates the correct public URL. - -### Example Controller Snippet - -```php -if ($request->hasFile('avatar')) { - $file = $request->file('avatar'); - $path = Storage::put($file, 'avatars'); - $avatarUrl = Storage::url($path); // Use in your views -} -``` - -### Notes - -- Always use the `Storage` helper for uploads and URLs. -- The storage root is now absolute for reliability. -- No need to set or override the storage root in `.env` unless you have a custom setup. -- The CLI symlink command ensures public access to uploaded files. - -## HTMX File Upload with - -You can use your main form for file upload: - -```twig -
- -
- - - {% if errors.avatar %} -
{{ errors.avatar }}
- {% endif %} -
- - -
-``` - -- On success, your server can return a fragment with the uploaded avatar URL and preview. - ---- - -## Error Clearing Script - -A generic script clears error messages for any field when focused: - -```html - -``` - -- Works for all fields with `.error[for="fieldname"]`. - ---- +You can test any part of your app: helpers, models, controllers, middleware, etc. Use mocks and stubs as needed. -See the rest of this documentation for more on validation, request handling, and UI best practices. +> **Note:** SproutPHP does not include test files by default. You are free to organize and write tests as you see fit for your project. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 760c7b2..7148a52 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ -# Release Notes: v0.1.7-beta.1 (2024-06-09) +# Release Notes: v0.1.7 (2025-07-18) ## Highlights -- **First Beta Release!** SproutPHP is now feature-complete and ready for broader testing and feedback. +- **First Stable Release!** SproutPHP is now production-ready, stable, and suitable for real-world projects. - **Dynamic Routing:** Support for route parameters (e.g., `/user/{id}`, `/file/{filename:.+}`) enables full CRUD and flexible APIs. - **CSRF Protection:** Robust, middleware-based CSRF protection for all state-changing requests (forms, AJAX, HTMX). - **SPA-like UX:** HTMX-powered forms and file uploads for a modern, seamless user experience. diff --git a/app/Controllers/ValidationTestController.php b/app/Controllers/ValidationTestController.php index c6347f8..9a4d13e 100644 --- a/app/Controllers/ValidationTestController.php +++ b/app/Controllers/ValidationTestController.php @@ -62,7 +62,7 @@ public function handleForm() $validator = new Validator($data, [ 'email' => 'required|email', 'name' => 'required|min:3', - 'avatar' => 'image|mimes:jpg,jpeg,png,gif', + 'avatar' => 'image|mimes:jpg,jpeg,png,gif|file_size:2048', ]); // File Upload Validation diff --git a/app/Middlewares/VerifyCsrfToken.php b/app/Middlewares/VerifyCsrfToken.php index 26edb1c..9b982b3 100644 --- a/app/Middlewares/VerifyCsrfToken.php +++ b/app/Middlewares/VerifyCsrfToken.php @@ -12,6 +12,16 @@ public function handle(Request $request, callable $next) // Check CSRF on state-changeing request if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) { $token = $_POST['_csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + + // If not found and JSON request, try to extract from JSON body + if (!$token && isset($_SERVER['CONTENT_TYPE']) && stripos($_SERVER['CONTENT_TYPE'], 'application/json') === 0) { + $json = file_get_contents('php://input'); + $data = json_decode($json, true); + if (is_array($data) && isset($data['_csrf_token'])) { + $token = $data['_csrf_token']; + } + } + $sessionToken = $_SESSION['_csrf_token'] ?? null; if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) { diff --git a/app/Middlewares/XssProtection.php b/app/Middlewares/XssProtection.php index 69aad54..3e6e8bb 100644 --- a/app/Middlewares/XssProtection.php +++ b/app/Middlewares/XssProtection.php @@ -60,6 +60,12 @@ private function getCspPolicy(): string return $basePolicy . " script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" . $imgSrc . $connectSrc; } else { // Production: strict policy + if ($debug) { + // Optionally log a warning if debug is on in production + if (function_exists('log_error')) { + log_error("[SproutPHP] WARNING: app.debug is true in production! CSP is strict, but debug mode should be off."); + } + } return $basePolicy . " script-src 'self'; style-src 'self';" . $imgSrc . $connectSrc; } } diff --git a/app/Views/errors/401.twig b/app/Views/errors/401.twig index 8dac5b9..7612a52 100644 --- a/app/Views/errors/401.twig +++ b/app/Views/errors/401.twig @@ -9,6 +9,15 @@ place-items: center; height: 100vh; text-align: center; + padding: 2rem; + } + h1 { + font-size: 3rem; + margin-bottom: 0.5rem; + } + p { + font-size: 1.2rem; + color: #666; } diff --git a/app/Views/errors/403.twig b/app/Views/errors/403.twig index 12c9ccd..cf06efe 100644 --- a/app/Views/errors/403.twig +++ b/app/Views/errors/403.twig @@ -9,6 +9,15 @@ place-items: center; height: 100vh; text-align: center; + padding: 2rem; + } + h1 { + font-size: 3rem; + margin-bottom: 0.5rem; + } + p { + font-size: 1.2rem; + color: #666; } diff --git a/app/Views/errors/404.twig b/app/Views/errors/404.twig index 537ad13..9fd3541 100644 --- a/app/Views/errors/404.twig +++ b/app/Views/errors/404.twig @@ -9,6 +9,15 @@ place-items: center; height: 100vh; text-align: center; + padding: 2rem; + } + h1 { + font-size: 3rem; + margin-bottom: 0.5rem; + } + p { + font-size: 1.2rem; + color: #666; } diff --git a/app/Views/errors/error.twig b/app/Views/errors/error.twig index 8165b46..7c7b884 100644 --- a/app/Views/errors/error.twig +++ b/app/Views/errors/error.twig @@ -3,13 +3,33 @@ Error + + -

🪵 Oops!

-

Something went wrong. Please try again later.

- {% if error_id %} - Error ID: - {{ error_id }} - {% endif %} +
+

🪵 Oops!

+

Something went wrong. Please try again later.

+ {% if error_id %} + Error ID: + {{ error_id }} + {% endif %} +
diff --git a/config/app.php b/config/app.php index 7cd9933..83b55a0 100644 --- a/config/app.php +++ b/config/app.php @@ -9,19 +9,27 @@ 'timezone' => env('APP_TIMEZONE', 'UTC'), 'locale' => env('APP_LOCALE', 'en'), 'key' => env('APP_KEY', 'your-secret-key-here'), - + // Framework settings 'framework' => 'SproutPHP', 'repo' => env('SPROUT_REPO', 'SproutPHP/framework'), 'user_agent' => env('SPROUT_USER_AGENT', 'sproutphp-app'), - - // Global middleware + + /** + * Global middleware + * Recommended Order + * 1. VerifyCsrfToken: Blocks malicious requests as early as possible + * 2. XssProtection: Set security headers before output + * 3. CorsMiddleware: Set CORS headers after CSRF/XSS checks + * NOTE: Other middlewares should be kept in after this order. + */ + 'global_middleware' => [ \App\Middlewares\VerifyCsrfToken::class, \App\Middlewares\XssProtection::class, \App\Middlewares\CorsMiddleware::class, ], - + // Session settings 'session' => [ 'name' => env('SESSION_NAME', 'sprout_session'), @@ -29,7 +37,7 @@ 'lifetime' => env('SESSION_LIFETIME', 120), 'path' => env('SESSION_PATH', '/storage/sessions'), ], - + // Logging 'log' => [ 'driver' => env('LOG_DRIVER', 'file'), diff --git a/core/Console/Commands/EnvCheck.php b/core/Console/Commands/EnvCheck.php new file mode 100644 index 0000000..4758203 --- /dev/null +++ b/core/Console/Commands/EnvCheck.php @@ -0,0 +1,47 @@ + 0; - // If user presses Enter or enters 0, use default minimal PicoCSS - if ($choice === '' || $choice === '0') { - $base = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/"; - $file = "pico.min.css"; - $url = $base . $file; - $dest = __DIR__ . '/../../public/assets/css/sprout.min.css'; - self::download($url, $dest); - // Do not return here, continue to docs prompt + if ($hasPico) { + echo "🌱 PicoCSS already installed. Skipping PicoCSS setup.\n"; } else { - $color = ''; - $needsColor = in_array($choice, ['5', '6', '7', '8']); - if ($needsColor) { - echo "Enter color name (amber, blue, cyan, fuchsia, green, grey, indigo, jade, lime, orange, pink, pumpkin, purple, red, sand, slate, violet, yellow, zinc): "; - $color = strtolower(trim(fgets(STDIN))); - $validColors = ['amber', 'blue', 'cyan', 'fuchsia', 'green', 'grey', 'indigo', 'jade', 'lime', 'orange', 'pink', 'pumpkin', 'purple', 'red', 'sand', 'slate', 'violet', 'yellow', 'zinc']; - if (!in_array($color, $validColors)) { - echo "Invalid color. Defaulting to blue.\n"; - $color = 'blue'; + // HTMX + self::download( + 'https://unpkg.com/htmx.org@latest/dist/htmx.min.js', + __DIR__ . '/../../public/assets/js/sprout.min.js' + ); + + // Pico.css - Prompt user for build type + echo "\n🌱 PicoCSS Setup — Choose your preferred CSS build:\n\n"; + echo "0) Default Sprout Layout (Minimal PicoCSS)\n"; + echo "1) Minimal (Standard) — pico.min.css\n"; + echo " [Full utility classes, grid, button styles, default blue theme]\n"; + echo "2) Classless — pico.classless.min.css\n"; + echo " [No classes needed, just semantic HTML, minimal blog/docs]\n"; + echo "3) Conditional — pico.conditional.min.css\n"; + echo " [Supports [data-theme], JS toggling, prefers-color-scheme]\n"; + echo "4) Fluid Classless — pico.fluid.classless.min.css\n"; + echo " [Full-width, no max-width, classless]\n"; + echo "5) Color Theme — e.g. pico.purple.min.css\n"; + echo "6) Classless + Color Theme — e.g. pico.classless.purple.min.css\n"; + echo "7) Conditional + Color Theme — e.g. pico.conditional.purple.min.css\n"; + echo "8) Fluid + Classless + Conditional + Color Theme — e.g. pico.fluid.classless.conditional.purple.min.css\n"; + echo "9) Color Palette Only — pico.colors.min.css\n"; + echo " [Just variables, add your own styles]\n\n"; + echo "Press Enter to use the default (Minimal PicoCSS).\n"; + echo "Enter the number of your choice [0-9]: "; + $choice = trim(fgets(STDIN)); + + // If user presses Enter or enters 0, use default minimal PicoCSS + if ($choice === '' || $choice === '0') { + $base = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/"; + $file = "pico.min.css"; + $url = $base . $file; + $dest = __DIR__ . '/../../public/assets/css/sprout.min.css'; + self::download($url, $dest); + // Do not return here, continue to docs prompt + } else { + $color = ''; + $needsColor = in_array($choice, ['5', '6', '7', '8']); + if ($needsColor) { + echo "Enter color name (amber, blue, cyan, fuchsia, green, grey, indigo, jade, lime, orange, pink, pumpkin, purple, red, sand, slate, violet, yellow, zinc): "; + $color = strtolower(trim(fgets(STDIN))); + $validColors = ['amber', 'blue', 'cyan', 'fuchsia', 'green', 'grey', 'indigo', 'jade', 'lime', 'orange', 'pink', 'pumpkin', 'purple', 'red', 'sand', 'slate', 'violet', 'yellow', 'zinc']; + if (!in_array($color, $validColors)) { + echo "Invalid color. Defaulting to blue.\n"; + $color = 'blue'; + } } - } - echo "Use minified version? [Y/n]: "; - $min = strtolower(trim(fgets(STDIN))); - $min = ($min === 'n') ? '' : '.min'; + echo "Use minified version? [Y/n]: "; + $min = strtolower(trim(fgets(STDIN))); + $min = ($min === 'n') ? '' : '.min'; - $base = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/"; - $file = ''; + $base = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/"; + $file = ''; - switch ($choice) { - case '1': - $file = "pico$min.css"; - break; - case '2': - $file = "pico.classless$min.css"; - break; - case '3': - $file = "pico.conditional$min.css"; - break; - case '4': - $file = "pico.fluid.classless$min.css"; - break; - case '5': - $file = "pico.$color$min.css"; - break; - case '6': - $file = "pico.classless.$color$min.css"; - break; - case '7': - $file = "pico.conditional.$color$min.css"; - break; - case '8': - $file = "pico.fluid.classless.conditional.$color$min.css"; - break; - case '9': - $file = "pico.colors$min.css"; - break; - default: - $file = "pico$min.css"; - break; - } + switch ($choice) { + case '1': + $file = "pico$min.css"; + break; + case '2': + $file = "pico.classless$min.css"; + break; + case '3': + $file = "pico.conditional$min.css"; + break; + case '4': + $file = "pico.fluid.classless$min.css"; + break; + case '5': + $file = "pico.$color$min.css"; + break; + case '6': + $file = "pico.classless.$color$min.css"; + break; + case '7': + $file = "pico.conditional.$color$min.css"; + break; + case '8': + $file = "pico.fluid.classless.conditional.$color$min.css"; + break; + case '9': + $file = "pico.colors$min.css"; + break; + default: + $file = "pico$min.css"; + break; + } - $url = $base . $file; - $dest = __DIR__ . '/../../public/assets/css/sprout.min.css'; + $url = $base . $file; + $dest = __DIR__ . '/../../public/assets/css/sprout.min.css'; - self::download($url, $dest); + self::download($url, $dest); - // Prompt for dark/light mode toggle - echo "\nWould you like to include a dark/light mode toggle button in your navbar? (y/n): "; - $includeToggle = strtolower(trim(fgets(STDIN))); - if ($includeToggle === 'y') { - $navbarPath = __DIR__ . '/../../app/Views/components/navbar.twig'; - $navbar = file_get_contents($navbarPath); - // Only add if not already present - if (strpos($navbar, 'theme-toggle-btn') === false) { - $toggleBtn = << - - - - HTML; - // Insert before of the right-side nav (last in file) - $navbar = preg_replace('/(<\/ul>)(?![\s\S]*<\/ul>)/', "$toggleBtn\n$1", $navbar, 1); - // Add the script before at the end - $toggleScript = << - SCRIPT; - $navbar = preg_replace('/<\/div>\s*$/', "$toggleScript\n", $navbar, 1); - file_put_contents($navbarPath, $navbar); - echo "✅ Dark/light mode toggle added to your navbar.\n"; + setInitialTheme(); + themeBtn.addEventListener('click', function() { + const currentTheme = html.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + themeIcon.textContent = newTheme === 'dark' ? '🌙' : '☀️'; + }); + })(); + + SCRIPT; + $navbar = preg_replace('/<\/div>\s*$/', "$toggleScript\n", $navbar, 1); + file_put_contents($navbarPath, $navbar); + echo "✅ Dark/light mode toggle added to your navbar.\n"; + } else { + echo "ℹ️ Dark/light mode toggle already present in your navbar.\n"; + } } else { - echo "ℹ️ Dark/light mode toggle already present in your navbar.\n"; + echo "ℹ️ You can add a dark/light mode toggle later by yourself if you wish.\n"; } - } else { - echo "ℹ️ You can add a dark/light mode toggle later by yourself if you wish.\n"; } } - // Prompt for offline documentation (always shown) - echo "\nWould you like to download offline documentation for SproutPHP? (y/n): "; - $downloadDocs = strtolower(trim(fgets(STDIN))); - if ($downloadDocs === 'y') { - $zipUrl = 'https://github.com/SproutPHP/documentation/archive/refs/heads/main.zip'; - $zipPath = __DIR__ . '/../../docs_temp.zip'; - $docsDir = __DIR__ . '/../../docs'; + if ($hasDocs) { + echo "🌱 Offline docs already installed. Skipping docs setup.\n"; + } else { + // Prompt for offline documentation (always shown) + echo "\nWould you like to download offline documentation for SproutPHP? (y/n): "; + $downloadDocs = strtolower(trim(fgets(STDIN))); + if ($downloadDocs === 'y') { + $zipUrl = 'https://github.com/SproutPHP/documentation/archive/refs/heads/main.zip'; + $zipPath = __DIR__ . '/../../docs_temp.zip'; + $docsDir = __DIR__ . '/../../docs'; - // Download the zip file - echo "\n📦 Downloading documentation...\n"; - $zipData = @file_get_contents($zipUrl); - if ($zipData === false) { - echo "❌ Failed to download documentation zip.\n"; - } else { - file_put_contents($zipPath, $zipData); - // Extract zip - $zip = new \ZipArchive(); - if ($zip->open($zipPath) === TRUE) { - // Remove existing docs dir if present - if (is_dir($docsDir)) { - self::rrmdir($docsDir); - } - // Extract to a temp location - $zip->extractTo(__DIR__ . '/../../'); - $zip->close(); - // Move extracted files to docs (flatten structure) - $extracted = __DIR__ . '/../../documentation-main'; - if (is_dir($extracted)) { - mkdir($docsDir, 0777, true); - $objects = scandir($extracted); - foreach ($objects as $object) { - if ($object != "." && $object != "..") { - $src = $extracted . DIRECTORY_SEPARATOR . $object; - $dst = $docsDir . DIRECTORY_SEPARATOR . $object; - if (is_dir($src)) { - self::copyDir($src, $dst); - } else { - copy($src, $dst); + // Download the zip file + echo "\n📦 Downloading documentation...\n"; + $zipData = @file_get_contents($zipUrl); + if ($zipData === false) { + echo "❌ Failed to download documentation zip.\n"; + } else { + file_put_contents($zipPath, $zipData); + // Extract zip + $zip = new \ZipArchive(); + if ($zip->open($zipPath) === TRUE) { + // Remove existing docs dir if present + if (is_dir($docsDir)) { + self::rrmdir($docsDir); + } + // Extract to a temp location + $zip->extractTo(__DIR__ . '/../../'); + $zip->close(); + // Move extracted files to docs (flatten structure) + $extracted = __DIR__ . '/../../documentation-main'; + if (is_dir($extracted)) { + mkdir($docsDir, 0777, true); + $objects = scandir($extracted); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + $src = $extracted . DIRECTORY_SEPARATOR . $object; + $dst = $docsDir . DIRECTORY_SEPARATOR . $object; + if (is_dir($src)) { + self::copyDir($src, $dst); + } else { + copy($src, $dst); + } } } + self::rrmdir($extracted); + echo "✅ Documentation downloaded to 'docs/'\n"; + } else { + echo "❌ Extraction failed.\n"; } - self::rrmdir($extracted); - echo "✅ Documentation downloaded to 'docs/'\n"; } else { - echo "❌ Extraction failed.\n"; + echo "❌ Failed to extract documentation zip.\n"; } - } else { - echo "❌ Failed to extract documentation zip.\n"; + // Delete the zip file + @unlink($zipPath); } - // Delete the zip file - @unlink($zipPath); + } else { + echo "ℹ️ Skipping offline documentation.\n"; } - } else { - echo "ℹ️ Skipping offline documentation.\n"; } } diff --git a/core/Database/DB.php b/core/Database/DB.php index 65962a6..990341f 100644 --- a/core/Database/DB.php +++ b/core/Database/DB.php @@ -12,13 +12,14 @@ class DB public static function connection() { - if (self::$pdo) return self::$pdo; + if (self::$pdo) + return self::$pdo; $connection = config('database.default', 'mysql'); $config = config("database.connections.$connection"); - + if (!$config) { - die("❌ Database connection '$connection' not found in config."); + throw new \RuntimeException("❌ Database connection '$connection' not found in config."); } $host = $config['host'] ?? 'localhost'; @@ -27,7 +28,7 @@ public static function connection() $username = $config['username'] ?? 'root'; $password = $config['password'] ?? ''; $charset = $config['charset'] ?? 'utf8mb4'; - + $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=$charset"; try { @@ -35,7 +36,7 @@ public static function connection() self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); return self::$pdo; } catch (PDOException $ex) { - die("❌ DB Connection failed: " . $ex->getMessage()); + throw new \RuntimeException("❌ DB Connection failed: " . $ex->getMessage(), 0, $ex); } } diff --git a/core/Error/ErrorHandler.php b/core/Error/ErrorHandler.php index 5d07f37..6868f36 100644 --- a/core/Error/ErrorHandler.php +++ b/core/Error/ErrorHandler.php @@ -6,6 +6,10 @@ class ErrorHandler { public static function register() { + // Skip registering handlers in CLI or PHPUnit (for testing) + if (php_sapi_name() === 'cli' || getenv('PHPUNIT_RUNNING') || defined('PHPUNIT_COMPOSER_INSTALL')) { + return; + } ini_set('display_errors', 0); // Prevent raw output ini_set('log_errors', 1); error_reporting(E_ALL); @@ -60,9 +64,23 @@ public static function render($message, $file, $line, $trace = null, $code = 500 ? "errors/{$code}" : "errors/error"; // fallback - echo \Core\View\View::render($viewFile, ['error_id' => $errorId]); + try { + echo \Core\View\View::render($viewFile, ['error_id' => $errorId]); + } catch (\Throwable $ex) { + echo "
"; + echo "

SproutPHP Error

"; + echo "Sorry, an error occurred and no error view could be loaded.
"; + echo "Error code: $code
"; + if ($errorId) { + echo "Error ID: $errorId
"; + } + echo "
"; + } } - exit; + // Only call exit for web requests, not for CLI + if (php_sapi_name() !== 'cli' && php_sapi_name() !== 'phpdbg') { + exit; + } } } diff --git a/core/Routing/Router.php b/core/Routing/Router.php index 7c05daa..0bdf36c 100644 --- a/core/Routing/Router.php +++ b/core/Routing/Router.php @@ -48,30 +48,55 @@ public function dispatch(Request $request) $method = $request->method; $uri = $request->uri; + $uri = ltrim($uri, '/'); // Try exact match first - if (isset($this->routes[$method][$uri])) { - $route = $this->routes[$method][$uri]; + if (isset($this->routes[$method]['/' . $uri])) { + $route = $this->routes[$method]['/' . $uri]; return $this->runRoute($route, $request); } // Try pattern match for dynamic parameters if (isset($this->routes[$method])) { foreach ($this->routes[$method] as $pattern => $route) { + $normalizedPattern = ltrim($pattern, '/'); $paramNames = []; - $regex = preg_replace_callback('/\{([a-zA-Z0-9_]+)(:([^}]+))?\}/', function ($matches) use (&$paramNames) { - $paramNames[] = $matches[1]; - if (isset($matches[3])) { - return '(' . $matches[3] . ')'; // custom regex + $regex = preg_replace_callback('/\{([a-zA-Z0-9_]+)(\??)(?::([^}]+))?\}/', function ($matches) use (&$paramNames) { + $name = $matches[1]; + $optional = $matches[2] === '?'; + $customRegex = isset($matches[3]) ? $matches[3] : null; + $paramNames[] = $name; + $pattern = $customRegex ?: '[^/]+'; + $segment = '(' . $pattern . ')'; + if ($optional) { + // Make the parameter optional (no extra slash) + return '(?:' . $segment . ')?'; + } else { + return $segment; } - return '([^/]+)'; // default: match anything except / - }, $pattern); - $regex = '#^' . $regex . '$#'; - if (preg_match($regex, $uri, $matches)) { + }, '/' . $normalizedPattern); + // Allow for routes that end with optional params (trailing slash optional) + $regex = '#^' . rtrim($regex, '/') . '/?$#'; + if ($pattern === '/test/{name}') { + $result = preg_match($regex, '/' . $uri, $matches); + // dd([ + // 'pattern' => $pattern, + // 'regex' => $regex, + // 'uri' => '/' . $uri, + // 'preg_match_result' => $result, + // 'matches' => $matches ?? [] + // ]); + } + if (preg_match($regex, '/' . $uri, $matches)) { array_shift($matches); // remove full match + // Fill missing optional params with null + while (count($matches) < count($paramNames)) { + $matches[] = null; + } $params = array_combine($paramNames, $matches); return $this->runRoute($route, $request, $params); } } + // dd(array_keys($this->routes[$method])); } // No match found @@ -104,7 +129,12 @@ protected function runRoute($route, $request, $params = []) } elseif (isset(MiddlewareRegistry::$map[$mw])) { $resolvedMiddleware[] = MiddlewareRegistry::$map[$mw]; } else { - throw new \Exception("Unknown middleware: $mw"); + $msg = "[SproutPHP] Middleware alias '$mw' is not registerd. Please check your routes or MiddlewareRegistry."; + if (function_exists('log_error')) { + log_error($msg); + } + + throw new \Exception("$msg"); } } diff --git a/core/Support/Validator.php b/core/Support/Validator.php index 4951cd9..d3d0ba3 100644 --- a/core/Support/Validator.php +++ b/core/Support/Validator.php @@ -392,4 +392,22 @@ protected function validateImage($field) $this->errors[$field] = "The $field must be a valid image."; } } + + /** + * File Size: File must not exceed given size in KB + * Usage: 'avatar' => 'file_size:2048' (max 2MB) + */ + protected function validateFile_size($field, $param) + { + $maxKb = (int) $param; + $file = $_FILES[$field] ?? null; + if (!$file || !is_uploaded_file($file['tmp_name'])) { + $this->errors[$field] = "The $field must be a file."; + return; + } + $sizeKb = (int) ($file['size'] / 1024); + if ($sizeKb > $maxKb) { + $this->errors[$field] = "The $field must not be greater than $maxKb KB."; + } + } } diff --git a/core/View/View.php b/core/View/View.php index ebbdf2e..aff834e 100644 --- a/core/View/View.php +++ b/core/View/View.php @@ -26,6 +26,15 @@ public static function init() if (!is_dir($cachePath)) { mkdir($cachePath, 0777, true); } + + if (!is_writable($cachePath)) { + // Show a clear error and fallback to no cache + echo "
"; + echo "SproutPHP Error: Twig cache directory $cachePath is not writable!
"; + echo "Please check permissions or set view.twig.cache to false in your config.
"; + echo "
"; + $cachePath = false; // Fallback to no cache + } } self::$twig = new Environment($loader, [ diff --git a/public/index.php b/public/index.php index 8fd5900..fde3c74 100644 --- a/public/index.php +++ b/public/index.php @@ -13,10 +13,22 @@ require_once __DIR__ . '/../vendor/autoload.php'; /** - * Starting the session after autoload + * Setting the session path and starting the session after autoload */ +$sessionConfig = config('app.session', []); +$sessionDriver = $sessionConfig['driver'] ?? 'file'; + +if ($sessionDriver === 'file') { + $sessionPath = $sessionConfig['path'] ?? '/storage/sessions'; + $fullSessionPath = realpath(__DIR__ . '/../') . $sessionPath; + if (!is_dir($fullSessionPath)) { + mkdir($fullSessionPath, 0777, true); + } + session_save_path($fullSessionPath); +} + if (session_status() !== PHP_SESSION_ACTIVE) { - session_name(config('app.session.name', 'sprout_session')); + session_name($sessionConfig['name'] ?? 'sprout_session'); session_start(); } diff --git a/sprout b/sprout index 960035f..f18a71b 100644 --- a/sprout +++ b/sprout @@ -1,6 +1,7 @@ #!/usr/bin/env php