asReq = [ 't' => (string) ($asReq['t'] ?? ''), 'id' => self::positiveInt($asReq['id'] ?? 0), 'dir' => (string) ($asReq['dir'] ?? ''), 'date' => (string) ($asReq['date'] ?? ''), 'content' => (string) ($asReq['content'] ?? ''), 'has_content'=> array_key_exists('content', $asReq), 'name' => (string) ($asReq['name'] ?? ''), 'email' => (string) ($asReq['email'] ?? ''), 'password' => (string) ($asReq['password'] ?? ''), 'remember' => !empty($asReq['remember']), 'field' => (string) ($asReq['field'] ?? ''), 'value' => (string) ($asReq['value'] ?? ''), //sendBeacon cannot set headers, so the unload close falls back to //carrying the token in the body. 'csrf_token'=> (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? '')) ]; //Authentication and CSRF protection share the same server-side session. $this->initCsrfToken(); $this->oMyThoughts = new MyThoughts($sProcessPage, $this->asReq['t']); //Validate CSRF, then release the session lock before long-running work. $bValidMutationRequest = $this->validateMutationRequest($sAction); if(!$bValidMutationRequest || !in_array($sAction, self::SESSION_WRITING_ACTIONS, true)) $this->closeSession(); if(!$bValidMutationRequest) $sResult = MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED); else $sResult = ($sAction == '') ? $this->oMyThoughts->getAppMainPage($this->getCsrfToken()) : $this->dispatch($sAction); //Clean errors $sDebug = ob_get_clean(); if($sDebug != '') $this->oMyThoughts->addUncaughtError($sDebug); $this->closeSession(); return $sResult; } private function dispatch(string $sAction): string { $oJournal = $this->oMyThoughts->getJournal(); return match($sAction) { /* Account */ 'signup' => $this->oMyThoughts->signup($this->asReq['name'], $this->asReq['email'], $this->asReq['password'], $this->asReq['t']), 'login' => $this->oMyThoughts->login($this->asReq['email'], $this->asReq['password'], $this->asReq['t'], $this->asReq['remember']), 'logout' => $this->oMyThoughts->logout(), 'account' => $this->oMyThoughts->updateAccount($this->asReq['field'], $this->asReq['value']), /* Reading the book */ 'book' => $oJournal->getBook(), 'entries' => $oJournal->getEntries($this->asReq['dir'], $this->asReq['id']), 'date' => $oJournal->getEntryIdAtDate($this->asReq['date']), /* Writing in it */ 'open_entry' => $oJournal->openEntry(), 'save_entry' => $oJournal->saveEntry($this->asReq['id'], $this->asReq['content']), 'close_entry' => $oJournal->closeEntry($this->asReq['id'], $this->asReq['content'], $this->asReq['has_content']), 'delete_entry' => $oJournal->deleteEntry($this->asReq['id']), default => MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND) }; } /* CSRF & session */ private function validateMutationRequest(string $sAction): bool { return PHP_SAPI === 'cli' || !in_array($sAction, self::MUTATING_ACTIONS, true) || (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $this->checkCsrfToken($this->asReq['csrf_token'])) ; } private function getCsrfToken(): string { if($this->sCsrfToken === '') $this->initCsrfToken(); return $this->sCsrfToken; } private function initCsrfToken(): void { if(PHP_SAPI === 'cli') return; if(session_status() !== PHP_SESSION_ACTIVE) { session_set_cookie_params(['httponly' => true, 'secure' => User::isSecureRequest(), 'samesite' => 'Lax']); session_start(); } if(empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $this->sCsrfToken = $_SESSION['csrf_token']; } private function checkCsrfToken(string $sClientToken): bool { $sServerToken = $this->getCsrfToken(); return PHP_SAPI === 'cli' || ($sServerToken !== '' && $sClientToken !== '' && hash_equals($sServerToken, $sClientToken)); } private function closeSession(): void { if(session_status() === PHP_SESSION_ACTIVE) session_write_close(); } private static function positiveInt($oValue): int { return filter_var($oValue, FILTER_VALIDATE_INT, ['options' => ['default' => 0, 'min_range' => 0]]); } }