$err, "skipLoginCheck" => $skipLoginCheck, ]); } function msg_info(string $msg, bool $skipLoginCheck = false): void { render("info_page.twig", [ "message" => $msg, "skipLoginCheck" => $skipLoginCheck, ]); } function generateCaptchaText(): string { $phrase = ""; for ($i = 0; $i < CAPTCHA_PHRASE_LENGTH; ++$i) $phrase .= CAPTCHA_CHARSET[random_int(0, strlen(CAPTCHA_CHARSET) - 1)]; return $phrase; } function generatePasswordResetLink(Database &$db, User &$user): ?string { $token = $db->generateId(20); $user->passwordResetToken = $token; $user->passwordResetTokenCreated = new \DateTimeImmutable(); if (!$db->update($user)) { return null; } return env("PUBLIC_URL") . "?_action=pwreset&token=" . urlencode($user->passwordResetToken) . "&sig=" . urlencode(base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true))); } function decodePasswordResetLink(Database &$db, string $token, string $signature): ?User { $user = new User(); $user->passwordResetToken = $token; if (!$db->fetchWhere($user, "password_reset_token")) { return null; } $then = $user->passwordResetTokenCreated; $now = new \DateTimeImmutable(); if (($now->getTimestamp() - $then->getTimestamp()) >= 3600) { return null; } $expectedSignature = base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true)); if ($expectedSignature !== $signature) { return null; } return $user; } function getThemeAndLangInfo(): array { $availableThemes = []; $currentTheme = $_GET["theme"] ?? $_COOKIE["theme"] ?? env("MYSTIC_FORUM_THEME") ?? "default"; foreach (scandir($dir = __ROOT__ . '/application/themes/') as $ent) { if ($ent[0] === "." || !is_dir($dir . "/" . $ent) || !is_file($theme_file = $dir . "/" . $ent . "/theme.json")) continue; $theme_info = json_decode(file_get_contents($theme_file)); $availableThemes[$ent] = $theme_info; } $availableLangs = [ "en" => "🇺🇸 English", ]; foreach (i18n_get_available_locales() as $loc) { if (isset($availableLangs[$loc])) continue; $metadata = i18n_metadata($loc); $availableLangs[$loc] = $metadata["langName"] ?? $loc; } return [ "availableThemes" => $availableThemes, "currentTheme" => $currentTheme, "availableLangs" => $availableLangs, "currentLang" => i18n_get_current_locale(), ]; } function isTrue(string $str): bool { $str = strtolower($str); return in_array($str, ["yes","true","y","t","on","enabled","1","?1"]); } function reArrayFiles(&$file_post) { $file_ary = []; $file_count = count($file_post['name']); $file_keys = array_keys($file_post); for ($i=0; $i<$file_count; $i++) { if ($file_post["error"][$i] !== UPLOAD_ERR_OK) continue; foreach ($file_keys as $key) { $file_ary[$i][$key] = $file_post[$key][$i]; } } return $file_ary; } function render(string $page, array $context = []): void { $defaultTemplate = "bootstrap-3"; // bootstrap-3 is the default for backwards compatibility $templateDir = __ROOT__ . "/application/templates"; [ "availableThemes" => $availableThemes, "currentTheme" => $currentTheme, ] = getThemeAndLangInfo(); $currentThemeInfo = $availableThemes[$currentTheme] ?? (object)[]; $currentTemplate = $currentThemeInfo->template ?? $defaultTemplate; $availableTemplates = array_values(array_filter(scandir($templateDir), function($theme) use ($templateDir) { return $theme[0] !== "." && is_dir($templateDir . "/" . $theme); })); if (!in_array($currentTemplate, $availableTemplates)) $currentTemplate = $defaultTemplate; $loader = new FilesystemLoader([ $templateDir . "/" . $currentTemplate, ]); $twig = new Environment($loader, [ // TODO Enable caching ]); $twig->addFunction(new TwigFunction("__", __(...), [ "is_safe" => ["html"] ])); $twig->addFunction(new TwigFunction("___", ___(...), [ "is_safe" => ["html"] ])); $twig->addFunction(new TwigFunction("renderPost", renderPost(...), [ "is_safe" => ["html"] ])); $twig->addFunction(new TwigFunction("renderPostSummary", renderPostSummary(...), [ "is_safe" => ["html"] ])); $twig->addFunction(new TwigFunction("permission", fn(string $name): int => constant(UserPermissions::class . "::" . $name))); $twig->addFunction(new TwigFunction("getAndClearFormError", RequestUtils::getAndClearFormError(...))); $twig->addFunction(new TwigFunction("lastFormField", function(string $formId, string $field): ?string { $lastFormId = ""; $lastForm = RequestUtils::getLastForm($lastFormId) ?? null; if ($lastForm === null || $lastFormId !== $formId) return null; return $lastForm[$field] ?? null; })); $twig->addFilter(new TwigFilter("hash", fn(string $data, string $algo, bool $binary = false, array $options = []) => hash($algo, $data, $binary, $options))); $twig->addFilter(new TwigFilter("base64_encode", base64_encode(...))); $twig->addFilter(new TwigFilter("base64_decode", base64_decode(...))); echo $twig->render($page, [ "g" => [ "get" => &$_GET, "post" => &$_POST, "request" => &$_REQUEST, "session" => &$_SESSION, "cookie" => &$_COOKIE, "env" => &$_ENV, "server" => &$_SERVER, "globals" => $GLOBALS, ], "ctx" => &$context, "currentUser" => &$GLOBALS["currentUser"], ...getThemeAndLangInfo(), ]); RequestUtils::clearLastForm(); } const TAGS_REGEX = '/\[(b|i|u|s|\\^|_|spoiler)\](.+?)\[\/\1\]/s'; function expandTags(string $contents): string { return preg_replace_callback(TAGS_REGEX, function(array $m) { $randomId = "target-" . bin2hex(random_bytes(8)); $inner = $m[2]; $tag = [ "b" => ["", "" ], "i" => ["", "" ], "s" => ["", "" ], "^" => ["", "" ], "_" => ["", "" ], "spoiler" => [ "
", "
" ], ][$m[1]] ?? $m[1]; if (preg_match(TAGS_REGEX, $inner)) $inner = expandTags($inner); if (!is_array($tag)) $tag = [ "<$tag>", "" ]; $tagsBefore = $tag[0]; $tagsAfter = $tag[1]; return "{$tagsBefore}{$inner}{$tagsAfter}"; }, $contents); } function renderPostSummary(string $contents): string { $contents = renderPost($contents); // remove spoiler contents so they don't appear in summaries $contents = preg_replace('/(.*?)/s', '[ ' . __("Spoiler") . ' ]', $contents); return htmlentities(html_entity_decode(StringUtils::truncate(strip_tags($contents), 100))); } function renderPost(string $contents): string { $contents = preg_replace('~\R~u', "\n", $contents); $contents = trim($contents); $contents = preg_replace('/\n{3,}/', "\n\n", $contents); $contents = htmlentities($contents); $contents = nl2br($contents, false); $lines = explode("\n", $contents); $contents = ""; $lineBuf = []; $inQuote = false; foreach ($lines as $ln) { if (str_starts_with($ln, "> ")) { if (!$inQuote) { $contents .= implode("\n", $lineBuf); $lineBuf = []; $inQuote = true; } $lineBuf []= substr($ln, 5); } else { if ($inQuote) { $contents .= "
\n" . implode("\n", $lineBuf) . "
\n"; $lineBuf = []; $inQuote = false; if (trim($ln) === "
") continue; } $lineBuf []= $ln; } } if ($inQuote) { $contents .= "
" . implode("\n", $lineBuf) . "
"; } else { $contents .= implode("\n", $lineBuf); } $contents = expandTags($contents); return $contents; } function env(string $key): ?string { $val = getenv($key); if ($val === false) return null; return $val; } require_once __DIR__ . "/vendor/autoload.php"; require_once __DIR__ . "/application/i18n.php"; if ($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["lang"])) { parse_str($_SERVER["QUERY_STRING"], $query); if (empty($query["lang"])) { setcookie("lang", "", 100); } else { setcookie("lang", $query["lang"], time()+60*60*24*30); } unset($query["lang"]); $path = strtok($_SERVER["REQUEST_URI"], "?"); header("Location: $path?" . http_build_query($query)); exit; } $user_locale = env("LOCALE") ?? $_COOKIE["lang"] ?? locale_accept_from_http($_SERVER["HTTP_ACCEPT_LANGUAGE"] ?? ""); $chosen_locale = locale_lookup(i18n_get_available_locales(), $user_locale, true, "en"); i18n_locale($user_locale); $db = null; try { $db = new Database(Database::getConnectionString("db", getenv("POSTGRES_USER"), getenv("POSTGRES_PASSWORD"), getenv("POSTGRES_DBNAME"))); } catch (DatabaseConnectionException $ex) { msg_error( __("Failed to connect to database:\n%details%", [ "details" => $ex->getMessage(), ]), true ); exit; } $GLOBALS["db"] = &$db; $db->ensureTable(User::class); $db->ensureTable(Topic::class); $db->ensureTable(Post::class); $db->ensureTable(Attachment::class); $db->ensureTable(TopicLogMessage::class); $superuser = new User(); $superuser->id = "SUPERUSER"; if (!$db->fetch($superuser)) { $superUserPassword = base64_encode(random_bytes(12)); $suEmail = env("MYSTIC_FORUM_SUPERUSER_EMAIL"); if ($suEmail === null) { http_response_code(500); Messaging::error("No superuser email defined. Please set the 'MYSTIC_FORUM_SUPERUSER_EMAIL' environment variable accordingly"); exit; } $superuser->name = "superuser"; $superuser->email = $suEmail; $superuser->passwordHash = password_hash($superUserPassword, PASSWORD_DEFAULT); $superuser->displayName = "SuperUser"; $superuser->created = new \DateTimeImmutable(); $superuser->permissionMask = PHP_INT_MAX; $superuser->passwordResetRequired = false; $superuser->activated = true; $superuser->activationToken = ""; $db->insert($superuser); Messaging::info([ Messaging::bold("Superuser account created"), [ "Username" => $superuser->name, "Password" => $superUserPassword, ], "Please note that the password can only be shown this time, so please note it down!", ]); exit; } $currentUser = RequestUtils::getAuthorizedUser($db); $GLOBALS["currentUser"] = &$currentUser; // initialization finished function invalid_action(string $_action): never { http_response_code(404); msg_error(__("Invalid or unknown action \"%action%\"", [ "action" => $_action, ])); exit; } if ($_action !== null && !preg_match('/^[a-z][a-z0-9_]*$/', $_action)) invalid_action($_action); $_action ??= "_default"; $actionDir = __DIR__ . "/application/actions/$_action"; if (!is_dir($actionDir)) invalid_action($_action); $actionMethod = strtolower(preg_replace('/[^A-Za-z]/', '', $_rq_method)); if (is_file($commonFile = $actionDir . "/_common.php")) include $commonFile; if (is_file($anyFile = $actionDir . "/_any.php")) include $anyFile; elseif (is_file($methodFile = $actionDir . "/$actionMethod.php")) include $methodFile; else { http_response_code(405); Messaging::error("Invalid request method " . RequestUtils::getRequestMethod()); }