diff --git a/.env.example b/.env.example index c87c8f4..3e9e73a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,56 @@ +# App +APP_NAME=SproutPHP APP_ENV=local APP_DEBUG=true APP_URL=http://localhost:9090 +APP_TIMEZONE=UTC +APP_LOCALE=en +APP_KEY=your-secret-key-here + +# Repo Info For Versioning SPROUT_REPO=SproutPHP/framework SPROUT_USER_AGENT=sproutphp-app +# Database +DB_CONNECTION=mysql DB_HOST=localhost +DB_PORT=3306 DB_NAME=sprout DB_USER=root DB_PASS= + +# Mail +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailgun.org +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=noreply@sproutphp.com +MAIL_FROM_NAME=SproutPHP + +# Cache +CACHE_DRIVER=file +CACHE_PATH=/storage/cache + +# Session Configuration +SESSION_NAME=sprout_session +SESSION_DRIVER=file +SESSION_LIFETIME=120 +SESSION_PATH=/storage/sessions + +# Storage +STORAGE_DISK=public +STORAGE_PUBLIC_ROOT=storage/app/public +STORAGE_PUBLIC_URL=/storage +STORAGE_PRIVATE_ROOT=storage/app/private + +# Security +CSRF_ENABLED=true +XSS_PROTECTION=true +CORS_ENABLED=false + +# View +VIEW_ENGINE=twig +TWIG_CACHE=false +TWIG_DEBUG=true \ No newline at end of file diff --git a/.gitignore b/.gitignore index a067216..d0e7a57 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ composer.lock # Application specific /public/uploads/ -/public/storage/ \ No newline at end of file +/public/storage/ +/docs \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..556b36a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +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`). +Earlier releases (`v0.1.0-alpha.1` to `v0.1.7-alpha.1`) were experimental and do not strictly follow SemVer conventions. + +--- + +## [v0.1.7-alpha.3] - 2024-06-13 + +### Added +- Storage root is now set to an absolute path by default for reliability (no .env needed) +- Improved Storage helper documentation and usage +- Enhanced symlink command for better cross-platform compatibility +- Updated documentation for new storage system and best practices + +### Fixed +- Prevented duplicate/nested storage paths in uploads +- General codebase and documentation improvements + +--- + +## [v0.1.7-alpha.2] - 2025-07-15 + +### Added +- Storage helper for file uploads, saving to `public/uploads` and generating URLs. +- Modern file access in controllers: `$request->file('avatar')`, `$request->hasFile('avatar')`. +- Unified request data: merges `$_GET`, `$_POST`, and JSON body. +- `mimes` and `image` validation rules for secure file uploads. +- HTMX-powered file upload with progress bar in the main form (no JS required). +- Generic error-clearing script for all form fields. + +### Changed +- File uploads are now web-accessible by default. +- Improved documentation for file upload, validation, and request handling. + +### Fixed +- No more duplicate `/uploads/uploads/...` in file URLs. + +--- + +## Legacy Experimental Releases + +These were single-shot development releases with no progressive alpha/beta cycle. + +| Version Tag | Notes | +| ---------------- | ----------------------------------------- | +| `v0.1.0-alpha.1` | Initial experimental release | +| `v0.1.1-alpha.1` | Feature/bug updates without SemVer phases | +| `v0.1.2-alpha.1` | Same as above | +| `v0.1.3-alpha.1` | — | +| `v0.1.4-alpha.1` | — | +| `v0.1.5-alpha.1` | — | +| `v0.1.6-alpha.1` | — | +| `v0.1.7-alpha.1` | Final experimental/unstable release | + +--- + +**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 + +### Added +- Dynamic route parameter support (e.g., `/user/{id}`, `/blog/{slug}`) for CRUD and flexible routing +- Robust CSRF protection via middleware and helpers (works for forms, AJAX, and HTMX) +- SPA-like file upload and form handling with HTMX (including indicators and grid UI) +- Secure private file upload/download (no direct links, internal access only) +- Consistent CSRF token management (single session key, helpers, and middleware) + +### Improved +- UI/UX for validation and file upload forms (two-column grid, spinner, SPA feel) +- Path resolution for storage (public/private separation, symlink support) +- Code structure: CSRF logic moved to helpers/middleware, no raw PHP in entry + +### Fixed +- Issues with file download on PHP built-in server (now uses query param for compatibility) +- Consistency in CSRF token usage across the framework + +### Removed +- Exposed raw CSRF logic from entry point + +--- diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..9691746 --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,156 @@ +# SproutPHP Configuration Guide + +This document outlines all available configuration options for SproutPHP framework. + +## Environment Variables (.env file) + +### Application Configuration +```env +APP_NAME=SproutPHP +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:9090 +APP_TIMEZONE=UTC +APP_LOCALE=en +APP_KEY=your-secret-key-here +``` + +### Database Configuration +```env +DB_CONNECTION=mysql +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=sprout +DB_USER=root +DB_PASS= +DB_PREFIX= +``` + +### Security Configuration +```env +# CSRF Protection +CSRF_ENABLED=true +CSRF_TOKEN_NAME=_token +CSRF_EXPIRE=3600 + +# XSS Protection +XSS_PROTECTION=true +XSS_MODE=block # 'block', 'sanitize', or '0' to disable + +# Content Security Policy +CSP_ENABLED=true +CSP_REPORT_ONLY=false +CSP_REPORT_URI= + +# CORS +CORS_ENABLED=false +CORS_ALLOWED_ORIGINS=* +CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE +CORS_ALLOWED_HEADERS=Content-Type,Authorization +``` + +### Mail Configuration +```env +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailgun.org +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=noreply@sproutphp.com +MAIL_FROM_NAME=SproutPHP +``` + +### Cache Configuration +```env +CACHE_DRIVER=file +CACHE_PATH=/storage/cache +``` + +### Session Configuration +```env +SESSION_NAME=sprout_session +SESSION_DRIVER=file +SESSION_LIFETIME=120 +SESSION_PATH=/storage/sessions +``` + +### Logging Configuration +```env +LOG_DRIVER=file +LOG_LEVEL=debug +LOG_PATH=/storage/logs +``` + +### View Configuration +```env +VIEW_ENGINE=twig +TWIG_CACHE=false +TWIG_DEBUG=true +TWIG_AUTO_RELOAD=true +TWIG_STRICT_VARIABLES=false +``` + +### Framework Configuration +```env +SPROUT_REPO=SproutPHP/framework +SPROUT_USER_AGENT=sproutphp-app +``` + +## Configuration Usage + +### In PHP Code +```php +// Get app name +$appName = config('app.name'); + +// Get database host +$dbHost = config('database.connections.mysql.host'); + +// Check if XSS protection is enabled +$xssEnabled = config('security.xss.enabled'); + +// Get CSP mode +$cspMode = config('security.csp.report_only'); +``` + +### In Twig Templates +```twig +{# Get app name #} +

{{ config('app.name') }}

+ +{# Check environment #} +{% if config('app.env') == 'local' %} +
Development Mode
+{% endif %} +``` + +## Security Features + +### XSS Protection +The framework automatically adds XSS protection headers based on your configuration: + +- **Development**: Relaxed CSP policy allowing inline styles and external images +- **Production**: Strict CSP policy for maximum security + +### CSRF Protection +CSRF tokens are automatically generated and validated for state-changing requests (POST, PUT, PATCH, DELETE). + +### Content Security Policy +CSP headers are automatically set based on your environment: +- **Local/Debug**: Allows `unsafe-inline` for styles and external images +- **Production**: Strict policy with no unsafe directives + +## Environment-Specific Behavior + +### Local Environment +- Debug information displayed +- Relaxed security policies +- Detailed error messages +- HTMX debug indicator + +### Production Environment +- No debug information +- Strict security policies +- Generic error pages +- Optimized performance settings \ No newline at end of file diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..73e11d5 --- /dev/null +++ b/DOCS.md @@ -0,0 +1,671 @@ +# SproutPHP Documentation + +## [v0.1.7-beta.1] - 2024-06-09 + +### New Features & Improvements + +- **Dynamic Route Parameters:** Define routes with parameters (e.g., `/user/{id}`, `/blog/{slug}`) for flexible CRUD and API endpoints. +- **Robust CSRF Protection:** Middleware-based CSRF protection for all state-changing requests (forms, AJAX, HTMX). Use `{{ csrf_field()|raw }}` in forms and `{{ csrf_token() }}` for AJAX/HTMX headers. All tokens use the `_csrf_token` session key. +- **SPA-like Interactivity with HTMX:** Use HTMX attributes for seamless, partial updates (e.g., form submissions, file uploads) and request indicators (spinners). Example: + ```html +
+ ... +
+ ``` +- **Private File Handling:** Upload files to private storage (not web-accessible). Download private files only via internal controller methods (no direct links). Example: + ```php + // Upload + Storage::put($file, '', 'private'); + // Download (controller) + $path = Storage::path($filename, 'private'); + ``` +- **Storage Improvements:** Storage paths are now always resolved relative to the project root. Public/private separation, symlink support for serving public files, and compatibility with the PHP built-in server for downloads. +- **UI/UX:** Two-column grid for validation and file upload forms, spinner/indicator support, and SPA feel for user interactions. + +--- + +## Included by Default + +- **HTMX** for modern, interactive UIs (already loaded in your base template) +- **PicoCSS** for beautiful, minimal styling (already loaded in your base template) +- **Twig** for templating +- **CLI** for scaffolding and project management +- **Error pages** and basic debug tools + +## Example: Hello World Route + +```php +// routes/web.php +Route::get('/home', 'HomeController@index'); +``` + +```php +// app/Controllers/HomeController.php +class HomeController { + public function index() { + return view('home', ['title' => 'Hello, World!']); + } +} +``` + +```twig +{# app/Views/home.twig #} +

{{ title }}

+``` + +## Why Minimal? + +- **Faster response times** +- **Lower memory usage** +- **Easier to understand and debug** +- **No vendor lock-in** +- **You are in control** + +## What This Framework Is NOT + +- Not a Laravel, Symfony, CodeIgniter, or Slim clone +- Not a full-stack SPA framework +- Not for those who want everything done for them + +## What's Included + +- Minimal MVC structure +- Simple routing +- Twig templating (optional to swap for PHP) +- Essential CLI tools (scaffolding) +- Custom error handling +- **Centralized configuration system** with `config()` helper +- **Enhanced security** with configurable XSS and CSP protection +- **HTMX and PicoCSS pre-installed** + +## Features & Improvements Coming Soon + +| Feature/Improvement | Status | Notes/Suggestions | +| ------------------------------------------ | ---------------- | -------------------------------------------- | +| [ ] Event System | In Consideration | Event/listener system | +| [ ] Localization (i18n) | In Consideration | Translation files and helpers | +| [ ] Caching (Redis, Memcached, etc.) | In Consideration | Cache abstraction, Redis/Memcached support | +| [ ] Testing Utilities | In Consideration | PHPUnit integration and helpers | +| [x] File Uploads & Storage | In Consideration | File upload and storage abstraction | +| [ ] Command Bus/CQRS | In Consideration | Command handler system | +| [ ] Form Builder | In Consideration | Dynamic form generation and validation | +| [ ] API Support (JWT, rate limiting, etc.) | In Consideration | API middleware, JWT, transformers | +| [ ] ORM/Query Builder | In Consideration | Query builder/ORM for easier DB access | +| [ ] Model Relationships | In Consideration | hasOne, hasMany, belongsTo, etc. | +| [ ] Package Installation System | In Consideration | Install and manage reusable packages/plugins | + +## Optional Packages + +SproutPHP is designed to be lightweight. The following features are (or will be) available as optional packages, so you can install only what you need: + +- Authentication & Authorization +- Admin Panel/CRUD +- Advanced Error Handling +- API Support (JWT, rate limiting, etc.) +- File Uploads & Storage +- Queue/Job System +- Event System +- Localization (i18n) +- Caching (Redis, Memcached, etc.) +- Testing Utilities +- Asset Pipeline +- Social Login +- Notifications +- Payment Integration +- SEO Tools +- Form Builder +- User Profile/Avatar +- Two-Factor Authentication +- Command Bus/CQRS +- GraphQL Support + +## What's NOT Included + +- No asset pipeline or `node_modules` +- No heavy ORM (use Models for direct DB access) +- No built-in authentication (add your own as needed) +- No complex middleware (keep it simple) + +## Configuration System + +SproutPHP now includes a powerful configuration system: + +```php +// Access configuration values +$appName = config('app.name'); +$dbHost = config('database.connections.mysql.host'); +$xssEnabled = config('security.xss.enabled'); +``` + +### Configuration Files + +- `config/app.php` - Application settings and global middleware +- `config/database.php` - Database connections +- `config/security.php` - Security settings (CSRF, XSS, CSP) +- `config/view.php` - View engine settings +- `config/cache.php` - Cache configuration +- `config/mail.php` - Mail configuration + +See `CONFIGURATION.md` for complete documentation. + +## Validation System (NEW in v0.1.6-alpha.1) + +SproutPHP now includes a minimal, extensible validation system for validating user input in controllers and forms. + +### Overview + +- Validate associative arrays (e.g., $\_POST, custom data) +- Supports common rules: required, email, min, max, numeric, string, in, etc. +- Collects and returns error messages +- Easily display errors in Twig views +- Extensible: add your own rules as needed + +### Example Usage in a Controller + +```php +use Core\Support\Validator; + +public function handleForm() +{ + $data = $_POST; + $validator = new Validator($data, [ + 'email' => 'required|email', + 'name' => 'required|min:3', + 'age' => 'required|numeric|min:18', + 'role' => 'in:admin,user,guest', + ]); + + if ($validator->fails()) { + // Pass errors and old input to the view + return view('your-form-view', [ + 'errors' => $validator->errors(), + 'old' => $data, + ]); + } + + // Proceed with valid data +} +``` + +### Available Rules + +- `required` — Field must not be empty +- `email` — Must be a valid email address +- `min:N` — Minimum length N +- `max:N` — Maximum length N +- `numeric` — Must be a number +- `integer` — Must be an integer +- `string` — Must be a string +- `boolean` — Must be a boolean value +- `array` — Must be an array +- `in:val1,val2,...` — Value must be one of the listed options +- `not_in:val1,val2,...` — Value must NOT be one of the listed options +- `same:field` — Must match another field +- `different:field` — Must be different from another field +- `confirmed` — Must have a matching {field}\_confirmation value +- `regex:pattern` — Must match a regex pattern +- `url` — Must be a valid URL +- `ip` — Must be a valid IP address +- `date` — Must be a valid date +- `before:date` — Must be a date before the given date +- `after:date` — Must be a date after the given date +- `nullable` — Field is allowed to be null (affects other rules) +- `present` — Field must be present in the input (even if empty) +- `digits:N` — Must be exactly N digits +- `digits_between:min,max` — Must be between min and max digits +- `size:N` — Must be exactly N characters (for strings) or N value (for numbers/arrays) +- `starts_with:val1,val2,...` — Must start with one of the given values +- `ends_with:val1,val2,...` — Must end with one of the given values +- `uuid` — Must be a valid UUID + +You can add more rules by extending the Validator class. + +### Displaying Errors in Twig + +```twig +
+ + {% if errors.name %} +
{{ errors.name }}
+ {% endif %} + + {% if errors.email %} +
{{ errors.email }}
+ {% endif %} + +
+``` + +### Notes + +- Use the `validate()` helper for a shortcut: `validate($data, $rules)` +- See the Validator class in `core/Support/Validator.php` for more details and to add custom rules. + +## Dark/Light Mode Support + +SproutPHP supports instant dark and light mode switching using PicoCSS's built-in color schemes. The framework provides an optional sun/moon icon button in the navbar to toggle the theme. + +- PicoCSS automatically applies dark or light styles based on the `data-theme` attribute on ``. +- The toggle button updates `data-theme` to `dark` or `light` and saves the preference in localStorage. +- No extra CSS is needed for the color scheme itself—PicoCSS handles all color changes. + +### Example Usage + +Add this to your navbar: + +```html + +``` + +Add this script (in your layout or navbar): + +```html + +``` + +**Result:** + +- The icon (☀️ or 🌙) is shown in the navbar. +- Clicking the icon toggles the theme and updates the icon instantly. +- The theme preference is saved in `localStorage`. +- PicoCSS automatically applies the correct color scheme. + +## Using HTMX and PicoCSS + +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.** + +### Usage + +1. **Add a helper to `helpers.php`:** + + ```php + // core/Support/helpers.php + if (!function_exists('my_custom_helper')) { + function my_custom_helper($arg) { + return strtoupper($arg); + } + } + ``` + + Now you can use it in Twig: + + ```twig + {{ my_custom_helper('hello') }} + ``` + +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. + +## 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"]`. + +--- + +See the rest of this documentation for more on validation, request handling, and UI best practices. diff --git a/README.md b/README.md index 7944e9e..5a343ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # SproutPHP — Minimalist, Batteries-Included PHP Framework ## 🌱 Why "Sprout"? + SproutPHP is a **growing and budding framework**—a tiny sprout with the ambition to become a solid tree. It starts small, fast, and minimal, but is designed to evolve and grow with the needs of its community. The name "Sprout" reflects this progressive, ever-improving philosophy. If you want to be part of something that starts simple and grows strong, you’re in the right place! ## Philosophy @@ -11,12 +12,6 @@ SproutPHP is for developers who know PHP, HTML, and CSS, and want to build fast, - **Minimal dependencies**: Only essential Composer packages, no `node_modules` or asset pipeline. - **MVC structure**: Clear separation of Controllers, Models, and Views. - **Twig templating**: Clean, secure, and fast rendering (optional to swap for native PHP if desired). -- **Simple routing**: Easy-to-understand routing system. -- **Powerful CLI**: Scaffold controllers, models, migrations, views (with HTMX demo), resources, routes, and components with the `sprout` CLI—no manual file creation needed. -- **Batteries included**: HTMX and PicoCSS are pre-installed and loaded in your base template. Your app is modern and beautiful from the start. -- **Direct SQL or minimal DB abstraction**: Use the Model layer for direct database access, not a heavy ORM. -- **Custom error handling**: Lean error handler with dev/prod toggle. -- **Security helpers**: Basic CSRF and XSS protection (documented, not forced). ## Getting Started @@ -39,91 +34,17 @@ SproutPHP is for developers who know PHP, HTML, and CSS, and want to build fast, 7. **Create views in** `app/Views/` (Twig by default). 8. **Create models in** `app/Models/` for database access. -## Included by Default -- **HTMX** for modern, interactive UIs (already loaded in your base template) -- **PicoCSS** for beautiful, minimal styling (already loaded in your base template) -- **Twig** for templating -- **CLI** for scaffolding and project management -- **Error pages** and basic debug tools - -## Example: Hello World Route - -```php -// routes/web.php -Route::get('/home', 'HomeController@index'); -``` - -```php -// app/Controllers/HomeController.php -class HomeController { - public function index() { - return view('home', ['title' => 'Hello, World!']); - } -} -``` - -```twig -{# app/Views/home.twig #} -

{{ title }}

-``` - -## Why Minimal? -- **Faster response times** -- **Lower memory usage** -- **Easier to understand and debug** -- **No vendor lock-in** -- **You are in control** - -## What This Framework Is NOT -- Not a Laravel, Symfony, CodeIgniter, or Slim clone -- Not a full-stack SPA framework -- Not for those who want everything done for them +## Learn More -## What’s Included -- Minimal MVC structure -- Simple routing -- Twig templating (optional to swap for PHP) -- Essential CLI tools (scaffolding) -- Custom error handling -- Basic security helpers -- **HTMX and PicoCSS pre-installed** - -## What’s NOT Included -- No asset pipeline or `node_modules` -- No heavy ORM (use Models for direct DB access) -- No built-in authentication (add your own as needed) -- No complex middleware (keep it simple) - -## Using HTMX and PicoCSS -You do **not** need to install or include HTMX or PicoCSS yourself—they are already downloaded and loaded in your base template: - -```html - - -``` - -## 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 +- **Full documentation, features, and advanced usage:** See [DOCS.md](./DOCS.md) +- **Contributing:** See [CONTRIBUTING.md](./CONTRIBUTING.md) (if available) **Star this repo** to show your support and follow future updates! -**Sponsor development:** [buymeacoffee.com/yanikkumar](https://buymeacoffee.com/yanikkumar) +**Sponsor development:** [![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4%EF%B8%8F-pink?logo=githubsponsors&style=flat-square)](https://github.com/sponsors/yanikkumar) or [![Buy Me a Tea](https://img.shields.io/badge/Buy%20Me%20a%20Tea-ffdd00?logo=buymeacoffee&logoColor=black&style=flat-square)](https://buymeacoffee.com/yanikkumar) -> SproutPHP will continue to grow with new features, improvements, and community input. Stay tuned for updates, and help shape the future of this framework! +>SproutPHP will continue to grow with new features, improvements, and community input. Stay tuned for updates, and help shape the future of this framework! ## License -MIT \ No newline at end of file + +MIT diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..760c7b2 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,27 @@ +# Release Notes: v0.1.7-beta.1 (2024-06-09) + +## Highlights + +- **First Beta Release!** SproutPHP is now feature-complete and ready for broader testing and feedback. +- **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. +- **Private File Handling:** Secure upload/download of private files, accessible only via internal methods. +- **Cleaner Codebase:** All CSRF logic is now in helpers/middleware, not exposed in entry scripts. + +## Upgrade Notes + +- All CSRF tokens now use the `_csrf_token` session key. Update any custom code to use the new helpers. +- File downloads now use a query parameter (`?file=...`) for compatibility with the PHP built-in server. +- If you use custom routes, you can now use `{param}` and `{param:regex}` patterns. + +## What's New + +- Two-column grid UI for validation and file upload forms +- SPA feel with HTMX indicators and partial updates +- Consistent and secure CSRF handling everywhere +- Improved storage path resolution and symlink support + +## Thank You! + +Thank you for testing and contributing to SproutPHP. Please report any issues or feedback as we move toward a stable release. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..064809a --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,43 @@ +# Versioning Policy + +This project follows [Semantic Versioning 2.0.0](https://semver.org/), starting from version `v0.1.8-alpha.1`. + +--- + +## 🧪 Pre-release lifecycle + +Each version progresses through the following phases: + +vX.Y.Z-alpha.n → vX.Y.Z-beta.n → vX.Y.Z-rc.n → vX.Y.Z + +| Stage | Tag Example | Purpose | +| ------ | ---------------- | ------------------------------------------- | +| Alpha | `v0.2.0-alpha.1` | Early development, unstable, features added | +| Beta | `v0.2.0-beta.1` | Feature complete, internal/external testing | +| RC | `v0.2.0-rc.1` | Ready for release, final polish | +| Stable | `v0.2.0` | Production-ready release | + +--- + +## 📦 Legacy Pre-SemVer Releases + +The following tags were used during the early development phase and do **not follow** progressive SemVer conventions: + +v0.1.0-alpha.1 +v0.1.1-alpha.1 +v0.1.2-alpha.1 +v0.1.3-alpha.1 +v0.1.4-alpha.1 +v0.1.5-alpha.1 +v0.1.6-alpha.1 +v0.1.7-alpha.1 + +These were considered one-shot alpha builds during initial experimentation. They remain frozen in GitHub history for archival purposes. + +--- + +## 🏁 First SemVer-compliant tag + +v0.1.7-alpha.2 + +This tag marks the transition to proper SemVer pre-release flow and consistent versioning practices. diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 390018b..e0aaa0d 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -7,7 +7,8 @@ class HomeController public function index() { $release = getLatestRelease(); - return "

A minimilistic php-framework designed for go-to developer without the need for javascript or heavy modules. 🌳

- SproutPHP latest release: $release"; + $appName = config('app.name', 'SproutPHP'); + return "

A minimalist php-framework designed for go-to developer without the need for javascript or heavy modules. 🌳

+ {$appName} latest release: $release
"; } } diff --git a/app/Controllers/ValidationTestController.php b/app/Controllers/ValidationTestController.php new file mode 100644 index 0000000..c6347f8 --- /dev/null +++ b/app/Controllers/ValidationTestController.php @@ -0,0 +1,170 @@ + [], + 'old' => [], + ]); + } + + // List private files for the right grid + $privateFiles = $this->getPrivateFiles(); + + return view('validation-test', [ + 'title' => 'ValidationTestController Index', + 'privateFiles' => $privateFiles, + ]); + } + + /** + * Helper to get private files list + */ + protected function getPrivateFiles() + { + $privateDir = Storage::path('', 'private'); + $privateFiles = []; + + if (is_dir($privateDir)) { + foreach (scandir($privateDir) as $file) { + if ($file !== '.' && $file !== '..' && is_file($privateDir . '/' . $file)) { + $privateFiles[] = $file; + } + } + } + return $privateFiles; + } + + public function handleForm() + { + sleep(1); // 1 second delay + $request = Request::capture(); + + $data = [ + 'name' => $request->input('name'), + 'email' => $request->input('email'), + 'avatar' => $request->input('avatar'), + ]; + + // Or You can get all input data simply using + // $data = $request->data; + + $validator = new Validator($data, [ + 'email' => 'required|email', + 'name' => 'required|min:3', + 'avatar' => 'image|mimes:jpg,jpeg,png,gif', + ]); + + // File Upload Validation + $avatarError = null; + if ($request->hasFile('avatar')) { + $file = $request->file('avatar'); + $path = Storage::put($file, 'avatars'); + if (!$path) { + $avatarError = 'File upload failed.'; + } + } else { + $avatarError = "Please upload an avatar."; + } + + if ($validator->fails() || $avatarError) { + $errors = $validator->errors(); + if ($avatarError) + $errors['avatar'] = $avatarError; + // Return only the form fragment with errors for HTMX + return view('partials/validation-form', [ + 'errors' => $validator->errors(), + 'old' => $data, + ]); + } + + // On success, return a success message fragment + return view('partials/validation-success', [ + 'name' => $data['name'], + 'email' => $data['email'], + 'avatar_url' => isset($path) ? Storage::url($path) : null, + ]); + } + + /** + * Handle private file upload + */ + public function handlePrivateUpload() + { + $request = Request::capture(); + $error = null; + $path = null; + if ($request->hasFile('private_file')) { + $file = $request->file('private_file'); + $path = Storage::put($file, '', 'private'); + if (!$path) { + $error = 'Private file upload failed.'; + } + } else { + $error = 'Please select a file to upload.'; + } + + // HTMX: return only the private files list fragment + if (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true') { + $privateFiles = $this->getPrivateFiles(); + return view('partials/private-files-list', [ + 'privateFiles' => $privateFiles, + 'error' => $error, + ]); + } + + // Fallback: redirect back + header('Location: ' . $_SERVER['HTTP_REFERER']); + exit; + } + + /** + * Render private files list fragment (for HTMX) + */ + public function privateFilesListFragment() + { + $privateFiles = $this->getPrivateFiles(); + return view('partials/private-files-list', [ + 'privateFiles' => $privateFiles, + ]); + } + + /** + * Securely download a private file + */ + public function downloadPrivateFile() + { + $filename = isset($_GET['file']) ? urldecode($_GET['file']) : null; + if (!$filename) { + http_response_code(400); + echo 'Missing file parameter.'; + exit; + } + $privatePath = Storage::path($filename, 'private'); + if (!is_file($privatePath)) { + http_response_code(404); + echo 'File not found.'; + exit; + } + // Set headers for download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . basename($filename) . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . filesize($privatePath)); + readfile($privatePath); + exit; + } +} diff --git a/app/Middlewares/CorsMiddleware.php b/app/Middlewares/CorsMiddleware.php new file mode 100644 index 0000000..a8524ee --- /dev/null +++ b/app/Middlewares/CorsMiddleware.php @@ -0,0 +1,36 @@ +"; + } +} \ No newline at end of file diff --git a/app/Middlewares/VerifyCsrfToken.php b/app/Middlewares/VerifyCsrfToken.php new file mode 100644 index 0000000..26edb1c --- /dev/null +++ b/app/Middlewares/VerifyCsrfToken.php @@ -0,0 +1,24 @@ +getCspPolicy(); + header("Content-Security-Policy: {$cspPolicy}"); + } + + $response = $next($request); + return $response; + } + + private function getCspPolicy(): string + { + $env = config('app.env', 'local'); + $debug = config('app.debug', false); + + // Base CSP policy + $basePolicy = "default-src 'self'; object-src 'none';"; + + if ($env === 'local' || $debug) { + // Development: relaxed policy + return $basePolicy . " script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https://img.shields.io;"; + } else { + // Production: strict policy + return $basePolicy . " script-src 'self'; style-src 'self'; img-src 'self';"; + } + } +} \ No newline at end of file diff --git a/app/Views/Components/footer.twig b/app/Views/Components/footer.twig new file mode 100644 index 0000000..a56c9cf --- /dev/null +++ b/app/Views/Components/footer.twig @@ -0,0 +1,17 @@ +{# SproutPHP Component: footer.twig #} + \ No newline at end of file diff --git a/app/Views/Components/navbar.twig b/app/Views/Components/navbar.twig new file mode 100644 index 0000000..c41ae8b --- /dev/null +++ b/app/Views/Components/navbar.twig @@ -0,0 +1,62 @@ +{# SproutPHP Component: navbar.twig #} + + + diff --git a/app/Views/Components/spinner.twig b/app/Views/Components/spinner.twig new file mode 100644 index 0000000..1194a35 --- /dev/null +++ b/app/Views/Components/spinner.twig @@ -0,0 +1,29 @@ +{# SproutPHP Component: spinner.twig #} +
+
+
+ \ No newline at end of file diff --git a/app/Views/home.twig b/app/Views/home.twig index 935d027..9dc6688 100644 --- a/app/Views/home.twig +++ b/app/Views/home.twig @@ -1,20 +1,75 @@ {% extends "layouts/base.twig" %} -{% block title %}Home — SproutPHP{% endblock %} +{% block title %}Home — SproutPHP +{% endblock %} {% block content %} -

{{ title }}

-

Welcome to SproutPHP, the seed-to-plant minimal PHP framework 🌱

-

HTMX-ready, JS-optional, and developer-happy.

- - - -
+
+ SproutPHP Logo +

{{ config('app.name', 'SproutPHP') }}

+
+ {{ getLatestRelease }} + — + {{ config('app.env', 'local') }} + environment +
+

Welcome to + SproutPHP, the seed-to-plant minimal PHP framework 🌱
HTMX-ready, JS-optional, and developer-happy.

+
+ 📖 Documentation + 🌱 GitHub + ❤️ Sponsor +
+ +
+ +
+ {% if main_content is defined %} + {{ main_content|raw }} + {% endif %} +
+
+ + {% endblock %} diff --git a/app/Views/layouts/base.twig b/app/Views/layouts/base.twig index f7eecb4..2299586 100644 --- a/app/Views/layouts/base.twig +++ b/app/Views/layouts/base.twig @@ -5,28 +5,38 @@ {% block title %} - SproutPHP + {{ config('app.name', 'SproutPHP') }} {% endblock %} - -
{% block content %}{% endblock %} -
+ +
+ {% include 'components/navbar.twig' %} +
+ {% block content %} + {% if content is defined %} + {{ content|raw }} + {% endif %} + {% endblock %} +
+
- {% if env('APP_ENV') == 'local' %} -
- ⚡ HTMX Active -
- 📃 - HTMX Docs ↗ -
- {% endif %} + {% if config('app.env', 'local') == 'local' %} +
+ ⚡ HTMX Active +
+ 📃 + HTMX Docs ↗ +
+ {% endif %} - {% if app_debug %} - {{ debugbar|raw }} - {% endif %} - - + {% if app_debug %} + {{ debugbar|raw }} + {% endif %} + + {% include 'components/footer.twig' %} + + diff --git a/app/Views/partials/config-test.twig b/app/Views/partials/config-test.twig new file mode 100644 index 0000000..c474e63 --- /dev/null +++ b/app/Views/partials/config-test.twig @@ -0,0 +1,6 @@ +

Configuration Test

+

App Name: {{ app_name }}

+

Environment: {{ env }}

+

Debug: {{ debug ? 'true' : 'false' }}

+

Database Host: {{ db_host }}

+

Twig Cache: {{ twig_cache ? 'true' : 'false' }}

\ No newline at end of file diff --git a/app/Views/partials/debug-config.twig b/app/Views/partials/debug-config.twig new file mode 100644 index 0000000..9f959b7 --- /dev/null +++ b/app/Views/partials/debug-config.twig @@ -0,0 +1,7 @@ +

Config Debug

+

Config function exists: {{ config_exists ? 'Yes' : 'No' }}

+

App config loaded: {{ app_config_loaded ? 'Yes' : 'No' }}

+

Global middleware count: {{ middleware_count }}

+{% for mw in middlewares %} +

Middleware: {{ mw.name }} (exists: {{ mw.exists ? 'Yes' : 'No' }})

+{% endfor %} \ No newline at end of file diff --git a/app/Views/partials/env-test.twig b/app/Views/partials/env-test.twig new file mode 100644 index 0000000..2821d12 --- /dev/null +++ b/app/Views/partials/env-test.twig @@ -0,0 +1,2 @@ +

Environment Test

+

Current environment: {{ env }}

\ No newline at end of file diff --git a/app/Views/partials/private-files-list.twig b/app/Views/partials/private-files-list.twig new file mode 100644 index 0000000..83c36fd --- /dev/null +++ b/app/Views/partials/private-files-list.twig @@ -0,0 +1,13 @@ +

Private Files

+{% if privateFiles and privateFiles|length > 0 %} + +{% else %} +

No private files uploaded yet.

+{% endif %} diff --git a/app/Views/partials/security-test.twig b/app/Views/partials/security-test.twig new file mode 100644 index 0000000..6e7981f --- /dev/null +++ b/app/Views/partials/security-test.twig @@ -0,0 +1,8 @@ +

Security Configuration Test

+

XSS Protection: {{ xss_enabled ? 'Enabled' : 'Disabled' }}

+

XSS Mode: {{ xss_mode }}

+

CSP Enabled: {{ csp_enabled ? 'Enabled' : 'Disabled' }}

+

CSP Report Only: {{ csp_report_only ? 'Yes' : 'No' }}

+

Environment: {{ env }}

+

Debug Mode: {{ debug ? 'Enabled' : 'Disabled' }}

+

This text should be red in local environment

\ No newline at end of file diff --git a/app/Views/partials/validation-form.twig b/app/Views/partials/validation-form.twig new file mode 100644 index 0000000..5450c50 --- /dev/null +++ b/app/Views/partials/validation-form.twig @@ -0,0 +1,43 @@ +{% include "components/spinner.twig" %} +
+ {# CSRF TOKEN #} + {{ csrf_field()|raw }} +
+ + + {% if errors.name %} +
{{ errors.name }}
+ {% endif %} +
+
+ + + {% if errors.email %} +
{{ errors.email }}
+ {% endif %} +
+
+ + + {% if errors.avatar %} +
{{ errors.avatar }}
+ {% endif %} +
+ +
+ diff --git a/app/Views/partials/validation-success.twig b/app/Views/partials/validation-success.twig new file mode 100644 index 0000000..d29b794 --- /dev/null +++ b/app/Views/partials/validation-success.twig @@ -0,0 +1,12 @@ +
+

Form Submitted Successfully!

+

Name: + {{ name|e }}

+

Email: + {{ email|e }}

+ {% if avatar_url %} +

Avatar: + Avatar

+ {% endif %} + +
diff --git a/app/Views/partials/validation-test.twig b/app/Views/partials/validation-test.twig new file mode 100644 index 0000000..b5b06b2 --- /dev/null +++ b/app/Views/partials/validation-test.twig @@ -0,0 +1,6 @@ +{% block content %} +

Validation Test Form

+
+ {% include "partials/validation-form.twig" %} +
+{% endblock %} \ No newline at end of file diff --git a/app/Views/validation-test.twig b/app/Views/validation-test.twig new file mode 100644 index 0000000..a9c3a30 --- /dev/null +++ b/app/Views/validation-test.twig @@ -0,0 +1,26 @@ +{% extends "layouts/base.twig" %} +{% block title %}validation-test +{% endblock %} + +{% block content %} +
+
+

Validation Test Form

+ {% include "partials/validation-form.twig" %} +
+
+

Private File Upload

+
+ {{ csrf_field()|raw }} +
+ + +
+ +
+
+ {% include "partials/private-files-list.twig" %} +
+
+
+{% endblock %} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..7cd9933 --- /dev/null +++ b/config/app.php @@ -0,0 +1,39 @@ + env('APP_NAME', 'SproutPHP'), + 'env' => env('APP_ENV', 'local'), + 'debug' => env('APP_DEBUG', true), + 'url' => env('APP_URL', 'http://localhost'), + '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' => [ + \App\Middlewares\VerifyCsrfToken::class, + \App\Middlewares\XssProtection::class, + \App\Middlewares\CorsMiddleware::class, + ], + + // Session settings + 'session' => [ + 'name' => env('SESSION_NAME', 'sprout_session'), + 'driver' => env('SESSION_DRIVER', 'file'), + 'lifetime' => env('SESSION_LIFETIME', 120), + 'path' => env('SESSION_PATH', '/storage/sessions'), + ], + + // Logging + 'log' => [ + 'driver' => env('LOG_DRIVER', 'file'), + 'level' => env('LOG_LEVEL', 'debug'), + 'path' => env('LOG_PATH', '/storage/logs'), + ], +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..87001e4 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,22 @@ + env('CACHE_DRIVER', 'file'), + + 'stores' => [ + 'file' => [ + 'driver' => 'file', + 'path' => env('CACHE_PATH', '/storage/cache'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', 6379), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', 0), + ], + ], + + 'prefix' => env('CACHE_PREFIX', 'sprout_'), +]; \ No newline at end of file diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..1c40782 --- /dev/null +++ b/config/database.php @@ -0,0 +1,39 @@ + env('DB_CONNECTION', 'mysql'), + + 'connections' => [ + 'mysql' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 3306), + 'database' => env('DB_NAME', 'sprout'), + 'username' => env('DB_USER', 'root'), + 'password' => env('DB_PASS', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => env('DB_PREFIX', ''), + 'strict' => true, + 'engine' => null, + ], + + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', '/storage/database.sqlite'), + 'prefix' => env('DB_PREFIX', ''), + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 5432), + 'database' => env('DB_NAME', 'sprout'), + 'username' => env('DB_USER', 'postgres'), + 'password' => env('DB_PASS', ''), + 'charset' => 'utf8', + 'prefix' => env('DB_PREFIX', ''), + 'schema' => 'public', + ], + ] +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..f6c42dc --- /dev/null +++ b/config/mail.php @@ -0,0 +1,22 @@ + env('MAIL_DRIVER', 'smtp'), + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'noreply@sproutphp.com'), + 'name' => env('MAIL_FROM_NAME', 'SproutPHP'), + ], + 'drivers' => [ + 'smtp' => [ + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + ], + + 'sendmail' => [ + 'path'=>'/usr/sbin/sendmail -bs', + ] + ], +]; diff --git a/config/security.php b/config/security.php new file mode 100644 index 0000000..1c7d135 --- /dev/null +++ b/config/security.php @@ -0,0 +1,27 @@ + [ + 'enabled' => env('CSRF_ENABLED', true), + 'token_name' => env('CSRF_TOKEN_NAME', '_token'), + 'expire' => env('CSRF_EXPIRE', 3600), + ], + + 'xss' => [ + 'enabled' => env('XSS_PROTECTION', true), + 'mode' => env('XSS_MODE', 'block'), // 'block', 'sanitize', or '0' to disable + ], + + 'csp' => [ + 'enabled' => env('CSP_ENABLED', true), + 'report_only' => env('CSP_REPORT_ONLY', false), + 'report_uri' => env('CSP_REPORT_URI', null), + ], + + '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')), + ], +]; \ No newline at end of file diff --git a/config/storage.php b/config/storage.php new file mode 100644 index 0000000..e8708b6 --- /dev/null +++ b/config/storage.php @@ -0,0 +1,16 @@ + env('STORAGE_DISK', 'public'), + 'disks' => [ + 'public' => [ + 'root' => env('STORAGE_PUBLIC_ROOT', 'storage/app/public'), + 'url' => env('STORAGE_PUBLIC_LINK', '/storage'), + 'visibility' => 'public', + ], + 'private' => [ + 'root' => env('STORAGE_PRIVATE_ROOT', 'storage/app/private'), + 'visibility' => 'private', + ] + ], +]; \ No newline at end of file diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..40b9097 --- /dev/null +++ b/config/view.php @@ -0,0 +1,35 @@ + env('VIEW_ENGINE', 'twig'), + + 'twig' => [ + 'cache' => env('TWIG_CACHE', false), + 'debug' => env('TWIG_DEBUG', true), + 'auto_reload' => env('TWIG_AUTO_RELOAD', true), + 'strict_variables' => env('TWIG_STRICT_VARIABLES', false), + ], + + // Explicitly list helper functions to register with Twig which are defined in helpers.php + 'twig_helpers' => [ + 'view', + 'assets', + 'debug', + 'dd', + 'env', + 'log_error', + 'abort', + 'getLatestRelease', + 'is_ajax_request', + 'csrf_field', + 'config', + 'is_htmx_request', + 'render_fragment_or_full' + ], + + 'paths' => [ + 'views' => env('VIEW_PATH', '/app/Views'), + 'components' => env('COMPONENT_PATH', '/app/Views/components'), + 'layouts' => env('LAYOUT_PATH', '/app/Views/layouts'), + ], +]; diff --git a/core/Bootstrap/bootstrap.php b/core/Bootstrap/bootstrap.php index 7f294b6..7c0ae5a 100644 --- a/core/Bootstrap/bootstrap.php +++ b/core/Bootstrap/bootstrap.php @@ -1,14 +1,17 @@ -dispatch($request); \ No newline at end of file +/** + * Test config loading + */ +if (!function_exists('config')) { + throw new \Exception("Config helper function not found. Check if helpers.php is loaded."); +} + +/** + * Gloabl Middlewares + */ +$globalMiddleware = config('app.global_middleware', [ + VerifyCsrfToken::class, + XssProtection::class, +]); + +// Debug middleware loading +if (config('app.debug', false)) { + foreach ($globalMiddleware as $middleware) { + if (!class_exists($middleware)) { + throw new \Exception("Middleware class '$middleware' not found. Check autoloading."); + } + } +} + +$kernel = new MiddlewareKernel($globalMiddleware); +$response = $kernel->handle($request, function ($request) use ($router) { + return $router->dispatch($request); +}); + +echo $response; diff --git a/core/Console/Commands/Build.php b/core/Console/Commands/Build.php new file mode 100644 index 0000000..20c998c --- /dev/null +++ b/core/Console/Commands/Build.php @@ -0,0 +1,291 @@ +isDir()) continue; + $path = $file->getPathname(); + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $contents = file_get_contents($path); + $minified = $contents; + switch ($ext) { + case 'php': + // SKIP minifying PHP files to avoid breaking code + continue 2; + case 'twig': + // Remove Twig comments and extra whitespace + $minified = preg_replace('/\{#.*?#\}/s', '', $minified); // twig comments + $minified = preg_replace('/\s+/', ' ', $minified); // whitespace + break; + case 'css': + // Remove CSS comments and whitespace + $minified = preg_replace('#/\*.*?\*/#s', '', $minified); // block comments + $minified = preg_replace('/\s+/', ' ', $minified); // whitespace + break; + case 'js': + // Remove JS comments and whitespace + $minified = preg_replace('#/\*.*?\*/#s', '', $minified); // block comments + $minified = preg_replace('#//.*#', '', $minified); // line comments + $minified = preg_replace('/\s+/', ' ', $minified); // whitespace + break; + } + file_put_contents($path, trim($minified)); + } + } + + private static function composerProdInstall() + { + // Remove post-install-cmd from composer.json in build + $composerJson = 'build/composer.json'; + if (file_exists($composerJson)) { + $json = json_decode(file_get_contents($composerJson), true); + if (isset($json['scripts']['post-install-cmd'])) { + unset($json['scripts']['post-install-cmd']); + file_put_contents($composerJson, json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + } + echo "\n🌱 Running composer install --no-dev --optimize-autoloader in build/...\n"; + $cmd = 'cd build && composer install --no-dev --optimize-autoloader'; + system($cmd); + } + + private static function precompileTwigTemplates() + { + echo "\n🌱 Precompiling Twig templates (in subprocess)...\n"; + $script = 'build/scripts/precompile-twig.php'; + if (!file_exists($script)) { + // Write the script to build/scripts/precompile-twig.php + if (!is_dir('build/scripts')) { + mkdir('build/scripts', 0777, true); + } + file_put_contents($script, self::twigPrecompileScriptContent()); + } + // Run the script in a separate PHP process + system('php ' . escapeshellarg($script)); + } + + private static function twigPrecompileScriptContent() + { + return <<<'EOT' + $cacheDir, + 'auto_reload' => false, + 'debug' => false, + ]); + // Register all user-defined functions as Twig functions + $functions = get_defined_functions()['user']; + foreach ($functions as $functionName) { + if (function_exists($functionName)) { + $twig->addFunction(new \Twig\TwigFunction($functionName, $functionName)); + } + } + $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($viewsDir)); + foreach ($rii as $file) { + if ($file->isDir()) continue; + $path = $file->getPathname(); + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'twig') { + $relPath = ltrim(str_replace($viewsDir, '', $path), '/\\'); + try { + $twig->load($relPath); + echo "Precompiled: $relPath\n"; + } catch (\Exception $e) { + echo "Twig precompile error in $relPath: " . $e->getMessage() . "\n"; + } + } + } + echo "Twig templates precompiled and cache warmed up.\n"; + EOT; + } + + private static function getDirectorySize($dir) + { + $size = 0; + $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($rii as $file) { + if ($file->isFile()) { + $size += $file->getSize(); + } + } + return $size; + } + + private static function printSummary($projectSize = 0, $buildSize = 0, $duration = 0) + { + echo "\n🌱 Build complete!\n"; + echo "\nProject size: " . self::formatBytes($projectSize); + echo "\nBuild size: " . self::formatBytes($buildSize); + if ($projectSize > 0) { + $reduction = $projectSize > 0 ? 100 - round(($buildSize / $projectSize) * 100, 2) : 0; + echo "\nSize reduced: {$reduction}%"; + } + echo "\nTime taken: " . round($duration, 2) . " seconds"; + echo "\n\nTo deploy your app, upload the build/ directory to your server."; + echo "\nSet your web server's document root to build/public/"; + echo "\nYour entry point is build/public/index.php"; + echo "\n\n🌱 SproutPHP - Happy Deployment! 🚀\n"; + } + + private static function formatBytes($bytes, $precision = 2) + { + $units = array('B', 'KB', 'MB', 'GB', 'TB'); + $bytes = max($bytes, 0); + $pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0; + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + return round($bytes, $precision) . ' ' . $units[$pow]; + } +} \ No newline at end of file diff --git a/core/Console/Commands/MakeComponent.php b/core/Console/Commands/MakeComponent.php index afb9a2a..fa72d11 100644 --- a/core/Console/Commands/MakeComponent.php +++ b/core/Console/Commands/MakeComponent.php @@ -7,7 +7,7 @@ class MakeComponent public static function handle($name) { $name = strtolower(trim($name)); - $dir = __DIR__ . '/../../../app/Views/Components/'; + $dir = __DIR__ . '/../../../app/Views/components/'; $file = "{$dir}/{$name}.twig"; if (!is_dir($dir)) { @@ -32,6 +32,6 @@ public static function handle($name) TWIG; file_put_contents($file, $template); - echo "✅ Component created: app/Components/{$name}.twig\n"; + echo "✅ Component created: app/Views/components/{$name}.twig\n"; } } diff --git a/core/Console/Commands/MakeMiddleware.php b/core/Console/Commands/MakeMiddleware.php new file mode 100644 index 0000000..ed51b4a --- /dev/null +++ b/core/Console/Commands/MakeMiddleware.php @@ -0,0 +1,43 @@ +HTMX Demo Page +

HTMX Demo Page for $name

+ + 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"; + } else { + echo "ℹ️ Dark/light mode toggle already present in your navbar.\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'; + + // 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"; + } + } else { + echo "❌ Failed to extract documentation zip.\n"; + } + // Delete the zip file + @unlink($zipPath); + } + } else { + echo "ℹ️ Skipping offline documentation.\n"; + } + } + + // Helper to recursively remove a directory + protected static function rrmdir($dir) + { + if (!is_dir($dir)) + return; + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + $path = $dir . DIRECTORY_SEPARATOR . $object; + if (is_dir($path)) + self::rrmdir($path); + else + unlink($path); + } + } + rmdir($dir); + } + + // Helper to recursively copy a directory + protected static function copyDir($src, $dst) + { + $dir = opendir($src); + @mkdir($dst, 0777, true); + while (false !== ($file = readdir($dir))) { + if (($file != '.') && ($file != '..')) { + $srcPath = $src . DIRECTORY_SEPARATOR . $file; + $dstPath = $dst . DIRECTORY_SEPARATOR . $file; + if (is_dir($srcPath)) { + self::copyDir($srcPath, $dstPath); + } else { + copy($srcPath, $dstPath); + } + } + } + closedir($dir); } } diff --git a/core/Database/DB.php b/core/Database/DB.php index d2c078a..65962a6 100644 --- a/core/Database/DB.php +++ b/core/Database/DB.php @@ -14,14 +14,24 @@ public static function connection() { if (self::$pdo) return self::$pdo; - $host = env('DB_HOST', 'localhost'); - $db = env('DB_NAME', 'sprout'); - $user = env('DB_USER', 'root'); - $pass = env('DB_PASS', ''); - $dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4"; + $connection = config('database.default', 'mysql'); + $config = config("database.connections.$connection"); + + if (!$config) { + die("❌ Database connection '$connection' not found in config."); + } + + $host = $config['host'] ?? 'localhost'; + $port = $config['port'] ?? 3306; + $database = $config['database'] ?? 'sprout'; + $username = $config['username'] ?? 'root'; + $password = $config['password'] ?? ''; + $charset = $config['charset'] ?? 'utf8mb4'; + + $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=$charset"; try { - self::$pdo = new PDO($dsn, $user, $pass); + self::$pdo = new PDO($dsn, $username, $password); self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); return self::$pdo; } catch (PDOException $ex) { @@ -34,6 +44,11 @@ public static function getQueries() return self::$queries; } + public static function resetQueryLog() + { + self::$queries = []; + } + public static function logQuery($sql, $params, $start, $end) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); diff --git a/core/Error/ErrorHandler.php b/core/Error/ErrorHandler.php index 8672129..5d07f37 100644 --- a/core/Error/ErrorHandler.php +++ b/core/Error/ErrorHandler.php @@ -36,7 +36,7 @@ public static function handleFatal() public static function render($message, $file, $line, $trace = null, $code = 500) { - if (env('APP_ENV') === 'local' && env('APP_DEBUG') === 'true') { + if (config('app.env', 'local') === 'local' && config('app.debug', false)) { echo "
"; echo "

🍂 SproutPHP Error

"; echo "Message: $message
"; diff --git a/core/Http/Middleware/MiddlewareInterface.php b/core/Http/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..9d62319 --- /dev/null +++ b/core/Http/Middleware/MiddlewareInterface.php @@ -0,0 +1,10 @@ +middlewares = $middlewares; + } + + public function handle(Request $request, callable $coreHandler) + { + $handler = array_reduce( + array_reverse($this->middlewares), + function($next, $middleware) { + return function($req) use ($next, $middleware) { + // Validate middleware class + if (!is_string($middleware) || !class_exists($middleware)) { + throw new \Exception("Invalid middleware class: " . (is_string($middleware) ? $middleware : gettype($middleware))); + } + + // Check if middleware implements the interface + if (!is_subclass_of($middleware, MiddlewareInterface::class)) { + throw new \Exception("Middleware class '$middleware' must implement MiddlewareInterface"); + } + + return (new $middleware())->handle($req, $next); + }; + }, + $coreHandler + ); + + return $handler($request); + } +} diff --git a/core/Http/Middleware/MiddlewareRegistry.php b/core/Http/Middleware/MiddlewareRegistry.php new file mode 100644 index 0000000..998f68b --- /dev/null +++ b/core/Http/Middleware/MiddlewareRegistry.php @@ -0,0 +1,23 @@ + \App\Middlewares\VerifyCsrfToken::class, + 'xss' => \App\Middlewares\XssProtection::class, + 'route-test' => \App\Middlewares\RouteTestMiddleware::class, + // Register your middlewares here + ]; + + public static function get($alias) + { + return self::$map[$alias] ?? null; + } + + public static function register($alias, $class) + { + self::$map[$alias] = $class; + } +} \ No newline at end of file diff --git a/core/Http/Request.php b/core/Http/Request.php index 3524705..716a27d 100644 --- a/core/Http/Request.php +++ b/core/Http/Request.php @@ -2,21 +2,64 @@ namespace Core\Http; -class Request { - public $method, $uri; +class Request +{ + public $method, $uri, $data = [], $files = []; - private function __construct($method, $uri) { + private function __construct($method, $uri, $data = [], $files = []) + { $this->method = $method; $this->uri = $uri; + $this->data = $data; + $this->files = $files; } /** * Static method that reads the HTTP method + URI and returns a new Request object */ - public static function capture(){ + public static function capture() + { $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); - return new self($method, $uri); + // If get/post request + $data = array_merge($_GET, $_POST); + + // if JSON, decode and merge + if (stripos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) { + $json = file_get_contents('php://input'); + $jsonData = json_decode($json, true); + if (is_array($jsonData)) { + $data = array_merge($data, $jsonData); + } + } + + $files = $_FILES; + + return new self($method, $uri, $data, $files); + } + + /** + * To request the input fields + */ + public function input($key, $default = null) + { + return $this->data[$key] ?? $default; + } + + /** + * File method to get the input files + */ + public function file($key) + { + return $_FILES[$key] ?? null; + } + + /** + * File method to check if input is file + */ + public function hasFile($key) + { + return isset($_FILES[$key]) && is_uploaded_file($_FILES[$key]['tmp_name']); } } \ No newline at end of file diff --git a/core/Routing/Route.php b/core/Routing/Route.php index 63312b6..73baa4b 100644 --- a/core/Routing/Route.php +++ b/core/Routing/Route.php @@ -5,29 +5,56 @@ class Route { public static $router; + public $uri; + public $method; + public $action; + public $middleware = []; + + public function __construct($method, $uri, $action) + { + $this->method = $method; + $this->uri = $uri; + $this->action = $action; + } public static function get($uri, $action) { - self::$router->get($uri, $action); + $route = new self('GET', $uri, $action); + self::$router->registerRoute($route); + return $route; } public static function post($uri, $action) { - self::$router->post($uri, $action); + $route = new self('POST', $uri, $action); + self::$router->registerRoute($route); + return $route; } public static function put($uri, $action) { - self::$router->put($uri, $action); + $route = new self('PUT', $uri, $action); + self::$router->registerRoute($route); + return $route; } public static function patch($uri, $action) { - self::$router->patch($uri, $action); + $route = new self('PATCH', $uri, $action); + self::$router->registerRoute($route); + return $route; } public static function delete($uri, $action) { - self::$router->delete($uri, $action); + $route = new self('DELETE', $uri, $action); + self::$router->registerRoute($route); + return $route; + } + + public function middleware($middleware) + { + $this->middleware = is_array($middleware) ? $middleware : [$middleware]; + return $this; } } diff --git a/core/Routing/Router.php b/core/Routing/Router.php index 3855436..7c05daa 100644 --- a/core/Routing/Router.php +++ b/core/Routing/Router.php @@ -2,38 +2,42 @@ namespace Core\Routing; +use Core\Http\Middleware\MiddlewareKernel; +use Core\Http\Middleware\MiddlewareRegistry; use Core\Http\Request; class Router { protected $routes = []; - /** - * Registers a GET & POST route with URI and Callback - */ + public function registerRoute($route) + { + $this->routes[$route->method][$route->uri] = $route; + } + public function get($uri, $callback) { - $this->routes['GET'][$uri] = $callback; + return Route::get($uri, $callback); } public function post($uri, $callback) { - $this->routes['POST'][$uri] = $callback; + return Route::post($uri, $callback); } public function put($uri, $callback) { - $this->routes['PUT'][$uri] = $callback; + return Route::put($uri, $callback); } public function patch($uri, $callback) { - $this->routes['PATCH'][$uri] = $callback; + return Route::patch($uri, $callback); } public function delete($uri, $callback) { - $this->routes['DELETE'][$uri] = $callback; + return Route::delete($uri, $callback); } /** @@ -44,47 +48,98 @@ public function dispatch(Request $request) $method = $request->method; $uri = $request->uri; - if (!isset($this->routes[$method][$uri])) { - http_response_code(404); - if (env('APP_ENV') === 'local') { - echo "
"; - echo "

404: Route not found

"; - echo "
"; - } else { - echo \Core\View\View::render('errors/404'); + // Try exact match first + 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) { + $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 + } + return '([^/]+)'; // default: match anything except / + }, $pattern); + $regex = '#^' . $regex . '$#'; + if (preg_match($regex, $uri, $matches)) { + array_shift($matches); // remove full match + $params = array_combine($paramNames, $matches); + return $this->runRoute($route, $request, $params); + } } - exit; } - $callback = $this->routes[$method][$uri]; + // No match found + http_response_code(404); + if (config('app.env', 'local') === 'local') { + echo "
"; + echo "

404: Route not found

"; + echo "
"; + } else { + echo \Core\View\View::render('errors/404'); + } + exit; + } + /** + * Run the matched route with parameters + */ + protected function runRoute($route, $request, $params = []) + { /** - * If it is a closure + * Collecting middleware Route-specific */ - if (is_callable($callback)) { - echo $callback(); - return; + $routeMiddleware = $route->middleware ?? []; + + // Resolve the alias to classNames + $resolvedMiddleware = []; + foreach ($routeMiddleware as $mw) { + if (class_exists($mw)) { + $resolvedMiddleware[] = $mw; + } elseif (isset(MiddlewareRegistry::$map[$mw])) { + $resolvedMiddleware[] = MiddlewareRegistry::$map[$mw]; + } else { + throw new \Exception("Unknown middleware: $mw"); + } } - /** - * If it is a string like 'SiteController@index' - */ - if (is_string($callback)) { - list($controllerName, $methodName) = explode('@', $callback); + $kernel = new MiddlewareKernel($resolvedMiddleware); + + $coreHandler = function ($request) use ($route, $params) { + $callback = $route->action; - $controllerClass = 'App\\Controllers\\' . $controllerName; + /** + * If it is a closure + */ + if (is_callable($callback)) { + return call_user_func_array($callback, $params); + } + + /** + * If it is a string like 'SiteController@index' + */ + if (is_string($callback)) { + list($controllerName, $methodName) = explode('@', $callback); + + $controllerClass = 'App\\Controllers\\' . $controllerName; - if (class_exists($controllerClass)) { - $controller = new $controllerClass; + if (class_exists($controllerClass)) { + $controller = new $controllerClass; - if (method_exists($controller, $methodName)) { - echo $controller->$methodName(); - return; + if (method_exists($controller, $methodName)) { + return call_user_func_array([$controller, $methodName], $params); + } } - } - http_response_code(500); - echo "Controller or method not found"; - } + http_response_code(500); + return "Controller or method not found"; + } + }; + return $kernel->handle($request, $coreHandler); } } diff --git a/core/Support/Debugbar.php b/core/Support/Debugbar.php index 5754f08..06351f9 100644 --- a/core/Support/Debugbar.php +++ b/core/Support/Debugbar.php @@ -6,12 +6,41 @@ class Debugbar { + /** + * Check if current request is AJAX or HTMX + */ + public static function isAjaxRequest(): bool + { + return ( + isset($_SERVER['HTTP_X_REQUESTED_WITH']) && + strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest' + ) || ( + isset($_SERVER['HTTP_HX_REQUEST']) && + $_SERVER['HTTP_HX_REQUEST'] === 'true' + ); + } + + /** + * Reset debugbar for new request + */ + public static function resetForRequest() + { + // Reset query log for this specific request + DB::resetQueryLog(); + + // Set new start time for this request + if (!defined('REQUEST_START')) { + define('REQUEST_START', microtime(true)); + } + } + public static function render() { - if (env('APP_DEBUG') !== 'true') return; + if (!env('APP_DEBUG')) return; $endTime = microtime(true); - $executionTime = round(($endTime - SPROUT_START) * 1000, 2); // ms + $startTime = defined('REQUEST_START') ? REQUEST_START : SPROUT_START; + $executionTime = round(($endTime - $startTime) * 1000, 2); // ms $memoryUsage = round(memory_get_usage(true) / 1024 / 1024, 2); // MB $queries = DB::getQueries(); $totalQueryTime = round(array_sum(array_column($queries, 'duration')), 2); @@ -33,7 +62,7 @@ public static function render() echo "🌿 SproutPHP DebugBar "; echo " | Method: " . $_SERVER['REQUEST_METHOD'] . ""; - echo " | URI: " . $_SERVER['REQUEST_URI'] . ""; + echo " | URI: " . htmlspecialchars($_SERVER['REQUEST_URI']) . ""; echo " | Queries: " . count($queries) . ""; echo " | Query Time: {$totalQueryTime}ms"; echo " | Page Time: {$executionTime}ms"; @@ -57,5 +86,16 @@ public static function render() } echo "
"; + // Inline script to update URI after HTMX navigation + echo ""; } } diff --git a/core/Support/Storage.php b/core/Support/Storage.php new file mode 100644 index 0000000..1f74c9e --- /dev/null +++ b/core/Support/Storage.php @@ -0,0 +1,89 @@ +data = $data; + $this->rules = $rules; + $this->validate(); + } + + public function fails() + { + return !empty($this->errors); + } + + public function errors() + { + return $this->errors; + } + + protected function validate() + { + foreach ($this->rules as $field => $ruleString) { + $rules = explode('|', $ruleString); + foreach ($rules as $rule) { + $params = null; + if (strpos($rule, ':') !== false) { + [$rule, $params] = explode(':', $rule, 2); + } + + $method = 'validate' . ucfirst($rule); + if (method_exists($this, $method)) { + $this->$method($field, $params); + } + } + } + } + + /** + * Field must not be empty + */ + protected function validateRequired($field) + { + if (empty($this->data[$field])) { + $this->errors[$field] = "The $field field is required."; + } + } + + /** + * Field must be a valid email address + */ + protected function validateEmail($field) + { + if (!filter_var($this->data[$field] ?? '', FILTER_VALIDATE_EMAIL)) { + $this->errors[$field] = "The $field must be a valid email address."; + } + } + + /** + * Minimum lenght + */ + protected function validateMin($field, $param) + { + if (strlen($this->data[$field] ?? '') < (int) $param) { + $this->errors[$field] = "The $field must be at least $param characters."; + } + } + + /** + * Maximum length + */ + protected function validateMax($field, $param) + { + if (strlen($this->data[$field] ?? '') > (int) $param) { + $this->errors[$field] = "The $field must be at atmost $param characters."; + } + } + + /** + * Field must be numeric + */ + protected function validateNumeric($field, $param) + { + if (!is_numeric($this->data[$field] ?? null)) { + $this->errors[$field] = "The $field must be a number"; + } + } + + /** + * Field must be an integer + */ + protected function validateInteger($field) + { + if (!filter_var($this->data[$field] ?? null, FILTER_VALIDATE_INT)) { + $this->errors[$field] = "The $field must be an integer."; + } + } + + /** + * Field must be a string + */ + protected function validateString($field) + { + if (!is_string($this->data[$field] ?? null)) { + $this->errors[$field] = "The $field must be a string."; + } + } + + /** + * Field must be a boolean + */ + protected function validateBoolean($field) + { + $value = $this->data[$field] ?? null; + if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) { + $this->errors[$field] = "This $field must be a boolean."; + } + } + + /** + * Field must be an array + */ + protected function validateArray($field) + { + if (!is_array($this->data[$field] ?? null)) { + $this->errors[$field] = "The $field must be an array."; + } + } + + /** + * In: Value must be one of the listed options + */ + protected function validateIn($field, $param) + { + $options = explode(',', $param); + if (!in_array($this->data[$field] ?? null, $options)) { + $this->errors[$field] = "The $field must be one of: " . implode(', ', $options) . "."; + } + } + + /** + * Not In: Value must NOT be one of the listed options + */ + protected function validateNot_in($field, $param) + { + $options = explode(',', $param); + if (in_array($this->data[$field] ?? null, $options)) { + $this->errors[$field] = "The $field must not be one of: " . implode(', ', $options) . "."; + } + } + + /** + * Same: Must match another field + */ + protected function validateSame($field, $param) + { + if (($this->data[$field] ?? null) !== ($this->data[$param] ?? null)) { + $this->errors[$field] = "The $field must match $param."; + } + } + + /** + * Different: Must be different from another field + */ + protected function validateDifferent($field, $param) + { + if (($this->data[$field] ?? null) === ($this->data[$param] ?? null)) { + $this->errors[$field] = "The $field must be different from $param."; + } + } + + /** + * Confirmed: Must have a matching {field}_confirmation value + */ + protected function validateConfirmed($field) + { + if (($this->data[$field] ?? null) !== ($this->data[$field . '_confirmation'] ?? null)) { + $this->errors[$field] = "The $field confirmation does not match."; + } + } + + /** + * Regex: Must match a regex pattern + */ + protected function validateRegex($field, $param) + { + if (!preg_match('/' . $param . '/', $this->data[$field] ?? '')) { + $this->errors[$field] = "The $field format is invalid."; + } + } + + /** + * URL: Must be a valid URL + */ + protected function validateUrl($field) + { + if (!filter_var($this->data[$field] ?? '', FILTER_VALIDATE_URL)) { + $this->errors[$field] = "The $field must be a valid URL."; + } + } + + /** + * IP: Must be a valid IP address + */ + protected function validateIp($field) + { + if (!filter_var($this->data[$field] ?? '', FILTER_VALIDATE_IP)) { + $this->errors[$field] = "The $field must be a valid IP address."; + } + } + + /** + * Date: Must be a valid date + */ + protected function validateDate($field) + { + if (strtotime($this->data[$field] ?? '') === false) { + $this->errors[$field] = "The $field must be a valid date."; + } + } + + /** + * Before: Must be a date before the given date + */ + protected function validateBefore($field, $param) + { + $value = strtotime($this->data[$field] ?? ''); + $compare = strtotime($param); + if ($value === false || $compare === false || $value >= $compare) { + $this->errors[$field] = "The $field must be a date before $param."; + } + } + + /** + * After: Must be a date after the given date + */ + protected function validateAfter($field, $param) + { + $value = strtotime($this->data[$field] ?? ''); + $compare = strtotime($param); + if ($value === false || $compare === false || $value <= $compare) { + $this->errors[$field] = "The $field must be a date after $param."; + } + } + + /** + * Nullable: Field is allowed to be null (affects other rules) + */ + protected function validateNullable($field) + { + // No error, just a marker for other rules to skip if null + } + + /** + * Present: Field must be present in the input (even if empty) + */ + protected function validatePresent($field) + { + if (!array_key_exists($field, $this->data)) { + $this->errors[$field] = "The $field field must be present."; + } + } + + /** + * Digits: Must be exactly N digits + */ + protected function validateDigits($field, $param) + { + if (!preg_match('/^\d{' . (int) $param . '}$/', $this->data[$field] ?? '')) { + $this->errors[$field] = "The $field must be exactly $param digits."; + } + } + + /** + * Digits Between: Must be between min and max digits + */ + protected function validateDigits_between($field, $param) + { + [$min, $max] = explode(',', $param); + $length = strlen($this->data[$field] ?? ''); + if (!preg_match('/^\d+$/', $this->data[$field] ?? '') || $length < (int) $min || $length > (int) $max) { + $this->errors[$field] = "The $field must be between $min and $max digits."; + } + } + + /** + * Size: Must be exactly N characters (for strings) or N value (for numbers/arrays) + */ + protected function validateSize($field, $param) + { + $value = $this->data[$field] ?? null; + if (is_array($value)) { + $size = count($value); + } elseif (is_numeric($value)) { + $size = $value; + } else { + $size = strlen($value); + } + if ($size != (int) $param) { + $this->errors[$field] = "The $field must be exactly $param in size."; + } + } + + /** + * Starts With: Must start with one of the given values + */ + protected function validateStarts_with($field, $param) + { + $options = explode(',', $param); + $value = $this->data[$field] ?? ''; + $valid = false; + foreach ($options as $option) { + if (strpos($value, $option) === 0) { + $valid = true; + break; + } + } + if (!$valid) { + $this->errors[$field] = "The $field must start with one of: " . implode(', ', $options) . "."; + } + } + + /** + * Ends With: Must end with one of the given values + */ + protected function validateEnds_with($field, $param) + { + $options = explode(',', $param); + $value = $this->data[$field] ?? ''; + $valid = false; + foreach ($options as $option) { + if (substr($value, -strlen($option)) === $option) { + $valid = true; + break; + } + } + if (!$valid) { + $this->errors[$field] = "The $field must end with one of: " . implode(', ', $options) . "."; + } + } + + /** + * UUID: Must be a valid UUID + */ + protected function validateUuid($field) + { + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $this->data[$field] ?? '')) { + $this->errors[$field] = "The $field must be a valid UUID."; + } + } + + /** + * MIMES: File extension matches allowed types + */ + protected function validateMimes($field, $param) + { + if (!isset($this->data[$field]) && !isset($_FILES[$field])) { + $this->errors[$field] = "The $field field is required."; + return; + } + $file = $_FILES[$field] ?? null; + if (!$file || !is_uploaded_file($file['tmp_name'])) { + $this->errors[$field] = "The $field must be a file."; + return; + } + $allowed = array_map('strtolower', explode(',', $param)); + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed)) { + $this->errors[$field] = "The $field must be a file of type: " . implode(', ', $allowed) . "."; + } + } + + /** + * Image: File is a valid image (by MIME type) + */ + protected function validateImage($field) + { + $file = $_FILES[$field] ?? null; + if (!$file || !is_uploaded_file($file['tmp_name'])) { + $this->errors[$field] = "The $field must be an image file."; + return; + } + $mime = mime_content_type($file['tmp_name']); + if (strpos($mime, 'image/') !== 0) { + $this->errors[$field] = "The $field must be a valid image."; + } + } +} diff --git a/core/Support/helpers.php b/core/Support/helpers.php index 176c8f0..38defaf 100644 --- a/core/Support/helpers.php +++ b/core/Support/helpers.php @@ -1,14 +1,19 @@ [ - "header" => "User-Agent: $userAgent\r\n" - ] - ]; - $context = stream_context_create($opts); - $json = @file_get_contents($url, false, $context); - $data = json_decode($json, true); - - if (is_array($data) && count($data) > 0) { - $tag = $data[0]['tag_name'] ?? 'unknown'; - $isPrerelease = $data[0]['prerelease'] ? ' (pre-release)' : ''; - return $tag . $isPrerelease; + + // Helper to fetch release info + $fetchRelease = function ($headers) use ($url) { + $opts = [ + "http" => [ + "header" => $headers + ] + ]; + $context = stream_context_create($opts); + $json = @file_get_contents($url, false, $context); + $httpCode = null; + if ($http_response_header ?? false) { + foreach ($http_response_header as $header) { + if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $header, $matches)) { + $httpCode = (int) $matches[1]; + break; + } + } + } + return [$json, $httpCode]; + }; + + // 1. Try with token if present + if ($token) { + $headers = "User-Agent: $userAgent\r\nAuthorization: token $token\r\n"; + list($json, $httpCode) = $fetchRelease($headers); + if ($httpCode === 200) { + $data = json_decode($json, true); + if (is_array($data) && count($data) > 0) { + $tag = $data[0]['tag_name'] ?? $defaultVersion; + $isPrerelease = !empty($data[0]['prerelease']) ? ' (pre-release)' : ''; + return $tag . $isPrerelease; + } + } + // If unauthorized or rate-limited, fall through to unauthenticated + } + // 2. Try unauthenticated + $headers = "User-Agent: $userAgent\r\n"; + list($json, $httpCode) = $fetchRelease($headers); + if ($httpCode === 200) { + $data = json_decode($json, true); + if (is_array($data) && count($data) > 0) { + $tag = $data[0]['tag_name'] ?? $defaultVersion; + $isPrerelease = !empty($data[0]['prerelease']) ? ' (pre-release)' : ''; + return $tag . $isPrerelease; + } } - return 'unknown'; + // 3. If all else fails, return default + return $defaultVersion; } } + +/** + * Check if current request is AJAX or HTMX + */ +if (!function_exists('is_ajax_request')) { + function is_ajax_request(): bool + { + return ( + isset($_SERVER['HTTP_X_REQUESTED_WITH']) && + strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest' + ) || ( + isset($_SERVER['HTTP_HX_REQUEST']) && + $_SERVER['HTTP_HX_REQUEST'] === 'true' + ); + } +} + +/** + * CSRF Token + */ +if (!function_exists('csrf_field')) { + function csrf_field() + { + if (empty($_SESSION['_csrf_token'])) { + $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); + } + $token = $_SESSION['_csrf_token']; + return ''; + } +} + +/** + * CSRF Token (string only) + */ +if (!function_exists('csrf_token')) { + function csrf_token() + { + if (empty($_SESSION['_csrf_token'])) { + $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['_csrf_token']; + } +} + +/** + * Config Helper + */ +if (!function_exists('config')) { + function config($key, $default = null) + { + static $configs = []; + + $segments = explode('.', $key); + $file = $segments[0]; + + if (!isset($configs[$file])) { + $configPath = __DIR__ . '/../../config/' . $file . '.php'; + if (file_exists($configPath)) { + $configs[$file] = require $configPath; + } else { + return $default; + } + } + + $value = $configs[$file]; + array_shift($segments); // Remove the file name from segments + + foreach ($segments as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + } else { + return $default; + } + } + + return $value; + } +} + +if (!function_exists('is_htmx_request')) { + /** + * Returns true if the request is an HTMX request. + */ + function is_htmx_request() + { + return isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true'; + } +} + +if (!function_exists('render_fragment_or_full')) { + /** + * Renders a fragment for HTMX/AJAX, or wraps it in a layout for normal requests. + * @param string $fragmentView The Twig view for the fragment (e.g., 'partials/security-test') + * @param array $data Data to pass to the view + * @param string $layoutView The layout view (default: 'base') + * @param string $blockName The block name in the layout to inject the fragment (default: 'content') + */ + function render_fragment_or_full($fragmentView, $data = [], $layoutView = 'layouts/base', $blockName = 'content') + { + $isPartial = is_htmx_request() || is_ajax_request(); + if ($isPartial) { + echo view($fragmentView, $data, true); + } else { + $data[$blockName] = view($fragmentView, $data, true); // get fragment as string + echo view($layoutView, $data, true); // render layout as string, then echo + } + } +} + +/** + * Validator Helper + */ +function validate($data, $rules) +{ + return new Validator($data, $rules); +} \ No newline at end of file diff --git a/core/View/View.php b/core/View/View.php index 391d0db..685cab2 100644 --- a/core/View/View.php +++ b/core/View/View.php @@ -13,35 +13,92 @@ class View public static function init() { - $loader = new FilesystemLoader(__DIR__ . '/../../app/Views'); + $viewsPath = __DIR__ . '/../../app/Views'; + $loader = new FilesystemLoader($viewsPath); + + $twigConfig = config('view.twig', []); + + // Configure cache properly + $cacheEnv = $twigConfig['cache'] ?? false; + $cachePath = false; // Default to no cache + if ($cacheEnv) { + $cachePath = __DIR__ . '/../../storage/twig-cache'; + if (!is_dir($cachePath)) { + mkdir($cachePath, 0777, true); + } + } + self::$twig = new Environment($loader, [ - 'cache' => false, - 'debug' => true, + 'cache' => $cachePath, + 'debug' => $twigConfig['debug'] ?? true, + 'auto_reload' => $twigConfig['auto_reload'] ?? true, + 'strict_variables' => $twigConfig['strict_variables'] ?? false, ]); - // Register global functions like assets() debug() if it exists as Twig doesn’t have direct access to PHP global functions by default - if (function_exists('assets')) { - self::$twig->addFunction(new TwigFunction('assets', 'assets')); + // Register Twig DebugExtension if debug is enabled + if ($twigConfig['debug'] ?? false) { + self::$twig->addExtension(new \Twig\Extension\DebugExtension()); } - if (function_exists('debug') || function_exists('dd') || function_exists('env')) { - self::$twig->addFunction(new \Twig\TwigFunction('debug', 'debug')); - self::$twig->addFunction(new \Twig\TwigFunction('dd', 'dd')); - self::$twig->addFunction(new \Twig\TwigFunction('env', fn($key) => env($key))); + // Register helpers for Twig: auto-register all from helpers.php, merge with config('view.twig_helpers') if set. + self::registerExplicitHelpers(); + } + + /** + * Register helpers for Twig: auto-register all from helpers.php, merge with config('view.twig_helpers') if set. + */ + private static function registerExplicitHelpers() { + // 1. Get all user-defined functions (auto-discover from helpers.php and any loaded helpers) + $userFunctions = get_defined_functions()['user']; + + // 2. Get explicit list from config, if any + $configHelpers = config('view.twig_helpers', []); + + // 3. Merge and deduplicate + $allHelpers = array_unique(array_merge($userFunctions, $configHelpers)); + + // 4. Register each helper if it exists + foreach ($allHelpers as $helper) { + if (function_exists($helper)) { + self::$twig->addFunction(new \Twig\TwigFunction($helper, $helper)); + } } } - public static function render($template, $data = []) + public static function render($template, $data = [], $return = false) { - if (env('APP_DEBUG') === 'true') { - $data['debugbar'] = Debugbar::render(); - $data['app_debug'] = true; - } - if (!self::$twig) { self::init(); } - echo self::$twig->render($template . '.twig', $data); + // Check if this is an AJAX/HTMX request + if (Debugbar::isAjaxRequest() && config('app.debug', false)) { + // Reset debugbar for this request + Debugbar::resetForRequest(); + + // Render the template first + $content = self::$twig->render($template . '.twig', $data); + + // Append debugbar to the response + $debugbar = Debugbar::render(); + + if ($return) { + return $content . $debugbar; + } + echo $content . $debugbar; + return; + } + + // Regular request handling + if (config('app.debug', false)) { + $data['debugbar'] = Debugbar::render(); + $data['app_debug'] = true; + } + + $output = self::$twig->render($template . '.twig', $data); + if ($return) { + return $output; + } + echo $output; } } \ No newline at end of file diff --git a/public/assets/img/logo.png b/public/assets/img/logo.png new file mode 100644 index 0000000..df1c3f5 Binary files /dev/null and b/public/assets/img/logo.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..cec587a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php index 56bb9d2..8fd5900 100644 --- a/public/index.php +++ b/public/index.php @@ -4,15 +4,23 @@ /** * Enable error reporting (Only in DEV) */ -ini_set('display_errors',1); +ini_set('display_errors', 1); error_reporting(E_ALL); /** * Autoloader */ -require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__ . '/../vendor/autoload.php'; + +/** + * Starting the session after autoload + */ +if (session_status() !== PHP_SESSION_ACTIVE) { + session_name(config('app.session.name', 'sprout_session')); + session_start(); +} /** * Bootstrap the framework */ -require_once __DIR__.'/../core/Bootstrap/bootstrap.php'; \ No newline at end of file +require_once __DIR__ . '/../core/Bootstrap/bootstrap.php'; diff --git a/routes/web.php b/routes/web.php index 1520f72..6d82172 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,11 @@ 'SproutPHP Home']); + $getLatestRelease = getLatestRelease(); + return view('home', ['title' => 'SproutPHP Home', 'getLatestRelease' => $getLatestRelease]); }); Route::get('/home', 'HomeController@index'); @@ -21,8 +23,54 @@ Route::delete('/delete', function () { return 'DELETE received'; }); +Route::get('/security-test', function () { + $data = [ + 'xss_enabled' => config('security.xss.enabled'), + 'xss_mode' => config('security.xss.mode'), + 'csp_enabled' => config('security.csp.enabled'), + 'csp_report_only' => config('security.csp.report_only'), + 'env' => config('app.env'), + 'debug' => config('app.debug'), + ]; + render_fragment_or_full('partials/security-test', $data); +}); + +Route::get('/debug-config', function () { + $middlewares = config('app.global_middleware', []); + $middleware_info = []; + foreach ($middlewares as $middleware) { + if (!$middleware) + continue; // skip empty values + $middleware_info[] = [ + 'name' => $middleware, + 'exists' => class_exists($middleware), + ]; + } + $data = [ + 'config_exists' => function_exists('config'), + 'app_config_loaded' => config('app.name'), + 'middleware_count' => count($middleware_info), + 'middlewares' => $middleware_info, + ]; + render_fragment_or_full('partials/debug-config', $data); +}); + +Route::get('/config-test', function () { + $data = [ + 'app_name' => config('app.name'), + 'env' => config('app.env'), + 'debug' => config('app.debug'), + 'db_host' => config('database.connections.mysql.host'), + 'twig_cache' => config('view.twig.cache'), + ]; + render_fragment_or_full('partials/config-test', $data); +}); + Route::get('/envtest', function () { - debug(env('APP_ENV', 'default_env')); + $data = [ + 'env' => config('app.env', 'default_env'), + ]; + render_fragment_or_full('partials/env-test', $data); }); Route::get('/crash', function () { $a = 10 / 0; // Division by zero @@ -32,4 +80,13 @@ }); Route::get('/test500', function () { throw new Exception("Manual 500 test triggered."); -}); \ No newline at end of file +}); +Route::get('/route-middleware-test', function () { + return 'Route-specific middleware test response.'; +})->middleware('route-test'); + +// 🌱 Resource routes for Validation-testController +Route::get('/validation-test', 'ValidationTestController@index'); +Route::post('/validation-test', 'ValidationTestController@handleForm'); +Route::post('/validation-test/private-upload', 'ValidationTestController@handlePrivateUpload'); +Route::get('/validation-test/private-download', 'ValidationTestController@downloadPrivateFile'); \ No newline at end of file diff --git a/sprout b/sprout index 075be77..960035f 100644 --- a/sprout +++ b/sprout @@ -9,6 +9,9 @@ use Core\Console\Commands\MakeResource; use Core\Console\Commands\MakeRoute; use Core\Console\Commands\MakeView; use Core\Console\Commands\Migrate; +use Core\Console\Commands\Build; +use Core\Console\Commands\MakeMiddleware; +use Core\Console\Commands\SymlinkCreate; require_once __DIR__ . '/vendor/autoload.php'; @@ -79,6 +82,18 @@ switch ($command) { MakeComponent::handle($target); break; + case 'make:middleware': + if (!$target) { + echo "❌ Please provide a middleware name.\n"; + exit(1); + } + MakeMiddleware::handle($target); + break; + + case 'symlink:create': + SymlinkCreate::handle(); + break; + case 'env': $envValue = $target ?? null; @@ -132,7 +147,8 @@ switch ($command) { break; case 'bloom': - echo "🌸 Preparing production build (coming soon)\n"; + echo "\n🌸 SproutPHP Build: Preparing production build...\n"; + Build::run(); break; case 'version': @@ -195,6 +211,10 @@ switch ($command) { break; + case 'install:pico': + \Core\Console\PostInstall::run(); + break; + case 'help': default: echo "🌿 SproutPHP CLI\n"; @@ -206,12 +226,14 @@ switch ($command) { echo " php sprout make:resource Auto-generate MVC resources including routes\n"; echo " php sprout make:route Register routes for your particular controller\n"; echo " php sprout make:component Make a component\n"; + echo " php sprout make:middleware Make a middleware\n"; echo " php sprout env production To switch to production|local\n"; echo " php sprout logs Shows recent errors from logs\n"; echo " php sprout seed Generate files (coming soon)\n"; echo " php sprout prune Remove unwanted files (coming soon)\n"; - echo " php sprout bloom Build for production (coming soon)\n"; + echo " php sprout bloom Build for production (minifies, strips dev code, precompiles)\n"; echo " php sprout version Show framework version\n"; echo " php sprout info Show framework information\n"; + echo " php sprout install:pico Update PicoCSS (choose build/theme)\n"; break; }