parameters[$key] ?? $default; } public function has(string $key):bool { return array_key_exists($key, $this->parameters); } public function set(string $key, $value):void { $this->parameters[$key] = $value; } public function remove(string $key):void { unset($this->parameters[$key]); } public function all():array { return $this->parameters; } } /** * Extension of Parameter bag that reads and write the data into a file */ final class Storage extends ParameterBag { private bool $dirty = false; public function __construct(private readonly string $filename) { if (file_exists($filename)) { parent::__construct(unserialize(file_get_contents($filename), ['allowed_classes' => false])); } else { parent::__construct(); } } public function set(string $key, $value):void { $this->dirty = true; parent::set($key, $value); } public function remove(string $key):void { $this->dirty = true; parent::remove($key); } public function __destruct() { if ($this->dirty) { file_put_contents($this->filename, serialize($this->parameters)); } } } /** * Request object * Basically represents a given user request, holding GET and POST parameters, SERVER data and even allows to obtain * the path a user tries to access through the script * * Part of this code has been borrowed from the Http Foundation component, minus the IIS support: * @see https://github.com/symfony/http-foundation * > Copyright (c) 2004-present Fabien Potencier * > * > Permission is hereby granted, free of charge, to any person obtaining a copy * > of this software and associated documentation files (the "Software"), to deal * > in the Software without restriction, including without limitation the rights * > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * > copies of the Software, and to permit persons to whom the Software is furnished * > to do so, subject to the following conditions: * > * > The above copyright notice and this permission notice shall be included in all * > copies or substantial portions of the Software. * > * > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * > THE SOFTWARE. */ class Request { public readonly string $requestId; public ParameterBag $query; public ParameterBag $request; public ParameterBag $server; public function __construct() { $this->requestId = uniqid(more_entropy: true); $this->query = new ParameterBag($_GET); $this->request = new ParameterBag($_POST); $this->server = new ParameterBag($_SERVER); } public function getPathInfo(): string { if (null === ($requestUri = $this->getRequestUri())) { return '/'; } // Remove the query string from REQUEST_URI if (false !== $pos = strpos($requestUri, '?')) { $requestUri = substr($requestUri, 0, $pos); } if ('' !== $requestUri && '/' !== $requestUri[0]) { $requestUri = '/'.$requestUri; } if (null === ($baseUrl = $this->getBaseUrl())) { return $requestUri; } $pathInfo = substr($requestUri, \strlen($baseUrl)); if ('' === $pathInfo || '/' !== $pathInfo[0]) { return '/'.$pathInfo; } return $pathInfo; } private function getBaseUrl():string { $filename = basename($this->server->get('SCRIPT_FILENAME', '')); if (basename($this->server->get('SCRIPT_NAME', '')) === $filename) { $baseUrl = $this->server->get('SCRIPT_NAME'); } elseif (basename($this->server->get('PHP_SELF', '')) === $filename) { $baseUrl = $this->server->get('PHP_SELF'); } elseif (basename($this->server->get('ORIG_SCRIPT_NAME', '')) === $filename) { $baseUrl = $this->server->get('ORIG_SCRIPT_NAME'); // 1and1 shared hosting compatibility } else { // Backtrack up the script_filename to find the portion matching // php_self $path = $this->server->get('PHP_SELF', ''); $file = $this->server->get('SCRIPT_FILENAME', ''); $segs = explode('/', trim($file, '/')); $segs = array_reverse($segs); $index = 0; $last = \count($segs); $baseUrl = ''; do { $seg = $segs[$index]; $baseUrl = '/'.$seg.$baseUrl; ++$index; } while ($last > $index && (false !== $pos = strpos($path, $baseUrl)) && 0 != $pos); } // Does the baseUrl have anything in common with the request_uri? $requestUri = $this->getRequestUri(); if ('' !== $requestUri && '/' !== $requestUri[0]) { $requestUri = '/'.$requestUri; } if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) { // full $baseUrl matches return $prefix; } if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, rtrim(\dirname($baseUrl), '/'.\DIRECTORY_SEPARATOR).'/')) { // directory portion of $baseUrl matches return rtrim($prefix, '/'.\DIRECTORY_SEPARATOR); } $truncatedRequestUri = $requestUri; if (false !== $pos = strpos($requestUri, '?')) { $truncatedRequestUri = substr($requestUri, 0, $pos); } $basename = basename($baseUrl ?? ''); if (!$basename || !strpos(rawurldecode($truncatedRequestUri), $basename)) { // no match whatsoever; set it blank return ''; } // If using mod_rewrite or ISAPI_Rewrite strip the script filename // out of baseUrl. $pos !== 0 makes sure it is not matching a value // from PATH_INFO or QUERY_STRING if (\strlen($requestUri) >= \strlen($baseUrl) && (false !== $pos = strpos($requestUri, $baseUrl)) && 0 !== $pos) { $baseUrl = substr($requestUri, 0, $pos + \strlen($baseUrl)); } return rtrim($baseUrl, '/'.\DIRECTORY_SEPARATOR); } private function getRequestUri(): string { $requestUri = ''; if ($this->server->has('REQUEST_URI')) { $requestUri = $this->server->get('REQUEST_URI'); if ('' !== $requestUri && '/' === $requestUri[0]) { // To only use path and query remove the fragment. if (false !== $pos = strpos($requestUri, '#')) { $requestUri = substr($requestUri, 0, $pos); } } else { // HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path, // only use URL path. $uriComponents = parse_url($requestUri); if (isset($uriComponents['path'])) { $requestUri = $uriComponents['path']; } if (isset($uriComponents['query'])) { $requestUri .= '?'.$uriComponents['query']; } } } elseif ($this->server->has('ORIG_PATH_INFO')) { // IIS 5.0, PHP as CGI $requestUri = $this->server->get('ORIG_PATH_INFO'); if ('' != $this->server->get('QUERY_STRING')) { $requestUri .= '?'.$this->server->get('QUERY_STRING'); } $this->server->remove('ORIG_PATH_INFO'); } // normalize the request URI to ease creating sub-requests from this request $this->server->set('REQUEST_URI', $requestUri); return $requestUri; } /** * Returns the prefix as encoded in the string when the string starts with * the given prefix, null otherwise. */ private function getUrlencodedPrefix(string $string, string $prefix): ?string { if (!str_starts_with(rawurldecode($string), $prefix)) { return null; } $len = \strlen($prefix); if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { return $match[0]; } return null; } } /** * Response object, inspired by the Http Foundation component * Basically holds a response to be sent to the user */ class Response { public function __construct(protected string $content, protected int $status = 200, protected array $headers = []) {} public function send(): void { http_response_code($this->status); foreach ($this->headers as $name => $value) { header("$name: $value"); } echo $this->content; } } /** * HttpException allows to hold an HTTP error message to send to the user. */ class HttpException extends \Exception { public function __construct(string $message = "", int $code = 500, ?\Throwable $previous = null) { parent::__construct($message, $code, $previous); } } /** * Variant of Response which automatically encodes its body as JSON */ class JsonResponse extends Response { /** * @throws \JsonException */ public function __construct(array|string|null $body = null, int $status = 200, array $headers = []) { parent::__construct(json_encode($body, JSON_THROW_ON_ERROR), $status, $headers); $this->headers['Content-Type'] = 'application/json'; } } /** * Helper class to generate Curl requests for Webthread. */ class CurlRequest { public readonly int $httpCode; public readonly string $contentType; private function __construct(public readonly string $body, array $info) { $this->httpCode = $info['http_code']; $this->contentType = $info['content_type']; } public static function request($url, array $headers = []): self|false { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => 'WebThread-'.WEBTHREAD_VERSION, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => $headers, CURLOPT_MAXFILESIZE => WEBTHREAD_MAXFILESIZE, ]); $response = curl_exec($ch); $info = curl_getinfo($ch); curl_close($ch); return $response !== false ? new self($response, $info) : false; } } /** * Helper class that validates some user data */ class Validator { /** * @throws HttpException */ public static function validateLocalUrl(string $localUrl): void { if (empty($localUrl) || filter_var($localUrl, FILTER_VALIDATE_URL) === false) { throw new HttpException('Missing or invalid target URL', 400); } [ 'host' => $localHost, ] = parse_url($localUrl); // check target url matches the current host if ($localHost !== WEBTHREAD_DOMAIN) { throw new HttpException('Invalid domain for target URL', 400); } // check target url actually respond if (CurlRequest::request($localUrl)->httpCode !== 200) { throw new HttpException('Invalid target URL', 400); } } /** * @throws HttpException */ public static function validateRemoteUrl(string $remoteUrl): string { if (empty($remoteUrl) || filter_var($remoteUrl, FILTER_VALIDATE_URL) === false) { throw new HttpException('Missing or invalid URL: '.$remoteUrl, 400); } [ 'scheme' => $remoteScheme, 'host' => $remoteHost, ] = parse_url($remoteUrl); if (!in_array($remoteScheme, ['http', 'https'], true) ) { throw new HttpException('Invalid URL', 400); } // ip addresses are forbidden if (filter_var($remoteHost, FILTER_VALIDATE_IP) !== false) { throw new HttpException('Invalid URL', 400); } return $remoteHost; } } /** * Helper class that manages authentication */ class Security { public function __construct(private Storage $storage) { } public function authenticate(string $password): bool { $securityStorage = $this->storage->get(WEBTHREAD_STORAGE_KEY_SECURITY, []); if ($securityStorage['cooldown'] > time()) { throw new HttpException('Rate limit exceeded', 429); } if (password_verify($password, WEBTHREAD_PASSWORD_HASH)) { $securityStorage['failed_attempts'] = 0; $this->storage->set(WEBTHREAD_STORAGE_KEY_SECURITY, $securityStorage); return true; } $securityStorage['failed_attempts']++; $securityStorage['cooldown'] = time() + $securityStorage['failed_attempts']**4; $this->storage->set(WEBTHREAD_STORAGE_KEY_SECURITY, $securityStorage); return false; } } // -- CONTROLLERS ------------------------------------------------------------------------------------------------------ /** * Abstract controller class all controllers must implement in order to work properly. */ abstract class AbstractController { public function __construct(protected Storage $storage) {} abstract public function run(Request $request): Response; } class IdentityController extends AbstractController { /** * @throws JsonException */ public function run(Request $request): Response { return new JsonResponse([ 'software' => 'web-thread', 'version' => WEBTHREAD_VERSION, 'domain' => WEBTHREAD_DOMAIN, 'username' => WEBTHREAD_USERNAME, 'avatar' => WEBTHREAD_AVATAR, 'capabilities' => [ 'send', 'replies', 'following', 'admin' ] ]); } } class SendController extends AbstractController { /** * @throws HttpException * @throws JsonException */ public function run(Request $request): Response { // decompose URL and validate it $remoteUrl = $request->request->get('remote', ''); // strip hash part [$remoteUrl,] = explode('#', $remoteUrl); $localUrl = $request->request->get('target', ''); // strip hash part [$localUrl,] = explode('#', $localUrl); $remoteHost = Validator::validateRemoteUrl($remoteUrl); // verify if the remote url is banned $banned = $this->storage->get(WEBTHREAD_STORAGE_KEY_BANNED, []); if (in_array($remoteHost, $banned, true)) { throw new HttpException('Forbidden', 403); } Validator::validateLocalUrl($localUrl); // load replies from local URL $replies = $this->storage->get($localUrl, []); $follows = $this->storage->get(WEBTHREAD_STORAGE_KEY_FOLLOWING, []); // check if a similar domain already have a pending reply, and refuse it if so // skip this check if the domain is in the following list if (!array_key_exists($remoteHost, $follows)) { foreach ($replies as $reply) { if ($reply['domain'] === $remoteHost && $reply['status'] === 'pending') { throw new HttpException('Pending approval', 429); } } } // query provided url in search for webthread on the remote side $response = CurlRequest::request($remoteUrl, [ 'Accept: text/html', ]); if ($response === false) { throw new HttpException('Unable to retrieve page, or timed out', 400); } $dom = new DOMDocument(); @$dom->loadHTML($response->body); $xpath = new DOMXPath($dom); // find meta header "web-thread" $webthreadMeta = $xpath->query('/html/head/meta[@name="web-thread"]/@content'); if ($webthreadMeta->length === 0) { throw new HttpException('Web thread not found on remote page', 400); } // only consider the first one and call it to obtain username and avatar $remoteWebthreadUrl = $webthreadMeta->item(0)->value; $remoteWebthreadResponse = CurlRequest::request($remoteWebthreadUrl); if ($remoteWebthreadResponse === false) { throw new HttpException('Web thread not responding on remote page', 400); } $remoteTitle = $xpath->query('/html/head/title')->item(0)->textContent ?? $remoteUrl; $remoteData = json_decode($remoteWebthreadResponse->body, true, 512, JSON_THROW_ON_ERROR); $remoteName = $remoteData['username'] ?? 'no-username'; $remoteAvatar = $remoteData['avatar'] ?? null; // try to find a link to our page in the remote url $links = $xpath->query('//a[@href="'.$localUrl.'"]'); if ($links->length === 0) { throw new HttpException('Target URL not found on remote page', 400); } // check if reply url already exists foreach ($replies as $reply) { if ($reply['url'] === $remoteUrl) { throw new HttpException('A reply already exists with this url', 400); } } // successfully sent reply $uid = uniqid(more_entropy: true); $replies[$uid] = [ 'id' => $uid, 'username' => $remoteName, 'avatar' => $remoteAvatar, 'title' => $remoteTitle, 'target' => $localUrl, 'url' => $remoteUrl, 'domain' => $remoteHost, 'status' => in_array($remoteHost, $follows, true) ? 'published' : 'pending', ]; $this->storage->set($localUrl, $replies); return new JsonResponse(); } } class RepliesController extends AbstractController { /** * @throws HttpException * @throws JsonException */ public function run(Request $request): Response { $localUrl = $request->query->get('target', ''); // strip hash part [$localUrl,] = explode('#', $localUrl); if (!empty($localUrl)) { Validator::validateLocalUrl($localUrl); $replies = $this->storage->get($localUrl, []); } else { $replies = []; foreach ($this->storage->all() as $key => $storedReplies) { if (in_array($key, [WEBTHREAD_STORAGE_KEY_FOLLOWING, WEBTHREAD_STORAGE_KEY_BANNED, WEBTHREAD_STORAGE_KEY_SECURITY], true)) { continue; } $replies[] = $storedReplies; } $replies = array_merge(...$replies); } return new JsonResponse(array_values($replies)); } } class FollowingController extends AbstractController { /** * @throws JsonException */ public function run(Request $request): Response { return new JsonResponse(array_values($this->storage->get(WEBTHREAD_STORAGE_KEY_FOLLOWING, []))); } } class AdminController extends AbstractController { /** * @throws HttpException */ public function run(Request $request): Response { $localUrl = $request->request->get('target', ''); // strip hash part [$localUrl,] = explode('#', $localUrl); Validator::validateLocalUrl($localUrl); $action = $request->request->get('action'); if (!in_array($action, ['follow', 'approve', 'deny', 'ban'], true)) { throw new HttpException('Invalid action', 400); } $replyId = $request->request->get('reply'); $replies = $this->storage->get($localUrl, []); if (!isset($replies[$replyId])) { throw new HttpException('Reply not found', 404); } $reply = $replies[$replyId]; switch ($action) { case 'follow': $follows = $this->storage->get(WEBTHREAD_STORAGE_KEY_FOLLOWING, []); $follows[$reply['domain']] = [ 'username' => $reply['username'], 'avatar' => $reply['avatar'], 'domain' => $reply['domain'], ]; $this->storage->set(WEBTHREAD_STORAGE_KEY_FOLLOWING, $follows); // go through all stored replies to approve them foreach ($this->storage->all() as $key => $storedReplies) { if (in_array($key, [WEBTHREAD_STORAGE_KEY_FOLLOWING, WEBTHREAD_STORAGE_KEY_BANNED, WEBTHREAD_STORAGE_KEY_SECURITY], true)) { continue; } foreach ($storedReplies as $storedReply) { if ($storedReply['domain'] === $reply['domain']) { $storedReply['status'] = 'published'; } } $this->storage->set($key, $storedReplies); } break; case 'approve': $replies[$replyId]['status'] = 'published'; $this->storage->set($localUrl, $replies); break; case 'ban': $banned = $this->storage->get(WEBTHREAD_STORAGE_KEY_BANNED, []); $banned[] = $reply['domain']; $this->storage->set(WEBTHREAD_STORAGE_KEY_BANNED, $banned); // go through all stored replies to delete them foreach ($this->storage->all() as $key => $storedReplies) { if (in_array($key, [WEBTHREAD_STORAGE_KEY_FOLLOWING, WEBTHREAD_STORAGE_KEY_BANNED, WEBTHREAD_STORAGE_KEY_SECURITY], true)) { continue; } $storedReplies = array_filter($storedReplies, static fn ($storedReply) => $storedReply['domain'] !== $reply['domain']); $this->storage->set($key, $storedReplies); } break; case 'deny': unset($replies[$replyId]); $this->storage->set($localUrl, $replies); break; default: } return new JsonResponse(); } } // -- KERNEL ----------------------------------------------------------------------------------------------------------- /** * Microkernel for Webthread * Handles the routing as well as authentication when relevant. * To extend Webthread, all you need to do is create a controller like above, then register a route in the ROUTES * constant. The kernel will handle the rest and will call your controller when the conditions are met. */ class Kernel { // route configuration private const ROUTES = [ '/' => [ 'controller' => IdentityController::class, 'allowed_methods' => ['GET'], 'require_auth' => false, ], '/send' => [ 'controller' => SendController::class, 'allowed_methods' => ['POST'], 'require_auth' => false, ], '/replies' => [ 'controller' => RepliesController::class, 'allowed_methods' => ['GET'], 'require_auth' => false, ], '/following' => [ 'controller' => FollowingController::class, 'allowed_methods' => ['GET'], 'require_auth' => false, ], '/manage' => [ 'controller' => AdminController::class, 'allowed_methods' => ['POST'], 'require_auth' => true, ] ]; private Storage $storage; public function __construct() { $storageDir = WEBTHREAD_STORAGE_FILE; if (!str_starts_with($storageDir, '/')) { $storageDir = __DIR__.DIRECTORY_SEPARATOR.$storageDir; } $this->storage = new Storage($storageDir); } /** * @throws JsonException */ public function run(Request $request): Response { try { // extract request URI and check if a controller exists to handle it $uri = $request->getPathInfo(); if (empty(self::ROUTES[$uri])) { throw new HttpException('Not found', 404); } // fetch controller information [ 'controller' => $controllerClass, 'allowed_methods' => $allowedMethods, 'require_auth' => $requireAuth, ] = self::ROUTES[$uri]; // check if the method is allowed if (!in_array($request->server->get('REQUEST_METHOD'), $allowedMethods, true)) { throw new HttpException('Method Not Allowed', 405); } // check for authentication if relevant if ($requireAuth) { $security = new Security($this->storage); $password = $request->request->get('password', ''); if (empty($password) || !$security->authenticate($password)) { throw new HttpException('Unauthorized', 401); } } // run the controller return (new $controllerClass($this->storage))->run($request); } catch (\Throwable $exception) { $httpCode = $exception instanceof HttpException ? $exception->getCode() : 500; return new JsonResponse([ "message" => $exception->getMessage() ], $httpCode); } } } // Launch the microframework // First, capture the request data by creating a Request object $request = new Request(); // Then, initialize the kernel $kernel = new Kernel(); // Run the kernel with the request, which will output a response $response = $kernel->run($request); // Finally, send the response to the client $response->send();