v3 init push

This commit is contained in:
2026-09-03 18:28:33 +02:00
parent f719cb2989
commit 549a7e0fa0
111 changed files with 8736 additions and 4873 deletions
+150
View File
@@ -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
View File
@@ -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);
}
}
+201
View File
@@ -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
View File
@@ -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');
}
}