v3 init push
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\MyThoughts;
|
||||
|
||||
use Franzz\Objects\PhpObject;
|
||||
use Franzz\Objects\ToolBox;
|
||||
|
||||
/**
|
||||
* Single entry point: parses the request, guards it, dispatches it.
|
||||
*/
|
||||
class Controller extends PhpObject {
|
||||
//Anything that changes state must arrive as POST with a valid CSRF token.
|
||||
private const MUTATING_ACTIONS = [
|
||||
'signup',
|
||||
'login',
|
||||
'logout',
|
||||
'account',
|
||||
'open_entry',
|
||||
'save_entry',
|
||||
'close_entry',
|
||||
'delete_entry'
|
||||
];
|
||||
|
||||
//Actions that need the session lock held while they run.
|
||||
private const SESSION_WRITING_ACTIONS = [
|
||||
'signup',
|
||||
'login',
|
||||
'logout'
|
||||
];
|
||||
|
||||
private MyThoughts $oMyThoughts;
|
||||
private array $asReq = [];
|
||||
private string $sCsrfToken = '';
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(__CLASS__);
|
||||
}
|
||||
|
||||
public function handle($sProcessPage, array $argv = []): string {
|
||||
//Start buffering so warnings/notices can be collected
|
||||
ob_start();
|
||||
|
||||
$asReq = ToolBox::getRequest($argv);
|
||||
$sAction = $asReq['a'] ?? '';
|
||||
|
||||
$this->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]]);
|
||||
}
|
||||
}
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\MyThoughts;
|
||||
|
||||
use Franzz\Objects\Db;
|
||||
use Franzz\Objects\PhpObject;
|
||||
|
||||
/**
|
||||
* The book itself: one continuous, ordered stream of entries per user.
|
||||
*
|
||||
* Entries are never split into pages here. Where a page break falls depends on
|
||||
* the reader's viewport and font size, so pagination is a client-side layout
|
||||
* concern and the server only ever stores flat text plus the moment it was
|
||||
* started. Ordering is by id_entry: ids are handed out in writing order, which
|
||||
* makes them a stable cursor even when two entries share a timestamp.
|
||||
*/
|
||||
class Journal extends PhpObject {
|
||||
public const ENTRY_TABLE = 'entries';
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
//Entries loaded per request. The book flows, so the client keeps a window
|
||||
//of entries in memory and extends it when the reader turns past its edge.
|
||||
public const CHUNK_SIZE = 40;
|
||||
|
||||
//An entry left open by a browser that never got to send its close (crash,
|
||||
//killed tab, lost network) is sealed on the writer's next visit.
|
||||
private const OPEN_ENTRY_TTL = 60 * 60 * 12;
|
||||
|
||||
private const MAX_CONTENT_LENGTH = 262144; //256 KiB of plain text
|
||||
|
||||
private Db $oDb;
|
||||
private User $oUser;
|
||||
|
||||
public function __construct(Db &$oDb, User &$oUser) {
|
||||
parent::__construct(__CLASS__);
|
||||
$this->oDb = &$oDb;
|
||||
$this->oUser = &$oUser;
|
||||
}
|
||||
|
||||
/* Reading */
|
||||
|
||||
/**
|
||||
* Everything the book needs to render on load: the tail of the stream (what
|
||||
* you were last writing), plus every entry's date for the bookmark rail and
|
||||
* the calendar. Bookmarks stay cheap - id and timestamp only, no content.
|
||||
*/
|
||||
public function getBook(): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
|
||||
$this->sealStaleEntries();
|
||||
|
||||
$asEntries = $this->getEntryWindow();
|
||||
|
||||
return MyThoughts::getJsonResult(true, '', [
|
||||
'entries' => $asEntries,
|
||||
'bookmarks' => $this->getBookmarks(),
|
||||
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
|
||||
'has_newer' => false
|
||||
]);
|
||||
}
|
||||
|
||||
public function getEntries(string $sDirection, int $iCursorId): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
|
||||
$asEntries = match($sDirection) {
|
||||
'before' => $this->getEntryWindow($iCursorId, 'before'),
|
||||
'after' => $this->getEntryWindow($iCursorId, 'after'),
|
||||
'around' => $this->getEntryWindow($iCursorId, 'around'),
|
||||
default => null
|
||||
};
|
||||
|
||||
if($asEntries === null) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
return MyThoughts::getJsonResult(true, '', [
|
||||
'entries' => $asEntries,
|
||||
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
|
||||
'has_newer' => $this->hasNewerThan(end($asEntries)['id'] ?? 0)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a calendar click to a cursor: the first entry written on or after
|
||||
* the given day, falling back to the last entry before it when that day and
|
||||
* everything after it is blank.
|
||||
*/
|
||||
public function getEntryIdAtDate(string $sDate): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
|
||||
$oDate = \DateTime::createFromFormat('Y-m-d', $sDate);
|
||||
if(!$oDate) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
|
||||
$sDate = $oDate->format('Y-m-d');
|
||||
|
||||
$sMidnight = $sDate.' 00:00:00';
|
||||
$iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' >= '], 'ASC');
|
||||
if(!$iEntryId) $iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' < '], 'DESC');
|
||||
if(!$iEntryId) return MyThoughts::getJsonResult(false, 'book.no_entry_yet');
|
||||
|
||||
return MyThoughts::getJsonResult(true, '', ['id' => (int) $iEntryId]);
|
||||
}
|
||||
|
||||
/* Writing */
|
||||
|
||||
/**
|
||||
* Hand back the entry this session should be writing into: the one still
|
||||
* open from a reload a minute ago, or a fresh one stamped with now.
|
||||
*/
|
||||
public function openEntry(): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
|
||||
$this->sealStaleEntries();
|
||||
|
||||
$iEntryId = (int) $this->selectFirstId(['status' => self::STATUS_OPEN], [], 'DESC');
|
||||
|
||||
if($iEntryId <= 0) {
|
||||
$iEntryId = $this->oDb->insertRow(self::ENTRY_TABLE, [
|
||||
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
|
||||
'content' => '',
|
||||
'status' => self::STATUS_OPEN,
|
||||
'started_on' => date(Db::TIMESTAMP_FORMAT),
|
||||
'closed_on' => MyThoughts::ZERO_TIMESTAMP,
|
||||
'timezone' => date_default_timezone_get()
|
||||
]);
|
||||
|
||||
if($iEntryId <= 0) return MyThoughts::getJsonResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return MyThoughts::getJsonResult(true, '', ['entry' => $this->getEntryById($iEntryId)]);
|
||||
}
|
||||
|
||||
public function saveEntry(int $iEntryId, string $sContent): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
$sContent = self::normaliseContent($sContent);
|
||||
|
||||
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, ['content' => $sContent]) === false) {
|
||||
return MyThoughts::getJsonResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return MyThoughts::getJsonResult(true, '', ['id' => $iEntryId, 'saved_at' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal an entry: what was written stays as a journal entry, and an entry
|
||||
* nobody actually wrote in is dropped rather than left as a blank page.
|
||||
*/
|
||||
public function closeEntry(int $iEntryId, string $sContent = '', bool $bHasContent = false): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
$asResult = $this->sealEntry($iEntryId, $bHasContent ? $sContent : null);
|
||||
return MyThoughts::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the explicit close and by the stale-entry sweep, which runs
|
||||
* inside other endpoints and so must not emit a response of its own.
|
||||
*/
|
||||
private function sealEntry(int $iEntryId, ?string $sContent = null): array {
|
||||
$asData = ['status' => self::STATUS_CLOSED, 'closed_on' => date(Db::TIMESTAMP_FORMAT)];
|
||||
|
||||
//The unload beacon carries the last keystrokes the debounced autosave
|
||||
//never got to send, so trust it over what is already stored.
|
||||
if($sContent !== null) $asData['content'] = self::normaliseContent($sContent);
|
||||
|
||||
$sStored = $asData['content'] ?? (string) $this->oDb->selectValue(self::ENTRY_TABLE, 'content', $iEntryId);
|
||||
|
||||
if(trim($sStored) === '') {
|
||||
$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId);
|
||||
return MyThoughts::getResult(true, '', ['id' => $iEntryId, 'discarded' => true]);
|
||||
}
|
||||
|
||||
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, $asData) === false) {
|
||||
return MyThoughts::getResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return MyThoughts::getResult(true, '', ['entry' => $this->getEntryById($iEntryId), 'discarded' => false]);
|
||||
}
|
||||
|
||||
public function deleteEntry(int $iEntryId): string {
|
||||
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
if(!$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId)) return MyThoughts::getJsonResult(false, 'error.commit_db');
|
||||
|
||||
return MyThoughts::getJsonResult(true, 'book.entry_deleted', ['id' => $iEntryId]);
|
||||
}
|
||||
|
||||
/* Internals */
|
||||
|
||||
/**
|
||||
* @param int $iCursorId 0 for the newest window
|
||||
* @param string $sDirection before|after|around, relative to the cursor
|
||||
*/
|
||||
private function getEntryWindow(int $iCursorId = 0, string $sDirection = 'latest'): array {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
|
||||
//"around" is two half-windows so the target entry lands mid-book with
|
||||
//something to read on either side of it.
|
||||
if($sDirection == 'around' && $iCursorId > 0) {
|
||||
$asBefore = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' < '], 'DESC', (int) (self::CHUNK_SIZE / 2));
|
||||
$asFrom = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' >= '], 'ASC', (int) (self::CHUNK_SIZE / 2));
|
||||
return array_merge(array_reverse($asBefore), $asFrom);
|
||||
}
|
||||
|
||||
//selectEntries() scopes every read to the logged-in user, so the window
|
||||
//itself only has to say where in the stream it starts.
|
||||
$asConstraints = [];
|
||||
$asOperators = [];
|
||||
|
||||
if($iCursorId > 0 && $sDirection == 'before') {
|
||||
$asConstraints[$sIdColumn] = $iCursorId;
|
||||
$asOperators[$sIdColumn] = ' < ';
|
||||
}
|
||||
elseif($iCursorId > 0 && $sDirection == 'after') {
|
||||
$asConstraints[$sIdColumn] = $iCursorId;
|
||||
$asOperators[$sIdColumn] = ' > ';
|
||||
}
|
||||
|
||||
//"after" reads forward; everything else reads backward from the cursor
|
||||
//and is flipped, so the caller always gets chronological order.
|
||||
$bForward = ($sDirection == 'after');
|
||||
$asEntries = $this->selectEntries($asConstraints, $asOperators, $bForward ? 'ASC' : 'DESC', self::CHUNK_SIZE);
|
||||
|
||||
return $bForward ? $asEntries : array_reverse($asEntries);
|
||||
}
|
||||
|
||||
private function selectEntries(array $asConstraints, array $asOperators, string $sOrder, int $iLimit): array {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
|
||||
|
||||
$asRows = $this->oDb->selectRows([
|
||||
'select' => [$sIdColumn.' AS id', 'content', 'status', 'timezone', 'UNIX_TIMESTAMP(started_on) AS time', 'UNIX_TIMESTAMP(closed_on) AS closed'],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> $asConstraints,
|
||||
'constOpe' => $asOperators,
|
||||
'orderBy' => [$sIdColumn => $sOrder],
|
||||
'limit' => $iLimit
|
||||
]);
|
||||
|
||||
return array_map([self::class, 'castEntry'], $asRows);
|
||||
}
|
||||
|
||||
private function getEntryById(int $iEntryId): array {
|
||||
$asEntries = $this->selectEntries([Db::getId(self::ENTRY_TABLE) => $iEntryId], [], 'ASC', 1);
|
||||
return $asEntries[0] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookmark rail and the calendar both need every entry's date, and
|
||||
* nothing else - so this deliberately never selects content.
|
||||
*/
|
||||
private function getBookmarks(): array {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
|
||||
$asRows = $this->oDb->selectRows([
|
||||
'select' => [
|
||||
$sIdColumn.' AS id',
|
||||
'UNIX_TIMESTAMP(started_on) AS time',
|
||||
'timezone',
|
||||
'status',
|
||||
'LEFT(content, 60) AS preview'
|
||||
],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> [Db::getId(User::USER_TABLE) => $this->oUser->getUserId()],
|
||||
'orderBy' => [$sIdColumn => 'ASC']
|
||||
]);
|
||||
|
||||
return array_map(static function(array $asRow): array {
|
||||
return [
|
||||
'id' => (int) $asRow['id'],
|
||||
'time' => (int) $asRow['time'],
|
||||
'timezone' => $asRow['timezone'],
|
||||
'status' => $asRow['status'],
|
||||
'preview' => trim(preg_replace('/\s+/u', ' ', $asRow['preview'] ?? ''))
|
||||
];
|
||||
}, $asRows);
|
||||
}
|
||||
|
||||
private function hasOlderThan(int $iEntryId): bool {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' < '], 'DESC') !== false);
|
||||
}
|
||||
|
||||
private function hasNewerThan(int $iEntryId): bool {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' > '], 'ASC') !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false Id of the first matching entry in the given order
|
||||
*/
|
||||
private function selectFirstId(array $asConstraints, array $asOperators, string $sOrder) {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
|
||||
|
||||
$asRows = $this->oDb->selectRows([
|
||||
'select' => [$sIdColumn],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> $asConstraints,
|
||||
'constOpe' => $asOperators,
|
||||
'orderBy' => [$sIdColumn => $sOrder],
|
||||
'limit' => 1
|
||||
]);
|
||||
|
||||
return empty($asRows) ? false : (int) $asRows[0];
|
||||
}
|
||||
|
||||
private function ownsEntry(int $iEntryId): bool {
|
||||
if($iEntryId <= 0) return false;
|
||||
|
||||
$iOwnerId = $this->oDb->selectValue(
|
||||
self::ENTRY_TABLE,
|
||||
Db::getId(User::USER_TABLE),
|
||||
[Db::getId(self::ENTRY_TABLE) => $iEntryId]
|
||||
);
|
||||
|
||||
return ($iOwnerId !== false) && ((int) $iOwnerId === $this->oUser->getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close anything the browser abandoned. Without this a stale open entry
|
||||
* would silently swallow tomorrow's writing into yesterday's timestamp.
|
||||
*/
|
||||
private function sealStaleEntries(): void {
|
||||
$sIdColumn = Db::getId(self::ENTRY_TABLE);
|
||||
|
||||
//Db::updateRows only builds equality constraints, so the cutoff has to
|
||||
//be resolved to a list of ids first.
|
||||
$asStale = $this->oDb->selectRows([
|
||||
'select' => [$sIdColumn],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> [
|
||||
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
|
||||
'status' => self::STATUS_OPEN,
|
||||
'led' => date(Db::TIMESTAMP_FORMAT, time() - self::OPEN_ENTRY_TTL)
|
||||
],
|
||||
'constOpe' => ['led' => ' < ']
|
||||
]);
|
||||
|
||||
foreach($asStale as $iStaleId) $this->sealEntry((int) $iStaleId);
|
||||
|
||||
//Blank pages left behind by earlier sessions - nothing was written, so
|
||||
//they are not entries and should not show up as bookmarks.
|
||||
$asBlank = $this->oDb->selectRows([
|
||||
'select' => [$sIdColumn],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> [
|
||||
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
|
||||
'status' => self::STATUS_CLOSED,
|
||||
'TRIM(content)' => ''
|
||||
]
|
||||
]);
|
||||
|
||||
foreach($asBlank as $iBlankId) $this->oDb->deleteRow(self::ENTRY_TABLE, (int) $iBlankId);
|
||||
}
|
||||
|
||||
private static function castEntry(array $asRow): array {
|
||||
return [
|
||||
'id' => (int) $asRow['id'],
|
||||
'content' => (string) $asRow['content'],
|
||||
'status' => $asRow['status'],
|
||||
'timezone' => $asRow['timezone'],
|
||||
'time' => (int) $asRow['time'],
|
||||
'closed' => (int) $asRow['closed']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text in, plain text out: normalise line endings, strip control
|
||||
* characters the book can never render, and cap the size.
|
||||
*
|
||||
* Tab and newline are the two the page does render - the text column sets a
|
||||
* tab-size and the paginator breaks paragraphs on newlines - so they are
|
||||
* held back from the sweep. Everything else in \p{C} (other controls, zero
|
||||
* width joiners, bidi overrides) would either draw nothing or quietly
|
||||
* corrupt the character offsets the caret is placed with.
|
||||
*/
|
||||
private static function normaliseContent(string $sContent): string {
|
||||
$sContent = str_replace(["\r\n", "\r"], "\n", $sContent);
|
||||
$sContent = preg_replace('/[^\P{C}\n\t]+/u', '', $sContent) ?? '';
|
||||
return mb_substr($sContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\MyThoughts;
|
||||
|
||||
use Franzz\Objects\Db;
|
||||
use Franzz\Objects\Main;
|
||||
use Franzz\Objects\Translator;
|
||||
use Settings;
|
||||
|
||||
/* Timezones
|
||||
* ---------
|
||||
* Every request carries the browser's IANA timezone in `t`, which Main applies
|
||||
* to PHP and to the MySQL session. Timestamps therefore go in and come out in
|
||||
* the reader's own time. On top of that each entry stores the timezone it was
|
||||
* written in, so a journal written across a move or a trip still shows each
|
||||
* page stamped with the local time of the moment it was written - and the
|
||||
* client formats from a UNIX timestamp, never from a preformatted string.
|
||||
*/
|
||||
class MyThoughts extends Main {
|
||||
public const PROJECT_NAME = 'MyThoughts';
|
||||
public const DEFAULT_LANG = 'en';
|
||||
|
||||
//The dictionaries shipped in resources/lang. Listed rather than globbed:
|
||||
//Translator resolves its folder relative to the calling script, and the
|
||||
//settings panel needs the list on a page load either way.
|
||||
public const LANGUAGES = ['en', 'fr'];
|
||||
|
||||
/* "Never happened", for the TIMESTAMP columns declared DEFAULT 0.
|
||||
*
|
||||
* Not date(TIMESTAMP_FORMAT, 0): that formats the epoch in the session's
|
||||
* timezone, which east of Greenwich lands before 1970-01-01 00:00:01 UTC -
|
||||
* below what a MySQL TIMESTAMP can hold - and is rejected outright under
|
||||
* STRICT_TRANS_TABLES. This is the same literal the columns default to. */
|
||||
public const ZERO_TIMESTAMP = '0000-00-00 00:00:00';
|
||||
|
||||
private const MAIN_PAGE = 'index';
|
||||
private const VITE_APP = 'src/app.js';
|
||||
|
||||
private User $oUser;
|
||||
private Journal $oJournal;
|
||||
|
||||
public function __construct($sProcessPage, $sTimezone) {
|
||||
parent::__construct($sProcessPage, true, $sTimezone);
|
||||
|
||||
$this->oUser = new User($this->oDb);
|
||||
$this->oLang = new Translator($this->oUser->getLang(), self::DEFAULT_LANG);
|
||||
$this->oJournal = new Journal($this->oDb, $this->oUser);
|
||||
}
|
||||
|
||||
public function getUser(): User {
|
||||
return $this->oUser;
|
||||
}
|
||||
|
||||
public function getJournal(): Journal {
|
||||
return $this->oJournal;
|
||||
}
|
||||
|
||||
protected function install() {
|
||||
$this->oDb->install();
|
||||
}
|
||||
|
||||
protected function getSqlOptions() {
|
||||
return [
|
||||
'tables' => [
|
||||
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'language', 'timezone', 'clearance'],
|
||||
Journal::ENTRY_TABLE=> [Db::getId(User::USER_TABLE), 'content', 'status', 'started_on', 'closed_on', 'timezone']
|
||||
],
|
||||
'types' => [
|
||||
'name' => 'VARCHAR(100) NOT NULL',
|
||||
'email' => 'VARCHAR(320) NOT NULL',
|
||||
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
'token' => "VARCHAR(64) NOT NULL DEFAULT ''",
|
||||
'token_exp' => 'TIMESTAMP DEFAULT 0',
|
||||
'language' => 'VARCHAR(2)',
|
||||
'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name
|
||||
'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER,
|
||||
'content' => 'LONGTEXT',
|
||||
'status' => 'VARCHAR(10)',
|
||||
'started_on'=> 'TIMESTAMP DEFAULT 0', //DEFAULT 0 removes auto-set to current time
|
||||
'closed_on' => 'TIMESTAMP DEFAULT 0'
|
||||
],
|
||||
'constraints' => [
|
||||
User::USER_TABLE => 'UNIQUE KEY `uni_email` (`email`)',
|
||||
//The book is always read as "this user's entries, in order",
|
||||
//so the reading order is indexed rather than the id alone.
|
||||
Journal::ENTRY_TABLE=> ['INDEX `idx_user_entry` (`id_user`, `id_entry`)', 'INDEX `idx_user_date` (`id_user`, `started_on`)']
|
||||
],
|
||||
//Deliberately no 'cascading_delete': Db cascades by reusing the same
|
||||
//id in the linked table, which would delete unrelated rows here.
|
||||
//The generated foreign keys already guard referential integrity.
|
||||
];
|
||||
}
|
||||
|
||||
/* Pages & API */
|
||||
|
||||
public function getAppMainPage(string $sCsrfToken = ''): string {
|
||||
$asViteAssets = $this->getViteAssets();
|
||||
|
||||
return parent::getMainPage(
|
||||
[
|
||||
'user' => $this->oUser->getUserInfo(),
|
||||
'consts' => [
|
||||
'title' => self::PROJECT_NAME,
|
||||
'languages' => self::LANGUAGES,
|
||||
'chunk_size' => Journal::CHUNK_SIZE,
|
||||
'default_timezone' => Settings::TIMEZONE,
|
||||
'autosave_delay' => 1200, //ms of stillness before a save
|
||||
'autosave_max_wait' => 8000, //ms of continuous typing before a forced save
|
||||
'csrf_token' => $sCsrfToken
|
||||
]
|
||||
],
|
||||
self::MAIN_PAGE,
|
||||
[
|
||||
'tags' => [
|
||||
'language' => $this->oLang->getLanguage(),
|
||||
'title' => self::PROJECT_NAME,
|
||||
'app_entry' => $asViteAssets['app']
|
||||
],
|
||||
'instances' => [
|
||||
'css' => $asViteAssets['css'],
|
||||
'module' => $asViteAssets['module']
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function signup(string $sName, string $sEmail, string $sPassword, string $sTimezone): string {
|
||||
$asResult = $this->oUser->signup($sName, $sEmail, $sPassword, $this->oLang->getLanguage(), $sTimezone);
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
|
||||
}
|
||||
|
||||
public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): string {
|
||||
$asResult = $this->oUser->login($sEmail, $sPassword, $sTimezone, $bRemember);
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
|
||||
}
|
||||
|
||||
public function logout(): string {
|
||||
$asResult = $this->oUser->logout();
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']);
|
||||
}
|
||||
|
||||
public function updateAccount(string $sField, string $sValue): string {
|
||||
$asResult = $this->oUser->updateSettings($sField, $sValue);
|
||||
|
||||
//A language change has to reach the next page load's translations too.
|
||||
if($asResult['result'] && $sField == 'language') $this->oLang->setLanguage($this->oUser->getLang(), self::DEFAULT_LANG);
|
||||
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
|
||||
}
|
||||
|
||||
/* Vite assets */
|
||||
|
||||
private function getViteAssets(): array {
|
||||
$sManifestPath = __DIR__.'/../public/.vite/manifest.json';
|
||||
|
||||
if(!file_exists($sManifestPath)) {
|
||||
$this->addError('Vite manifest not found - run "npm run dev" or "npm run prod" to build the frontend.');
|
||||
return ['app' => '', 'css' => [], 'module' => []];
|
||||
}
|
||||
|
||||
$asManifest = json_decode(file_get_contents($sManifestPath), true);
|
||||
$asAppImport = $asManifest[self::VITE_APP] ?? [];
|
||||
|
||||
//Recursive search for chunk imports
|
||||
$asImports = [];
|
||||
$asSeenImports = [self::VITE_APP => true];
|
||||
$this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports);
|
||||
|
||||
//CSS
|
||||
$asCssFiles = [];
|
||||
foreach(array_merge([$asAppImport], $asImports) as $asChunk) {
|
||||
foreach($asChunk['css'] ?? [] as $sCssFile) $asCssFiles[] = $sCssFile;
|
||||
}
|
||||
|
||||
//Modules
|
||||
$asModuleFiles = [];
|
||||
foreach($asImports as $asImport) {
|
||||
if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file'];
|
||||
}
|
||||
|
||||
return [
|
||||
'app' => $asAppImport['file'] ?? '',
|
||||
'css' => self::getViteAssetInstances($asCssFiles),
|
||||
'module' => self::getViteAssetInstances($asModuleFiles)
|
||||
];
|
||||
}
|
||||
|
||||
private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports): void {
|
||||
foreach($asChunk['imports'] ?? [] as $sImport) {
|
||||
if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue;
|
||||
|
||||
$asSeenImports[$sImport] = true;
|
||||
$this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports);
|
||||
$asImports[] = $asManifest[$sImport];
|
||||
}
|
||||
}
|
||||
|
||||
private static function getViteAssetInstances(array $asFilePaths): array {
|
||||
return array_map(static function($sFilePath) { return ['filename' => $sFilePath]; }, $asFilePaths);
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\MyThoughts;
|
||||
|
||||
use Franzz\Objects\Db;
|
||||
use Franzz\Objects\PhpObject;
|
||||
|
||||
/**
|
||||
* Accounts, sessions and the remember-me cookie.
|
||||
*
|
||||
* Every reader gets their own book: an entry always belongs to exactly one
|
||||
* user, and nothing in Journal is reachable without a resolved user id.
|
||||
*/
|
||||
class User extends PhpObject {
|
||||
public const USER_TABLE = 'users';
|
||||
|
||||
public const CLEARANCE_USER = 0;
|
||||
public const CLEARANCE_ADMIN = 9;
|
||||
|
||||
private const MIN_PASSWORD_LENGTH = 8;
|
||||
private const MAX_NAME_LENGTH = 100;
|
||||
|
||||
public const DEFAULT_USER = [
|
||||
'id' => 0,
|
||||
'name' => '',
|
||||
'email' => '',
|
||||
'language' => '',
|
||||
'timezone' => '',
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
];
|
||||
|
||||
//Session & Cookie
|
||||
private const SESSION_ID_USER = 'id_user';
|
||||
private const COOKIE_TOKEN = 'mythoughts';
|
||||
private const COOKIE_DURATION = 60 * 60 * 24 * 90; //3 months
|
||||
|
||||
private Db $oDb;
|
||||
private int $iUserId = 0;
|
||||
private array $asUserInfo = self::DEFAULT_USER;
|
||||
|
||||
public function __construct(Db &$oDb) {
|
||||
parent::__construct(__CLASS__);
|
||||
$this->oDb = &$oDb;
|
||||
$this->setUserId(0);
|
||||
$this->checkSession();
|
||||
}
|
||||
|
||||
/* Identity */
|
||||
|
||||
public function getUserId(): int {
|
||||
return $this->iUserId;
|
||||
}
|
||||
|
||||
public function isLoggedIn(): bool {
|
||||
return ($this->iUserId > 0);
|
||||
}
|
||||
|
||||
public function getUserInfo(): array {
|
||||
return $this->asUserInfo;
|
||||
}
|
||||
|
||||
public function getLang(): string {
|
||||
return $this->asUserInfo['language'];
|
||||
}
|
||||
|
||||
public function getTimezone(): string {
|
||||
return $this->asUserInfo['timezone'];
|
||||
}
|
||||
|
||||
public function setUserId($iUserId): void {
|
||||
$this->iUserId = 0;
|
||||
$this->asUserInfo = self::DEFAULT_USER;
|
||||
|
||||
if($iUserId > 0) {
|
||||
$asUser = $this->getUserById($iUserId);
|
||||
if(!empty($asUser)) {
|
||||
$this->iUserId = (int) $iUserId;
|
||||
$this->asUserInfo = $asUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserById($iUserId): array {
|
||||
if($iUserId <= 0) return [];
|
||||
|
||||
$asSelect = array_keys(self::DEFAULT_USER);
|
||||
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
|
||||
|
||||
$asUser = $this->oDb->selectRow(self::USER_TABLE, [Db::getId(self::USER_TABLE) => $iUserId], $asSelect);
|
||||
if(empty($asUser)) return [];
|
||||
|
||||
$asUser['id'] = (int) $asUser['id'];
|
||||
$asUser['clearance'] = (int) $asUser['clearance'];
|
||||
return $asUser;
|
||||
}
|
||||
|
||||
/* Sign up & log in */
|
||||
|
||||
public function signup(string $sName, string $sEmail, string $sPassword, string $sLang, string $sTimezone): array {
|
||||
$sEmail = mb_strtolower(trim($sEmail));
|
||||
$sName = mb_substr(trim($sName), 0, self::MAX_NAME_LENGTH);
|
||||
|
||||
if($sName === '') return MyThoughts::getResult(false, 'account.name_required');
|
||||
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) return MyThoughts::getResult(false, 'account.invalid_email');
|
||||
if(mb_strlen($sPassword) < self::MIN_PASSWORD_LENGTH) return MyThoughts::getResult(false, 'account.password_too_short', [], [self::MIN_PASSWORD_LENGTH]);
|
||||
|
||||
//Taken emails must not be distinguishable from a wrong password, so the
|
||||
//message stays the same one login gives - no account enumeration here.
|
||||
if($this->oDb->selectId(self::USER_TABLE, ['email' => $sEmail]) > 0) {
|
||||
return MyThoughts::getResult(false, 'account.invalid_credentials');
|
||||
}
|
||||
|
||||
$iUserId = $this->oDb->insertRow(self::USER_TABLE, [
|
||||
'name' => $sName,
|
||||
'email' => $sEmail,
|
||||
'password' => password_hash($sPassword, PASSWORD_DEFAULT),
|
||||
'language' => $sLang,
|
||||
'timezone' => $sTimezone,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
]);
|
||||
|
||||
if($iUserId <= 0) return MyThoughts::getResult(false, 'error.commit_db');
|
||||
|
||||
$this->openSessionFor($iUserId, true);
|
||||
return MyThoughts::getResult(true, 'account.welcome', ['user' => $this->getUserInfo()]);
|
||||
}
|
||||
|
||||
public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): array {
|
||||
$sEmail = mb_strtolower(trim($sEmail));
|
||||
|
||||
$asDbUser = $this->oDb->selectRow(
|
||||
self::USER_TABLE,
|
||||
['email' => $sEmail],
|
||||
[Db::getId(self::USER_TABLE), 'password', 'timezone']
|
||||
);
|
||||
$iUserId = (int) ($asDbUser[Db::getId(self::USER_TABLE)] ?? 0);
|
||||
|
||||
//password_verify against a dummy hash on unknown emails keeps the
|
||||
//response time of "no such user" and "wrong password" comparable.
|
||||
$sHash = $asDbUser['password'] ?? '';
|
||||
$bValid = ($sHash !== '') ? password_verify($sPassword, $sHash) : password_verify($sPassword, '$2y$10$'.str_repeat('.', 53));
|
||||
|
||||
if($iUserId <= 0 || !$bValid) return MyThoughts::getResult(false, 'account.invalid_credentials');
|
||||
|
||||
if($sTimezone !== '' && $sTimezone !== ($asDbUser['timezone'] ?? '')) {
|
||||
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['timezone' => $sTimezone]);
|
||||
}
|
||||
|
||||
$this->openSessionFor($iUserId, $bRemember);
|
||||
return MyThoughts::getResult(true, 'account.logged_in', ['user' => $this->getUserInfo()]);
|
||||
}
|
||||
|
||||
public function logout(): array {
|
||||
$this->clearTokenCookie();
|
||||
|
||||
$_SESSION = [];
|
||||
if(session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
|
||||
|
||||
$this->setUserId(0);
|
||||
return MyThoughts::getResult(true, 'account.logged_out');
|
||||
}
|
||||
|
||||
public function updateSettings(string $sField, string $sValue): array {
|
||||
if(!$this->isLoggedIn()) return MyThoughts::getResult(false, MyThoughts::UNAUTHORIZED);
|
||||
if(!in_array($sField, ['name', 'language', 'timezone'], true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
$sValue = mb_substr(trim($sValue), 0, self::MAX_NAME_LENGTH);
|
||||
if($sField === 'name' && $sValue === '') return MyThoughts::getResult(false, 'account.name_required');
|
||||
if($sField === 'timezone' && !in_array($sValue, \DateTimeZone::listIdentifiers(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
if(!$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [$sField => $sValue])) {
|
||||
return MyThoughts::getResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
$this->setUserId($this->iUserId);
|
||||
return MyThoughts::getResult(true, 'account.saved', ['user' => $this->getUserInfo()]);
|
||||
}
|
||||
|
||||
/* Session plumbing */
|
||||
|
||||
private function openSessionFor(int $iUserId, bool $bRemember): void {
|
||||
$this->setUserId($iUserId);
|
||||
|
||||
if(session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_ID_USER] = $iUserId;
|
||||
}
|
||||
|
||||
if($bRemember) $this->setTokenCookie();
|
||||
else $this->clearTokenCookie();
|
||||
}
|
||||
|
||||
private function checkSession(): void {
|
||||
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
|
||||
if($iUserId > 0) $this->setUserId($iUserId);
|
||||
else $this->checkTokenCookie();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie holds "<id_user>:<secret>"; only a hash of the secret is stored,
|
||||
* so a dump of the users table cannot be replayed as a login.
|
||||
*/
|
||||
private function checkTokenCookie(): void {
|
||||
$sCookie = $_COOKIE[self::COOKIE_TOKEN] ?? '';
|
||||
if($sCookie === '') return;
|
||||
|
||||
$asParts = explode(':', $sCookie, 2);
|
||||
if(count($asParts) != 2) return;
|
||||
|
||||
$iUserId = (int) $asParts[0];
|
||||
$sSecret = $asParts[1];
|
||||
if($iUserId <= 0 || $sSecret === '') return;
|
||||
|
||||
$asToken = $this->oDb->selectRow(self::USER_TABLE, $iUserId, ['token', 'token_exp']);
|
||||
$sStoredHash = $asToken['token'] ?? '';
|
||||
|
||||
if($sStoredHash === '' || strtotime($asToken['token_exp'] ?? '0') < time()) {
|
||||
$this->clearTokenCookie();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!hash_equals($sStoredHash, hash('sha256', $sSecret))) {
|
||||
$this->clearTokenCookie();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setUserId($iUserId);
|
||||
if(!$this->isLoggedIn()) {
|
||||
$this->clearTokenCookie();
|
||||
return;
|
||||
}
|
||||
|
||||
if(session_status() === PHP_SESSION_ACTIVE) $_SESSION[self::SESSION_ID_USER] = $iUserId;
|
||||
|
||||
//Sliding expiry: an active reader is never logged out mid-journal.
|
||||
$this->setTokenCookie();
|
||||
}
|
||||
|
||||
private function setTokenCookie(): void {
|
||||
if(!$this->isLoggedIn()) return;
|
||||
|
||||
$sSecret = bin2hex(random_bytes(32));
|
||||
$iExpiry = time() + self::COOKIE_DURATION;
|
||||
|
||||
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [
|
||||
'token' => hash('sha256', $sSecret),
|
||||
'token_exp' => date(Db::TIMESTAMP_FORMAT, $iExpiry)
|
||||
]);
|
||||
|
||||
$this->writeCookie($this->iUserId.':'.$sSecret, $iExpiry);
|
||||
}
|
||||
|
||||
private function clearTokenCookie(): void {
|
||||
if($this->isLoggedIn()) {
|
||||
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, ['token' => '', 'token_exp' => MyThoughts::ZERO_TIMESTAMP]);
|
||||
}
|
||||
|
||||
$this->writeCookie('', time() - 3600);
|
||||
unset($_COOKIE[self::COOKIE_TOKEN]);
|
||||
}
|
||||
|
||||
private function writeCookie(string $sValue, int $iExpiry): void {
|
||||
if(PHP_SAPI === 'cli' || headers_sent()) return;
|
||||
|
||||
setcookie(self::COOKIE_TOKEN, $sValue, [
|
||||
'expires' => $iExpiry,
|
||||
'path' => dirname($_SERVER['SCRIPT_NAME'] ?? '/'),
|
||||
'httponly' => true,
|
||||
'secure' => self::isSecureRequest(),
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
}
|
||||
|
||||
public static function isSecureRequest(): bool {
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user