├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── endpoint.php └── schema.sql /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2018 by Martijn van der Ven 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 7 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 8 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 9 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 10 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 11 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 12 | PERFORMANCE OF THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mintoken 2 | 3 | A minimal [IndieAuth][] compatible [Token Endpoint][]. 4 | 5 | Several times I have been asked if there is a token endpoint available to be used together with [Selfauth][]. A minimal solution that would just work for issuing access tokens that is self-hostable on a server with PHP. 6 | 7 | I do not have a need for a token endpoint like this myself, thus developing one would go against my [selfdogfooding][] principles. But because I have a general interest in the IndieAuth specification, here is an implementation anyway! 8 | 9 | [IndieAuth]: https://indieauth.net/ 10 | [Token Endpoint]: https://indieauth.spec.indieweb.org/#token-endpoint 11 | [Selfauth]: https://github.com/Inklings-io/selfauth 12 | [selfdogfooding]: https://indieweb.org/selfdogfood 13 | 14 | ## Setup 15 | 16 | 1. Download [the latest release](https://github.com/Zegnat/php-mintoken/releases/latest) from GitHub and extract the files. 17 | 18 | 2. Create an SQLite database; these instructions assume the database is called `tokens.db`. 19 | 20 | **You can do this from the command line:** 21 | 22 | ```bash 23 | sqlite3 tokens.db < schema.sql 24 | ``` 25 | 26 | If you prefer, create the SQLite database by your favourite means and use `schema.sql` to create the expected tables. 27 | 28 | 3. Define trusted authorization endpoints in the `settings` table of the SQLite database. Mintoken will only check codes with these endpoints, and takes the `me` value they return as trusted without further verification. 29 | 30 | E.g. if we take [the example setup for Selfauth](https://github.com/Inklings-io/selfauth#setup), the endpoint `https://example.com/auth/` should be whitelisted. 31 | 32 | **From the command line:** 33 | 34 | ```bash 35 | sqlite3 tokens.db 'INSERT INTO settings VALUES ("endpoint", "https://example.com/auth/");' 36 | ``` 37 | 38 | 4. Upload the SQLite database to a secure directory on your server. Make sure it is not publicly available to the web! This is very important for security reasons. 39 | 40 | 5. Edit `endpoint.php` so line 5 defines the correct path to the SQLite database as the value for `MINTOKEN_SQLITE_PATH`. 41 | 42 | You should use the full path to `tokens.db`. For example, `define('MINTOKEN_SQLITE_PATH', '../../tokens.db');` 43 | 44 | 6. Put `endpoint.php` anywhere on your server where it is available to the web. (This can be in the same folder as Selfauth, for simplicity.) 45 | 46 | 7. Make the token endpoint discoverable. Either by defining a `Link` HTTP header, or adding the following to the `` of the pages where you also link to your `authorization_endpoint`: 47 | 48 | ```html 49 | 50 | ``` 51 | 52 | (The `href` must point at your `endpoint.php` file.) 53 | 54 | ## License 55 | 56 | The BSD Zero Clause License (0BSD). Please see the LICENSE file for 57 | more information. 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zegnat/mintoken", 3 | "description": "A minimal IndieAuth compatible token endpoint.", 4 | "type": "project", 5 | "version": "2.0.0", 6 | "license": "0BSD", 7 | "authors": [ 8 | { 9 | "name": "Martijn van der Ven", 10 | "email": "martijn@vanderven.se" 11 | } 12 | ], 13 | "require-dev": { 14 | "squizlabs/php_codesniffer": "^3.2", 15 | "cweiske/php-sqllint": "^0.2.2" 16 | }, 17 | "scripts": { 18 | "check-style": "phpcs -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 endpoint.php", 19 | "check-schema": "php-sqllint schema.sql" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /endpoint.php: -------------------------------------------------------------------------------- 1 | PDO::ERRMODE_EXCEPTION, 21 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 22 | PDO::ATTR_EMULATE_PREPARES => false, 23 | ]); 24 | } 25 | return $pdo; 26 | } 27 | 28 | function initCurl(string $url)/* : resource */ 29 | { 30 | $curl = curl_init(); 31 | curl_setopt($curl, CURLOPT_URL, $url); 32 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 33 | curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); 34 | curl_setopt($curl, CURLOPT_MAXREDIRS, 8); 35 | curl_setopt($curl, CURLOPT_TIMEOUT_MS, round(MINTOKEN_CURL_TIMEOUT * 1000)); 36 | curl_setopt($curl, CURLOPT_CONNECTTIMEOUT_MS, 2000); 37 | curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2); 38 | return $curl; 39 | } 40 | 41 | function storeToken(string $me, string $client_id, string $scope): string 42 | { 43 | $pdo = connectToDatabase(); 44 | do { 45 | $hashable = substr(str_replace(chr(0), '', random_bytes(100)), 0, 72); 46 | $hash = password_hash($hashable, PASSWORD_BCRYPT); 47 | } while (strlen($hashable) !== 72 || $hash === false); 48 | for ($i = 0; $i < 10; $i++) { 49 | $lastException = null; 50 | $id = bin2hex(random_bytes(32)); 51 | $revokeColumn = ''; 52 | $revokeValue = ''; 53 | $revoking = []; 54 | if (is_string(MINTOKEN_REVOKE_AFTER) && strlen(MINTOKEN_REVOKE_AFTER) > 0) { 55 | $revokeColumn = ', revoked'; 56 | $revokeValue = ', datetime(CURRENT_TIMESTAMP, ?)'; 57 | $revoking = ['+' . MINTOKEN_REVOKE_AFTER]; 58 | } 59 | // We have to prepare inside the loop, https://github.com/teamtnt/tntsearch/pull/126 60 | $statement = $pdo->prepare('INSERT INTO tokens (token_id, token_hash, auth_me, auth_client_id, auth_scope' . $revokeColumn . ') VALUES (?, ?, ?, ?, ?' . $revokeValue . ')'); 61 | try { 62 | $statement->execute(array_merge([$id, $hash, $me, $client_id, $scope], $revoking)); 63 | } catch (PDOException $e) { 64 | $lastException = $e; 65 | if ($statement->errorInfo()[1] !== 19) { 66 | throw $e; 67 | } 68 | continue; 69 | } 70 | break; 71 | } 72 | if ($lastException !== null) { 73 | throw $e; 74 | } 75 | return $id . '_' . bin2hex($hashable); 76 | } 77 | 78 | function retrieveToken(string $token): ?array 79 | { 80 | list($id, $hashable) = explode('_', $token); 81 | $pdo = connectToDatabase(); 82 | $statement = $pdo->prepare('SELECT *, revoked > CURRENT_TIMESTAMP AS active FROM tokens WHERE token_id = ?'); 83 | $statement->execute([$id]); 84 | $token = $statement->fetch(PDO::FETCH_ASSOC); 85 | if ($token !== false && password_verify(hex2bin($hashable), $token['token_hash'])) { 86 | return $token; 87 | } 88 | return null; 89 | } 90 | 91 | function markTokenUsed(string $tokenId): void 92 | { 93 | $pdo = connectToDatabase(); 94 | $statement = $pdo->prepare('UPDATE tokens SET last_use = CURRENT_TIMESTAMP WHERE token_id = ? AND (last_use IS NULL OR last_use < CURRENT_TIMESTAMP)'); 95 | $statement->execute([$tokenId]); 96 | } 97 | 98 | function revokeToken(string $token): void 99 | { 100 | $token = retrieveToken($token); 101 | if ($token !== null) { 102 | $pdo = connectToDatabase(); 103 | $statement = $pdo->prepare('UPDATE tokens SET revoked = CURRENT_TIMESTAMP WHERE token_id = ? AND (revoked IS NULL OR revoked > CURRENT_TIMESTAMP)'); 104 | $statement->execute([$token['token_id']]); 105 | } 106 | } 107 | 108 | function isTrustedEndpoint(string $endpoint): bool 109 | { 110 | $pdo = connectToDatabase(); 111 | $statement = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_name = ? AND setting_value = ?'); 112 | $statement->execute(['endpoint', $endpoint]); 113 | return $statement->fetchColumn() > 0; 114 | } 115 | 116 | function discoverAuthorizationEndpoint(string $url): ?string 117 | { 118 | $curl = initCurl($url); 119 | $headers = []; 120 | $last = ''; 121 | curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$headers, &$last): int { 122 | $url = curl_getinfo($curl, CURLINFO_EFFECTIVE_URL); 123 | if ($url !== $last) { 124 | $headers = []; 125 | } 126 | $len = strlen($header); 127 | $header = explode(':', $header, 2); 128 | if (count($header) === 2) { 129 | $name = strtolower(trim($header[0])); 130 | if (!array_key_exists($name, $headers)) { 131 | $headers[$name] = [trim($header[1])]; 132 | } else { 133 | $headers[$name][] = trim($header[1]); 134 | } 135 | } 136 | $last = $url; 137 | return $len; 138 | }); 139 | $body = curl_exec($curl); 140 | if (curl_getinfo($curl, CURLINFO_HTTP_CODE) !== 200 || curl_errno($curl) !== 0) { 141 | return null; 142 | } 143 | curl_close($curl); 144 | $endpoint = null; 145 | if (array_key_exists('link', $headers)) { 146 | foreach ($headers['link'] as $link) { 147 | $found = preg_match('@^\s*<([^>]*)>\s*;(.*?;)?\srel="([^"]*?\s+)?authorization_endpoint(\s+[^"]*?)?"@', $link, $match); 148 | if ($found === 1) { 149 | $endpoint = $match[1]; 150 | break; 151 | } 152 | } 153 | } 154 | if ($endpoint === null) { 155 | libxml_use_internal_errors(true); 156 | $dom = new DOMDocument(); 157 | $dom->loadHTML(mb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8')); 158 | $xpath = new DOMXPath($dom); 159 | $nodes = $xpath->query('//*[contains(concat(" ", normalize-space(@rel), " "), " authorization_endpoint ") and @href][1]/@href'); 160 | if ($nodes->length === 0) { 161 | return null; 162 | } 163 | $endpoint = $nodes->item(0)->value; 164 | $bases = $xpath->query('//base[@href][1]/@href'); 165 | if ($bases->length !== 0) { 166 | $last = resolveUrl($last, $bases->item(0)->value); 167 | } 168 | } 169 | return resolveUrl($last, $endpoint); 170 | } 171 | 172 | function verifyCode(string $code, string $client_id, string $redirect_uri, string $endpoint): ?array 173 | { 174 | $curl = initCurl($endpoint); 175 | curl_setopt($curl, CURLOPT_POST, true); 176 | curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query([ 177 | 'code' => $code, 178 | 'client_id' => $client_id, 179 | 'redirect_uri' => $redirect_uri, 180 | ])); 181 | curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']); 182 | $body = curl_exec($curl); 183 | curl_close($curl); 184 | $info = json_decode($body, true, 2); 185 | if (json_last_error() !== JSON_ERROR_NONE) { 186 | return null; 187 | } 188 | $info = filter_var_array($info, [ 189 | 'me' => FILTER_VALIDATE_URL, 190 | 'scope' => [ 191 | 'filter' => FILTER_VALIDATE_REGEXP, 192 | 'options' => ['regexp' => '@^[\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*$@'], 193 | ], 194 | ]); 195 | if (in_array(null, $info, true) || in_array(false, $info, true)) { 196 | return null; 197 | } 198 | return $info; 199 | } 200 | 201 | function invalidRequest(): void 202 | { 203 | // This is probably wrong, but RFC 6750 is a little unclear. 204 | // Maybe this should be handled per RFC 6749, putting the error code in the redirect? 205 | header('HTTP/1.1 400 Bad Request'); 206 | header('Content-Type: text/plain;charset=UTF-8'); 207 | exit('invalid_request'); 208 | } 209 | 210 | $method = filter_input(INPUT_SERVER, 'REQUEST_METHOD', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^[!#$%&\'*+.^_`|~0-9a-z-]+$@i']]); 211 | if ($method === 'GET') { 212 | $bearer_regexp = '@^Bearer [0-9a-f]+_[0-9a-f]+$@'; 213 | $authorization = filter_input(INPUT_SERVER, 'HTTP_AUTHORIZATION', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]) 214 | ?? filter_input(INPUT_SERVER, 'REDIRECT_HTTP_AUTHORIZATION', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]); 215 | if ($authorization === null && function_exists('apache_request_headers')) { 216 | $headers = array_change_key_case(apache_request_headers(), CASE_LOWER); 217 | if (isset($headers['authorization'])) { 218 | $authorization = filter_var($headers['authorization'], FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]); 219 | } 220 | } 221 | if ($authorization === null) { 222 | header('HTTP/1.1 401 Unauthorized'); 223 | header('WWW-Authenticate: Bearer'); 224 | exit(); 225 | } elseif ($authorization === false) { 226 | header('HTTP/1.1 401 Unauthorized'); 227 | header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is malformed"'); 228 | exit(); 229 | } else { 230 | $token = retrieveToken(substr($authorization, 7)); 231 | if ($token === null) { 232 | header('HTTP/1.1 401 Unauthorized'); 233 | header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is unknown"'); 234 | exit(); 235 | } elseif ($token['active'] === '0') { 236 | header('HTTP/1.1 401 Unauthorized'); 237 | header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is revoked"'); 238 | exit(); 239 | } else { 240 | header('HTTP/1.1 200 OK'); 241 | header('Content-Type: application/json;charset=UTF-8'); 242 | markTokenUsed($token['token_id']); 243 | exit(json_encode([ 244 | 'me' => $token['auth_me'], 245 | 'client_id' => $token['auth_client_id'], 246 | 'scope' => $token['auth_scope'], 247 | ])); 248 | } 249 | } 250 | } elseif ($method === 'POST') { 251 | $type = filter_input(INPUT_SERVER, 'CONTENT_TYPE', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^application/x-www-form-urlencoded(;.*)?$@']]); 252 | if (!is_string($type)) { 253 | header('HTTP/1.1 415 Unsupported Media Type'); 254 | exit(); 255 | } 256 | $revoke = filter_input(INPUT_POST, 'action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^revoke$@']]); 257 | if (is_string($revoke)) { 258 | $token = filter_input(INPUT_POST, 'token', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^[0-9a-f]+_[0-9a-f]+$@']]); 259 | if (is_string($token)) { 260 | revokeToken($token); 261 | } 262 | header('HTTP/1.1 200 OK'); 263 | exit(); 264 | } 265 | $request = array_merge( 266 | filter_input_array(INPUT_POST, [ 267 | 'grant_type' => [ 268 | 'filter' => FILTER_VALIDATE_REGEXP, 269 | 'options' => ['regexp' => '@^authorization_code$@'], 270 | ], 271 | 'code' => [ 272 | 'filter' => FILTER_VALIDATE_REGEXP, 273 | 'options' => ['regexp' => '@^[\x20-\x7E]+$@'], 274 | ], 275 | 'client_id' => FILTER_VALIDATE_URL, 276 | 'redirect_uri' => FILTER_VALIDATE_URL, 277 | ]), 278 | filter_input_array(INPUT_GET, [ 279 | 'me' => FILTER_VALIDATE_URL, 280 | ]) 281 | ); 282 | if (in_array(null, $request, true) || in_array(false, $request, true)) { 283 | invalidRequest(); 284 | } 285 | $endpoint = discoverAuthorizationEndpoint($request['me']); 286 | if ($endpoint === null || !isTrustedEndpoint($endpoint)) { 287 | invalidRequest(); 288 | } 289 | $info = verifyCode($request['code'], $request['client_id'], $request['redirect_uri'], $endpoint); 290 | if ($info === null) { 291 | invalidRequest(); 292 | } 293 | $token = storeToken($info['me'], $request['client_id'], $info['scope']); 294 | header('HTTP/1.1 200 OK'); 295 | header('Content-Type: application/json;charset=UTF-8'); 296 | exit(json_encode([ 297 | 'access_token' => $token, 298 | 'token_type' => 'Bearer', 299 | 'scope' => $info['scope'], 300 | 'me' => $info['me'], 301 | ])); 302 | } else { 303 | header('HTTP/1.1 405 Method Not Allowed'); 304 | header('Allow: GET, POST'); 305 | exit(); 306 | } 307 | 308 | /** 309 | * The following wall of code is dangerous. There be dragons. 310 | * Taken from the mf2-php project, which is pledged to the public domain under CC0. 311 | */ 312 | function parseUriToComponents(string $uri): array 313 | { 314 | $result = [ 315 | 'scheme' => null, 316 | 'authority' => null, 317 | 'path' => null, 318 | 'query' => null, 319 | 'fragment' => null, 320 | ]; 321 | $u = @parse_url($uri); 322 | if (array_key_exists('scheme', $u)) { 323 | $result['scheme'] = $u['scheme']; 324 | } 325 | if (array_key_exists('host', $u)) { 326 | if (array_key_exists('user', $u)) { 327 | $result['authority'] = $u['user']; 328 | } 329 | if (array_key_exists('pass', $u)) { 330 | $result['authority'] .= ':' . $u['pass']; 331 | } 332 | if (array_key_exists('user', $u) || array_key_exists('pass', $u)) { 333 | $result['authority'] .= '@'; 334 | } 335 | $result['authority'] .= $u['host']; 336 | if (array_key_exists('port', $u)) { 337 | $result['authority'] .= ':' . $u['port']; 338 | } 339 | } 340 | if (array_key_exists('path', $u)) { 341 | $result['path'] = $u['path']; 342 | } 343 | if (array_key_exists('query', $u)) { 344 | $result['query'] = $u['query']; 345 | } 346 | if (array_key_exists('fragment', $u)) { 347 | $result['fragment'] = $u['fragment']; 348 | } 349 | return $result; 350 | } 351 | function resolveUrl(string $baseURI, string $referenceURI): string 352 | { 353 | $target = [ 354 | 'scheme' => null, 355 | 'authority' => null, 356 | 'path' => null, 357 | 'query' => null, 358 | 'fragment' => null, 359 | ]; 360 | $base = parseUriToComponents($baseURI); 361 | if ($base['path'] == null) { 362 | $base['path'] = '/'; 363 | } 364 | $reference = parseUriToComponents($referenceURI); 365 | if ($reference['scheme']) { 366 | $target['scheme'] = $reference['scheme']; 367 | $target['authority'] = $reference['authority']; 368 | $target['path'] = removeDotSegments($reference['path']); 369 | $target['query'] = $reference['query']; 370 | } else { 371 | if ($reference['authority']) { 372 | $target['authority'] = $reference['authority']; 373 | $target['path'] = removeDotSegments($reference['path']); 374 | $target['query'] = $reference['query']; 375 | } else { 376 | if ($reference['path'] == '') { 377 | $target['path'] = $base['path']; 378 | if ($reference['query']) { 379 | $target['query'] = $reference['query']; 380 | } else { 381 | $target['query'] = $base['query']; 382 | } 383 | } else { 384 | if (substr($reference['path'], 0, 1) == '/') { 385 | $target['path'] = removeDotSegments($reference['path']); 386 | } else { 387 | $target['path'] = mergePaths($base, $reference); 388 | $target['path'] = removeDotSegments($target['path']); 389 | } 390 | $target['query'] = $reference['query']; 391 | } 392 | $target['authority'] = $base['authority']; 393 | } 394 | $target['scheme'] = $base['scheme']; 395 | } 396 | $target['fragment'] = $reference['fragment']; 397 | $result = ''; 398 | if ($target['scheme']) { 399 | $result .= $target['scheme'] . ':'; 400 | } 401 | if ($target['authority']) { 402 | $result .= '//' . $target['authority']; 403 | } 404 | $result .= $target['path']; 405 | if ($target['query']) { 406 | $result .= '?' . $target['query']; 407 | } 408 | if ($target['fragment']) { 409 | $result .= '#' . $target['fragment']; 410 | } elseif ($referenceURI == '#') { 411 | $result .= '#'; 412 | } 413 | return $result; 414 | } 415 | function mergePaths(array $base, array $reference): string 416 | { 417 | if ($base['authority'] && $base['path'] == null) { 418 | $merged = '/' . $reference['path']; 419 | } else { 420 | if (($pos=strrpos($base['path'], '/')) !== false) { 421 | $merged = substr($base['path'], 0, $pos + 1) . $reference['path']; 422 | } else { 423 | $merged = $base['path']; 424 | } 425 | } 426 | return $merged; 427 | } 428 | function removeLeadingDotSlash(string &$input): void 429 | { 430 | if (substr($input, 0, 3) == '../') { 431 | $input = substr($input, 3); 432 | } elseif (substr($input, 0, 2) == './') { 433 | $input = substr($input, 2); 434 | } 435 | } 436 | function removeLeadingSlashDot(string &$input): void 437 | { 438 | if (substr($input, 0, 3) == '/./') { 439 | $input = '/' . substr($input, 3); 440 | } else { 441 | $input = '/' . substr($input, 2); 442 | } 443 | } 444 | function removeOneDirLevel(string &$input, string &$output): void 445 | { 446 | if (substr($input, 0, 4) == '/../') { 447 | $input = '/' . substr($input, 4); 448 | } else { 449 | $input = '/' . substr($input, 3); 450 | } 451 | $output = substr($output, 0, strrpos($output, '/')); 452 | } 453 | function removeLoneDotDot(string &$input): void 454 | { 455 | if ($input == '.') { 456 | $input = substr($input, 1); 457 | } else { 458 | $input = substr($input, 2); 459 | } 460 | } 461 | function moveOneSegmentFromInput(string &$input, string &$output): void 462 | { 463 | if (substr($input, 0, 1) != '/') { 464 | $pos = strpos($input, '/'); 465 | } else { 466 | $pos = strpos($input, '/', 1); 467 | } 468 | if ($pos === false) { 469 | $output .= $input; 470 | $input = ''; 471 | } else { 472 | $output .= substr($input, 0, $pos); 473 | $input = substr($input, $pos); 474 | } 475 | } 476 | function removeDotSegments(string $path): string 477 | { 478 | $input = $path; 479 | $output = ''; 480 | $step = 0; 481 | while ($input) { 482 | $step++; 483 | if (substr($input, 0, 3) == '../' || substr($input, 0, 2) == './') { 484 | removeLeadingDotSlash($input); 485 | } elseif (substr($input, 0, 3) == '/./' || $input == '/.') { 486 | removeLeadingSlashDot($input); 487 | } elseif (substr($input, 0, 4) == '/../' || $input == '/..') { 488 | removeOneDirLevel($input, $output); 489 | } elseif ($input == '.' || $input == '..') { 490 | removeLoneDotDot($input); 491 | } else { 492 | moveOneSegmentFromInput($input, $output); 493 | } 494 | } 495 | return $output; 496 | } 497 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tokens ( 2 | token_id CHAR(64) NOT NULL PRIMARY KEY, 3 | token_hash VARCHAR(255) NOT NULL, 4 | auth_me VARCHAR(255) NOT NULL, 5 | auth_client_id VARCHAR(255) NOT NULL, 6 | auth_scope VARCHAR(255) NOT NULL, 7 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 8 | last_use TIMESTAMP DEFAULT NULL, 9 | revoked TIMESTAMP DEFAULT NULL 10 | ); 11 | CREATE TABLE settings ( 12 | setting_name VARCHAR(255) NOT NULL, 13 | setting_value VARCHAR(255) NOT NULL, 14 | CONSTRAINT setting UNIQUE (setting_name, setting_value) 15 | ); 16 | --------------------------------------------------------------------------------