|
1
|
<?php |
|
2
|
// Important: you MUST read MINI-STYLE.md before using Mini framework. |
|
3
|
namespace mini; |
|
4
|
|
|
5
|
use Exception; |
|
6
|
use Throwable; |
|
7
|
use Composer\Autoload\ClassLoader; |
|
8
|
use mini\Http; |
|
9
|
use mini\Mini; |
|
10
|
use ReflectionClass; |
|
11
|
|
|
12
|
/** |
|
13
|
* Mini Framework - Global Helper Functions |
|
14
|
* |
|
15
|
* These functions are automatically loaded by Composer and available globally. |
|
16
|
*/ |
|
17
|
|
|
18
|
/** |
|
19
|
* Redirect to URL and exit |
|
20
|
* |
|
21
|
* @param string $url Target URL for redirect |
|
22
|
* @param int $statusCode HTTP status code (301 for permanent, 302 for temporary) |
|
23
|
*/ |
|
24
|
function redirect(string $url, int $statusCode = 302): void { |
|
25
|
http_response_code($statusCode); |
|
26
|
header('Location: ' . $url); |
|
27
|
exit; |
|
28
|
} |
|
29
|
|
|
30
|
/** |
|
31
|
* Escape HTML output |
|
32
|
*/ |
|
33
|
function h($str) { |
|
34
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8'); |
|
35
|
} |
|
36
|
|
|
37
|
/** |
|
38
|
* Get current URL |
|
39
|
*/ |
|
40
|
function current_url() { |
|
41
|
return (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') |
|
42
|
. '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; |
|
43
|
} |
|
44
|
|
|
45
|
/** |
|
46
|
* Generate URL with proper relative path resolution and optional CDN support |
|
47
|
* |
|
48
|
* Resolves paths against baseUrl (or cdnUrl if $cdn = true), handling relative paths like |
|
49
|
* '..', '.', and absolute paths. Strips scheme/host from input URLs to ensure all URLs |
|
50
|
* resolve against the configured base. |
|
51
|
* |
|
52
|
* Examples: |
|
53
|
* url('/api/users') → https://example.com/api/users |
|
54
|
* url('css/style.css', cdn: true) → https://cdn.example.com/app/css/style.css |
|
55
|
* url('../images/logo.png') → https://example.com/images/logo.png |
|
56
|
* url('/assets/app.js', ['v' => '2']) → https://example.com/assets/app.js?v=2 |
|
57
|
* |
|
58
|
* Returns UriInterface which is stringable, so works in templates: |
|
59
|
* <a href="<?= url('/users') ?>">Users</a> |
|
60
|
* |
|
61
|
* Can chain PSR-7 methods for further manipulation: |
|
62
|
* url('/posts')->withFragment('comments')->withQuery('page=2') |
|
63
|
* |
|
64
|
* @param string|\Psr\Http\Message\UriInterface $path Path to resolve (relative or absolute) |
|
65
|
* @param array $query Query parameters to merge |
|
66
|
* @param bool $cdn Use CDN base URL instead of regular base URL |
|
67
|
* @return \Psr\Http\Message\UriInterface Resolved URL |
|
68
|
* @throws Exception If base URL cannot be determined |
|
69
|
*/ |
|
70
|
function url(string|\Psr\Http\Message\UriInterface $path = '', array $query = [], bool $cdn = false): \Psr\Http\Message\UriInterface { |
|
71
|
$baseUrl = $cdn ? Mini::$mini->cdnUrl : Mini::$mini->baseUrl; |
|
72
|
|
|
73
|
if ($baseUrl === null) { |
|
74
|
throw new Exception('Base URL not configured. Set MINI_BASE_URL environment variable'); |
|
75
|
} |
|
76
|
|
|
77
|
// Parse base URL |
|
78
|
$base = new \mini\Http\Message\Uri($baseUrl); |
|
79
|
|
|
80
|
// Extract path from input (strip scheme/host if present) |
|
81
|
if ($path instanceof \Psr\Http\Message\UriInterface) { |
|
82
|
$inputPath = $path->getPath(); |
|
83
|
$inputQuery = $path->getQuery(); |
|
84
|
$inputFragment = $path->getFragment(); |
|
85
|
} else { |
|
86
|
// Parse string to extract path, query, fragment |
|
87
|
$parsed = new \mini\Http\Message\Uri($path); |
|
88
|
$inputPath = $parsed->getPath(); |
|
89
|
$inputQuery = $parsed->getQuery(); |
|
90
|
$inputFragment = $parsed->getFragment(); |
|
91
|
} |
|
92
|
|
|
93
|
// Resolve path relative to base path |
|
94
|
$basePath = $base->getPath(); |
|
95
|
|
|
96
|
if ($inputPath === '') { |
|
97
|
// Empty path - use base path |
|
98
|
$resolvedPath = $basePath; |
|
99
|
} elseif ($inputPath[0] === '/') { |
|
100
|
// Absolute path - prepend base path for subdirectory mounting |
|
101
|
// e.g., url('/todos/') with baseUrl '/assistant' becomes '/assistant/todos/' |
|
102
|
$resolvedPath = rtrim($basePath, '/') . $inputPath; |
|
103
|
} else { |
|
104
|
// Relative path - resolve against base path |
|
105
|
// Append to base path directory |
|
106
|
$baseDir = rtrim(dirname($basePath . '/dummy'), '/'); |
|
107
|
$resolvedPath = $baseDir . '/' . $inputPath; |
|
108
|
} |
|
109
|
|
|
110
|
// Normalize path (handle .. and .), preserving trailing slash |
|
111
|
$hadTrailingSlash = str_ends_with($resolvedPath, '/'); |
|
112
|
$resolvedPath = (string) (new \mini\Util\Path($resolvedPath))->canonical(); |
|
113
|
if ($hadTrailingSlash && !str_ends_with($resolvedPath, '/')) { |
|
114
|
$resolvedPath .= '/'; |
|
115
|
} |
|
116
|
|
|
117
|
// Merge query parameters: input query + $query array |
|
118
|
parse_str($inputQuery, $inputQueryParams); |
|
119
|
$mergedQuery = http_build_query($inputQueryParams + $query); |
|
120
|
|
|
121
|
// Build final URI |
|
122
|
return $base |
|
123
|
->withPath($resolvedPath) |
|
124
|
->withQuery($mergedQuery) |
|
125
|
->withFragment($inputFragment); |
|
126
|
} |
|
127
|
|
|
128
|
/** |
|
129
|
* Flash message functions |
|
130
|
*/ |
|
131
|
function flash_set($type, $message) { |
|
132
|
if (!isset($_SESSION['flash'])) { |
|
133
|
$_SESSION['flash'] = []; |
|
134
|
} |
|
135
|
$_SESSION['flash'][] = ['type' => $type, 'message' => $message]; |
|
136
|
} |
|
137
|
|
|
138
|
/** |
|
139
|
* Retrieve and clear all flash messages from the session |
|
140
|
* |
|
141
|
* Returns an array of flash messages and removes them from the session, |
|
142
|
* ensuring each message is only displayed once. |
|
143
|
* |
|
144
|
* @return array<array{type: string, message: string}> Array of flash messages |
|
145
|
*/ |
|
146
|
function flash_get(): array { |
|
147
|
if (!isset($_SESSION['flash'])) { |
|
148
|
return []; |
|
149
|
} |
|
150
|
$flash = $_SESSION['flash']; |
|
151
|
unset($_SESSION['flash']); |
|
152
|
return $flash; |
|
153
|
} |
|
154
|
|
|
155
|
|
|
156
|
// Note: session(), db() and cache() helpers are now in src/ feature directories |
|
157
|
|
|
158
|
|
|
159
|
/** |
|
160
|
* Bootstrap the mini framework for controller files |
|
161
|
* |
|
162
|
* Call this at the top of any directly-accessible PHP file in the document root. |
|
163
|
* Sets up error handling, output buffering, and clean URL redirects. |
|
164
|
* |
|
165
|
* Transitions application from Bootstrap to Ready phase, enabling access to Scoped services. |
|
166
|
* |
|
167
|
* Safe to call multiple times (idempotent after first call). |
|
168
|
*/ |
|
169
|
function bootstrap(): void |
|
170
|
{ |
|
171
|
static $initialized = false; |
|
172
|
if ($initialized) { |
|
173
|
return; // Already bootstrapped |
|
174
|
} |
|
175
|
$initialized = true; |
|
176
|
|
|
177
|
// Transition to Ready phase - enables request handling and access to Scoped services |
|
178
|
Mini::$mini->phase->trigger(Phase::Ready); |
|
179
|
|
|
180
|
// Clean up pre-existing output handlers |
|
181
|
$previousLevel = -1; |
|
182
|
while (ob_get_level() > 0 && ob_get_level() !== $previousLevel) { |
|
183
|
$previousLevel = ob_get_level(); |
|
184
|
@ob_end_clean(); |
|
185
|
} |
|
186
|
|
|
187
|
// Set up error handler (converts errors to exceptions) |
|
188
|
set_error_handler(function($severity, $message, $file, $line) { |
|
189
|
if (!(error_reporting() & $severity)) { |
|
190
|
return false; |
|
191
|
} |
|
192
|
throw new \ErrorException($message, 0, $severity, $file, $line); |
|
193
|
}); |
|
194
|
|
|
195
|
// Set up exception handler (fallback for bootstrap errors) - only if none exists |
|
196
|
$existingHandler = set_exception_handler(null); |
|
197
|
if ($existingHandler !== null) { |
|
198
|
// Developer has their own exception handler - keep it |
|
199
|
set_exception_handler($existingHandler); |
|
200
|
} else { |
|
201
|
// No handler exists - set Mini's fallback handler |
|
202
|
// Note: When using dispatch(), Dispatcher handles exceptions during request lifecycle |
|
203
|
set_exception_handler(function(\Throwable $exception) { |
|
204
|
error_log("Uncaught exception: " . $exception->getMessage() . " in " . $exception->getFile() . " line " . $exception->getLine()); |
|
205
|
error_log("Stack trace: " . $exception->getTraceAsString()); |
|
206
|
|
|
207
|
if (headers_sent()) { |
|
208
|
if (Mini::$mini->debug) { |
|
209
|
echo $exception; |
|
210
|
} else { |
|
211
|
echo get_class($exception) . " thrown in " . $exception->getFile() . " line " . $exception->getLine(); |
|
212
|
} |
|
213
|
die(); |
|
214
|
} |
|
215
|
|
|
216
|
if (ob_get_level() > 0) { |
|
217
|
ob_clean(); |
|
218
|
} |
|
219
|
|
|
220
|
// Render basic error page |
|
221
|
http_response_code(500); |
|
222
|
echo "<h1>500 - Internal Server Error</h1>"; |
|
223
|
if (Mini::$mini->debug) { |
|
224
|
echo "<pre>" . htmlspecialchars($exception->getMessage()) . "\n\n"; |
|
225
|
echo $exception->getTraceAsString() . "</pre>"; |
|
226
|
} else { |
|
227
|
echo "<p>An unexpected error occurred.</p>"; |
|
228
|
} |
|
229
|
}); |
|
230
|
} |
|
231
|
|
|
232
|
// Start unlimited output buffering for exception recovery |
|
233
|
// Buffer size 0 = unlimited, never auto-flush (prevents partial output on errors) |
|
234
|
ob_start(null, 0); |
|
235
|
|
|
236
|
// Parse application/json request bodies to $_POST (PHP doesn't do this natively) |
|
237
|
if (str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) { |
|
238
|
$json = file_get_contents('php://input'); |
|
239
|
$data = json_decode($json, true); |
|
240
|
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { |
|
241
|
$_POST = $data; |
|
242
|
} |
|
243
|
} |
|
244
|
|
|
245
|
// If routing is enabled and accessing /index.php directly, redirect to / |
|
246
|
if (isset($GLOBALS['mini_routing_enabled']) && $GLOBALS['mini_routing_enabled']) { |
|
247
|
$requestUri = $_SERVER['REQUEST_URI'] ?? ''; |
|
248
|
$path = parse_url($requestUri, PHP_URL_PATH) ?? ''; |
|
249
|
$query = $_SERVER['QUERY_STRING'] ?? ''; |
|
250
|
|
|
251
|
// Redirect /index.php to / |
|
252
|
if ($path === '/index.php' || str_ends_with($path, '/index.php')) { |
|
253
|
$redirectTo = rtrim(dirname($path), '/') ?: '/'; |
|
254
|
if ($query) { |
|
255
|
$redirectTo .= '?' . $query; |
|
256
|
} |
|
257
|
|
|
258
|
http_response_code(301); |
|
259
|
header('Location: ' . $redirectTo); |
|
260
|
exit; |
|
261
|
} |
|
262
|
} |
|
263
|
} |
|
264
|
|
|
265
|
/** |
|
266
|
* Router entry point for applications with routing enabled |
|
267
|
* |
|
268
|
* Call this from DOC_ROOT/index.php to enable routing: |
|
269
|
* - Sets up error handling and output buffering |
|
270
|
* - Delegates URL routing to Router |
|
271
|
* - Routes loaded from _routes/ directory |
|
272
|
* |
|
273
|
* Route handlers in _routes/ don't need to call bootstrap(). |
|
274
|
*/ |
|
275
|
// dispatch() function moved to src/Dispatcher/functions.php |
|
276
|
|
|
277
|
/** |
|
278
|
* Main entry point for file-based routing |
|
279
|
* |
|
280
|
* Bootstraps the application and routes the current request to |
|
281
|
* the appropriate handler in the _routes/ directory. |
|
282
|
* |
|
283
|
* @deprecated Use dispatch() instead. Will be removed in future version. |
|
284
|
*/ |
|
285
|
function router(): void |
|
286
|
{ |
|
287
|
// Set global flag that routing is enabled |
|
288
|
$GLOBALS['mini_routing_enabled'] = true; |
|
289
|
|
|
290
|
// Bootstrap sets up error handlers, output buffering, etc. |
|
291
|
bootstrap(); |
|
292
|
|
|
293
|
// Delegate routing to Router |
|
294
|
$requestUri = $_SERVER['REQUEST_URI'] ?? ''; |
|
295
|
$router = Mini::$mini->get(\mini\Router\Router::class); |
|
296
|
$router->handleRequest($requestUri); |
|
297
|
|
|
298
|
// Explicitly flush output buffer on successful completion |
|
299
|
// (Exception handler discards buffer via ob_end_clean()) |
|
300
|
if (ob_get_level() > 0) { |
|
301
|
ob_end_flush(); |
|
302
|
} |
|
303
|
} |
|
304
|
|
|
305
|
|
|
306
|
/** |
|
307
|
* Create a CSRF token for a specific action |
|
308
|
* |
|
309
|
* Convenience wrapper around new CSRF(). |
|
310
|
* |
|
311
|
* @param string $action Action name (e.g., 'delete-post', 'update-settings') |
|
312
|
* @param string $fieldName HTML field name (default: '__nonce__') |
|
313
|
* @return CSRF CSRF token object |
|
314
|
*/ |
|
315
|
function csrf(string $action, string $fieldName = '__nonce__'): CSRF { |
|
316
|
return new CSRF($action, $fieldName); |
|
317
|
} |
|
318
|
|
|
319
|
|