A tiny self-hostable "microjournaling" web app. Helps fill the void when abstaining from social media by encouraging journaling small thoughts throughout the day. # Requirements - web host with PHP
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500
  1. <?php declare(strict_types=1);
  2. // Copyright © 2023 Ian Albert
  3. //
  4. // Permission is hereby granted, free of charge, to any person obtaining a copy
  5. // of this software and associated documentation files (the “Software”), to deal
  6. // in the Software without restriction, including without limitation the rights
  7. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  8. // copies of the Software, and to permit persons to whom the Software is
  9. // furnished to do so, subject to the following conditions:
  10. //
  11. // The above copyright notice and this permission notice shall be included in
  12. // all copies or substantial portions of the Software.
  13. //
  14. // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  20. // SOFTWARE.
  21. // --------------------------------------------------------
  22. // -- Begin required configuration ------------------------
  23. /// Absolute path to the journal.db file on your web server. File must be
  24. /// writable by the web server user. THIS FILE SHOULD BE SECURED FROM WEB
  25. /// ACCESS! Otherwise someone can download your journal database if they know
  26. /// the default URL. Options:
  27. ///
  28. /// 1. Put journal.db somewhere that isn't under your web directory. (BEST OPTION)
  29. /// 2. Rename journal.db to something randomized, like the output of the
  30. /// `uuidgen` command, so the URL would be too obfuscated to guess.
  31. /// 3. Modify your `.htaccess` to forbid access to .db files. e.g.
  32. /// <Files ~ "\.db$">
  33. /// Order allow,deny
  34. /// Deny from all
  35. /// </Files>
  36. /// This example may or may not work for you. Be sure to test your
  37. /// server configuration!
  38. define('DB_PATH', '/path/to/journal.db');
  39. // URL of this page. You can omit the index.php and just keep the path if you'd
  40. // prefer a cleaner URL.
  41. define('BASE_URL', 'https://example.com/path/index.php');
  42. // -- End required configuration --------------------------
  43. // --------------------------------------------------------
  44. // -- Constants -------------------------------------------
  45. /// For detecting database migrations.
  46. define('SCHEMA_VERSION', '20230107');
  47. define('RECENT_POSTS_PER_PAGE', 50);
  48. define('SECOND', 1);
  49. define('MINUTE', 60 * SECOND);
  50. define('HOUR', 60 * MINUTE);
  51. define('DAY', 24 * HOUR);
  52. define('SESSION_COOKIE_TTL_SECONDS', 14 * DAY);
  53. define('SESSION_COOKIE_NAME', 'journal_token');
  54. /// If a new post matches an existing post created less than this many seconds
  55. /// ago, the new duplicate post will be ignored.
  56. define('DUPLICATE_POST_SECONDS', 10);
  57. define('TIME_ZONE_DEFAULT', 'America/Los_Angeles');
  58. define('TOKEN_BYTES', 16);
  59. define('MARKDOWN_ENABLED', true);
  60. define('SESSION_KEY_ERROR', 'error');
  61. define('SESSION_KEY_LAST_ACCESS', 'last_access');
  62. define('SESSION_KEY_POST_BODY', 'post_body');
  63. define('SESSION_KEY_USER_ID', 'user_id');
  64. // -- Core setup ------------------------------------------
  65. session_start();
  66. $_SESSION[SESSION_KEY_LAST_ACCESS] = time(); // keep alive
  67. function handle_error($error_level = E_WARNING, $error_message = '',
  68. $error_file = '', $error_line = 0, $error_context = null): void {
  69. $lvl = 'WARNING';
  70. switch ($error_level) {
  71. case E_WARNING: case E_USER_WARNING: $lvl = 'WARNING'; break;
  72. case E_ERROR: case E_USER_ERROR: $lvl = 'ERROR'; break;
  73. case E_NOTICE: case E_USER_NOTICE: $lvl = 'NOTICE'; break;
  74. }
  75. error_log("[{$lvl} - {$error_file}:{$error_line}] {$error_message}", 0);
  76. }
  77. set_error_handler('handle_error');
  78. error_reporting(E_ERROR);
  79. /// Logs a debug message.
  80. function trace(string $message): void {
  81. // error_log($message, 0);
  82. }
  83. // -- Localization ----------------------------------------
  84. class L10n {
  85. // General
  86. public const page_title = "Microjournal";
  87. public const mobile_app_icon_title = "μJournal";
  88. // Nav menu
  89. public const hamburger_menu_symbol = "☰";
  90. public const menu_search = "Search";
  91. public const menu_log_out = "Log out";
  92. // Create post form
  93. public const post_form_placeholder = "Your journal post here…";
  94. public const post_form_submit_post = "Post";
  95. public const post_form_submit_update = "Update";
  96. public const post_form_cancel = "Cancel";
  97. // Previous posts
  98. public const posts_previous_page = "Previous";
  99. public const posts_next_page = "Next";
  100. public static function post_created(int $timestamp): string {
  101. $date = self::date_string($timestamp);
  102. $date_full = self::date_string($timestamp, full: true);
  103. $date_iso = self::date_string($timestamp, iso: true);
  104. return "Posted <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>";
  105. }
  106. public static function post_updated(int $timestamp): string {
  107. $date = self::date_string($timestamp);
  108. $date_full = self::date_string($timestamp, full: true);
  109. $date_iso = self::date_string($timestamp, iso: true);
  110. return "(updated <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>)";
  111. }
  112. public const post_menu = "actions";
  113. public const post_menu_edit = "Edit";
  114. public const post_menu_delete = "Delete";
  115. // Search form
  116. public const search_submit = "Search";
  117. public const search_cancel = "Cancel";
  118. // Login form
  119. public const login_username_label = "Username:";
  120. public const login_password_label = "Password:";
  121. public const login_submit = "Log in";
  122. // Create account form
  123. public const create_prompt = "Journal is not setup. Please create a " .
  124. "username and password to secure your data.";
  125. public const create_username_label = "Username:";
  126. public const create_password_label = "Password:";
  127. public const create_time_zone_label = "Time zone:";
  128. public const create_submit = "Create account";
  129. // Error messages
  130. public const error_login_incorrect = "Login incorrect.";
  131. public const error_bad_sql_datatype = "Bad datatype in sqlite statement.";
  132. public const error_not_configured = "Journal not configured. Open " .
  133. "index.php in an editor and configure the variables at the top of " .
  134. "the file.";
  135. public const error_database_not_found = "Database file cannot be found. " .
  136. "Check configuration at the top of index.php.";
  137. public static function error_database_not_writable(
  138. string $user, string $group): string {
  139. return "Database file exists but is not writable by web server " .
  140. "user '{$user}' (user group '{$group}'). Check file permissions.";
  141. }
  142. public const error_no_schema_version = "No schema version in database. " .
  143. "Corrupted?";
  144. public static function error_unexpected_schema_version(
  145. string $schema_version): string {
  146. return "Unexpected schema version {$schema_version}.";
  147. }
  148. public const error_database_web_accessible = "Journal database is " .
  149. "accessible from the web! Either alter your .htaccess permissions " .
  150. "or rename the database with a UUID filename.";
  151. public static function error_parameter_required(string $name): string {
  152. return "Parameter {$name} is required.";
  153. }
  154. public static function error_parameter_empty(string $name): string {
  155. return "Parameter {$name} cannot be empty.";
  156. }
  157. public static function error_parameter_integer(string $name): string {
  158. return "Parameter {$name} must be integer.";
  159. }
  160. public const error_invalid_username = "Invalid username. Can be letters, " .
  161. "numbers, underscores, and dashes, or an email address.";
  162. public const error_sign_in_to_post = "Please sign in to post.";
  163. public const error_sign_in_to_edit = "Please sign in to edit.";
  164. public static function date_string(int $timestamp, bool $full=false, bool $iso=false): string {
  165. static $fmtTime = null;
  166. static $fmtDayOfWeek = null;
  167. static $fmtMonthDay = null;
  168. static $fmtLong = null;
  169. static $fmtFull = null;
  170. static $fmtIso = null;
  171. $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
  172. $timezone = User::$current->timezone;
  173. if (!$fmtTime) {
  174. $fmtTime = IntlDateFormatter::create(
  175. locale: $locale_code,
  176. dateType: IntlDateFormatter::NONE,
  177. timeType: IntlDateFormatter::MEDIUM,
  178. timezone: $timezone);
  179. $fmtDayOfWeek = IntlDateFormatter::create(
  180. locale: $locale_code,
  181. dateType: IntlDateFormatter::FULL,
  182. timeType: IntlDateFormatter::NONE,
  183. timezone: $timezone,
  184. pattern: "cccc");
  185. $fmtMonthDay = IntlDateFormatter::create(
  186. locale: $locale_code,
  187. dateType: IntlDateFormatter::MEDIUM,
  188. timeType: IntlDateFormatter::NONE,
  189. timezone: $timezone,
  190. pattern: "MMMM d");
  191. $fmtLong = IntlDateFormatter::create(
  192. locale: $locale_code,
  193. dateType: IntlDateFormatter::MEDIUM,
  194. timeType: IntlDateFormatter::MEDIUM,
  195. timezone: $timezone);
  196. $fmtFull = IntlDateFormatter::create(
  197. locale: $locale_code,
  198. dateType: IntlDateFormatter::FULL,
  199. timeType: IntlDateFormatter::FULL,
  200. timezone: $timezone);
  201. $fmtIso = IntlDateFormatter::create(
  202. locale: $locale_code,
  203. dateType: IntlDateFormatter::FULL,
  204. timeType: IntlDateFormatter::FULL,
  205. timezone: $timezone,
  206. pattern: "yyyy-MM-dd'T'HH:mm:ssxxx");
  207. }
  208. $now = time();
  209. if ($full) {
  210. return $fmtFull->format($timestamp);
  211. }
  212. if ($iso) {
  213. return $fmtIso->format($timestamp);
  214. }
  215. if ($timestamp > $now) { // future
  216. return $fmtLong->format($timestamp);
  217. }
  218. if ($now - $timestamp < 12 * HOUR) {
  219. return $fmtTime->format($timestamp);
  220. }
  221. if ($now - $timestamp < 4 * DAY) {
  222. return $fmtDayOfWeek->format($timestamp) . ', ' . $time->format($timestamp);
  223. }
  224. if ($now - $timestamp < 180 * DAY) {
  225. return $fmtMonthDay->format($timestamp) . ', ' . $time->format($timestamp);
  226. }
  227. return $fmtLong->format($timestamp);
  228. }
  229. }
  230. // -- Data classes ----------------------------------------
  231. /// Represents a journal post.
  232. class Post {
  233. public ?int $post_id;
  234. public string $body;
  235. public int $author_id;
  236. public int $created;
  237. public ?int $updated;
  238. function __construct(array $row) {
  239. $this->post_id = $row['rowid'];
  240. $this->body = $row['body'];
  241. $this->author_id = $row['author_id'];
  242. $this->created = $row['created'];
  243. $this->updated = $row['updated'];
  244. }
  245. /// Normalizes the body of a post.
  246. private static function normalize_body(string $body): string {
  247. $s = $body;
  248. $s = str_replace("\r\n", "\n", $s); // CRLF -> LF
  249. $s = trim($s);
  250. return $s;
  251. }
  252. /// Creates a new post.
  253. /// @param string $body Text content of the journal entry.
  254. /// @param int $author_id User ID of the post author.
  255. /// @param int $created Unix timestamp of when the post was created.
  256. public static function create(
  257. string $body,
  258. int $author_id,
  259. int $created): void {
  260. trace("Creating a post \"{$body}\"");
  261. $body = self::normalize_body($body);
  262. if (self::recent_duplicate_exists($body, $author_id, $created)) {
  263. trace("Same post already created recently. Skipping.");
  264. return;
  265. }
  266. $sql = 'INSERT INTO posts (body, author_id, created) VALUES ' .
  267. '(:body, :author_id, :created);';
  268. $args = array(
  269. ':body' => $body,
  270. ':author_id' => $author_id,
  271. ':created' => $created,
  272. );
  273. Database::query($sql, $args);
  274. }
  275. /// Tests if an identical post was created recently. For preventing double
  276. /// submits.
  277. /// @param string $body Text content of the journal entry.
  278. /// @param int $author_id User ID of the post author.
  279. /// @param int $created Unix timestamp of when the post was created.
  280. /// @return bool True if a similar recent post exists, false if not.
  281. public static function recent_duplicate_exists(
  282. string $body,
  283. int $author_id,
  284. int $created): bool {
  285. $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id ' .
  286. 'AND ABS(created - :created) < :seconds LIMIT 1;';
  287. $args = array(
  288. ':body' => $body,
  289. ':author_id' => $author_id,
  290. ':created' => $created,
  291. ':seconds' => DUPLICATE_POST_SECONDS,
  292. );
  293. return sizeof(Database::query($sql, $args)) > 0;
  294. }
  295. /// Fetches existing posts, newest first.
  296. /// @param int $user_id User ID of author to fetch posts for.
  297. /// @param int $count Maximum number of posts to return.
  298. /// @param ?int $before_time If provided, only posts older than this Unix
  299. /// timestamp are returned.
  300. /// @return array Three-element tuple - 0: array of Posts,
  301. /// 1: relative URL to show previous page, or null
  302. /// 2: relative URL to show next page, or null
  303. public static function get_posts(
  304. int $user_id,
  305. int $count = RECENT_POSTS_PER_PAGE,
  306. ?string $query = null,
  307. ?int $before_time = null): array {
  308. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id';
  309. $args = array(
  310. ':author_id' => $user_id,
  311. ':count' => $count + 1, // to see if there's another page
  312. );
  313. if ($before_time) {
  314. $sql .= ' AND created < :before_time';
  315. $args[':before_time'] = $before_time;
  316. }
  317. $search_where = '';
  318. if ($query) {
  319. foreach (explode(' ', $query) as $i => $term) {
  320. if (strlen($term) == 0) continue;
  321. $symbol = ":wordpattern{$i}";
  322. $search_where .= " AND body LIKE $symbol ESCAPE '!'";
  323. $args[$symbol] = '%' . Database::escape_like($term, '!') . '%';
  324. }
  325. $sql .= $search_where;
  326. }
  327. $sql .= ' ORDER BY created DESC LIMIT :count;';
  328. $posts = Database::query_objects('Post', $sql, $args);
  329. $prev_page = null;
  330. $next_page = null;
  331. if (sizeof($posts) > $count) {
  332. $posts = array_slice($posts, 0, $count);
  333. $next_page = '?before=' . $posts[array_key_last($posts)]->created;
  334. }
  335. if ($before_time) {
  336. // We're paged forward. Check if there are newer posts.
  337. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
  338. 'created >= :before_time ' . $search_where .
  339. ' ORDER BY created ASC LIMIT :count;';
  340. // Reusing same $args
  341. $newer_posts = Database::query_objects('Post', $sql, $args);
  342. if (sizeof($newer_posts) > $count) {
  343. $prev_date = $newer_posts[array_key_last($newer_posts)]->created;
  344. $prev_page = '?before=' . $prev_date;
  345. } else {
  346. $prev_page = BASE_URL;
  347. }
  348. }
  349. if ($query) {
  350. if ($prev_page) {
  351. $prev_page .= (str_contains($prev_page, '?') ? '&' : '?') .
  352. 'search=' . urlencode($query);
  353. }
  354. if ($next_page) {
  355. $next_page .= (str_contains($next_page, '?') ? '&' : '?') .
  356. 'search=' . urlencode($query);
  357. }
  358. }
  359. return array($posts, $prev_page, $next_page);
  360. }
  361. /// Fetches a post by its post ID.
  362. /// @param int $post_id ID of the post.
  363. /// @return ?Post The Post, or null if not found.
  364. public static function get_by_id(int $post_id): ?Post {
  365. $sql = 'SELECT rowid, * FROM posts WHERE rowid=:post_id;';
  366. $args = array(':post_id' => $post_id);
  367. return Database::query_object('Post', $sql, $args);
  368. }
  369. /// Deletes this post.
  370. public function delete(): void {
  371. $sql = 'DELETE FROM posts WHERE rowid=:post_id;';
  372. $args = array(':post_id' => $this->post_id);
  373. Database::query($sql, $args);
  374. $this->post_id = null;
  375. }
  376. /// Update text of post.
  377. public function update(string $new_body): void {
  378. $new_body = self::normalize_body($new_body);
  379. $sql = 'UPDATE posts SET body=:body, updated=:updated WHERE rowid=:rowid;';
  380. $args = array(
  381. ':body' => $new_body,
  382. ':updated' => time(),
  383. ':rowid' => $this->post_id,
  384. );
  385. Database::query($sql, $args);
  386. }
  387. }
  388. /// Represents a user.
  389. class User {
  390. public static ?User $current = null;
  391. public int $user_id;
  392. public string $username;
  393. public string $password_hash;
  394. public string $timezone;
  395. function __construct(array $row) {
  396. $this->user_id = $row['user_id'];
  397. $this->username = $row['username'];
  398. $this->password_hash = $row['password_hash'];
  399. $this->timezone = $row['timezone'];
  400. }
  401. /// Tests if any users exist in the database.
  402. public static function any_exist(): bool {
  403. static $has_any = null;
  404. if ($has_any !== null) return $has_any;
  405. $rows = Database::query('SELECT * FROM users;');
  406. $has_any = sizeof($rows) > 0;
  407. return $has_any;
  408. }
  409. /// Fetches a user by their user ID.
  410. public static function get_by_id(int $user_id): ?User {
  411. $sql = 'SELECT * FROM users WHERE user_id=:user_id LIMIT 1;';
  412. $args = array(':user_id' => $user_id);
  413. return Database::query_object('User', $sql, $args);
  414. }
  415. /// Fetches a user by their username.
  416. public static function get_by_username(string $username): ?User {
  417. $sql = 'SELECT * FROM users WHERE username=:username LIMIT 1;';
  418. $args = array(':username' => $username);
  419. return Database::query_object('User', $sql, $args);
  420. }
  421. /// Creates a new user.
  422. ///
  423. /// @param string $username Username
  424. /// @param string $password Password for the user
  425. /// @param string $timezone User's preferred time zone
  426. public static function create(
  427. string $username,
  428. string $password,
  429. string $timezone): void {
  430. trace("Creating user {$username}");
  431. $sql = 'INSERT OR IGNORE INTO users (username, password_hash, timezone) VALUES (:username, :password_hash, :timezone);';
  432. $args = array(
  433. ':username' => $username,
  434. ':password_hash' => password_hash($password, PASSWORD_DEFAULT),
  435. ':timezone' => $timezone,
  436. );
  437. Database::query($sql, $args);
  438. }
  439. /// Signs in an existing user and returns their User object, or null if
  440. /// sign in failed. Sets $_SESSION if successful.
  441. ///
  442. /// @param string $username User's username
  443. /// @param string $password User's password
  444. /// @return User object if sign in successful, otherwise null
  445. public static function sign_in(string $username, string $password): ?User {
  446. $user = self::get_by_username($username);
  447. if ($user === null) {
  448. trace("Login failed. No such user {$username}.");
  449. fatal_error(L10n::error_login_incorrect);
  450. return null;
  451. }
  452. if (!password_verify($password, $user->password_hash)) {
  453. trace("Login failed. Bad password for {$username}.");
  454. fatal_error(L10n::error_login_incorrect);
  455. return null;
  456. }
  457. trace("Login succeeded for {$username}.");
  458. $_SESSION[SESSION_KEY_USER_ID] = $user->user_id;
  459. self::$current = $user;
  460. Session::create($user->user_id);
  461. return $user;
  462. }
  463. /// Updates self::$current from $_SESSION.
  464. public static function update_current(): void {
  465. if (self::any_exist() &&
  466. array_key_exists(SESSION_KEY_USER_ID, $_SESSION) &&
  467. is_int($_SESSION[SESSION_KEY_USER_ID])) {
  468. $id = $_SESSION[SESSION_KEY_USER_ID];
  469. self::$current = User::get_by_id($id);
  470. } else {
  471. self::$current = null;
  472. }
  473. }
  474. public static function sign_out(): void {
  475. self::$current = null;
  476. Session::$current?->delete();
  477. unset($_SESSION[SESSION_KEY_USER_ID]);
  478. }
  479. }
  480. class Session {
  481. public static ?Session $current = null;
  482. public int $rowid;
  483. public string $token;
  484. public int $user_id;
  485. public int $created;
  486. public int $updated;
  487. function __construct(array $row) {
  488. $this->rowid = $row['rowid'];
  489. $this->token = $row['token'];
  490. $this->user_id = $row['user_id'];
  491. $this->created = $row['created'];
  492. $this->updated = $row['updated'];
  493. }
  494. /// Logs in from the journal token cookie if needed.
  495. public static function check_cookie(): void {
  496. if (User::$current) return;
  497. if (!array_key_exists(SESSION_COOKIE_NAME, $_COOKIE)) return;
  498. $token = $_COOKIE[SESSION_COOKIE_NAME];
  499. trace("Found token cookie");
  500. self::$current = self::get_by_token($token);
  501. if (!self::$current) return;
  502. $user = User::get_by_id(self::$current->user_id);
  503. if (!$user) {
  504. self::$current->delete();
  505. return;
  506. }
  507. trace("Found user from cookie");
  508. User::$current = $user;
  509. $_SESSION[SESSION_KEY_USER_ID] = $user->user_id;
  510. self::$current->touch();
  511. }
  512. public static function create(int $user_id): Session {
  513. $token = bin2hex(random_bytes(TOKEN_BYTES));
  514. $time = intval(time());
  515. $sql = 'INSERT INTO sessions (token, user_id, created, updated) ' .
  516. 'VALUES (:token, :user_id, :created, :updated);';
  517. $args = array(
  518. ':token' => $token,
  519. ':user_id' => $user_id,
  520. ':created' => $time,
  521. ':updated' => $time,
  522. );
  523. Database::query($sql, $args);
  524. $session = self::get_by_token($token);
  525. $session->touch(false);
  526. return $session;
  527. }
  528. public function touch(bool $update_table = true): void {
  529. $this->updated = time();
  530. $secure = str_starts_with(BASE_URL, 'https:');
  531. $expires = intval(time()) + SESSION_COOKIE_TTL_SECONDS;
  532. trace('Updating cookie to ' . L10n::date_string($expires));
  533. setcookie(SESSION_COOKIE_NAME, $this->token, $expires, path: '/',
  534. secure: $secure, httponly: true);
  535. if ($update_table) {
  536. $sql = 'UPDATE sessions SET updated=:updated WHERE rowid=:rowid;';
  537. $args = array(
  538. ':updated' => $this->updated,
  539. ':rowid' => $this->rowid,
  540. );
  541. Database::query($sql, $args);
  542. }
  543. }
  544. public static function get_by_token(string $token): ?Session {
  545. $sql = 'SELECT rowid, * FROM sessions WHERE token=:token LIMIT 1';
  546. $args = array(':token' => $token);
  547. return Database::query_object('Session', $sql, $args);
  548. }
  549. public static function get_by_user_id(int $user_id): array {
  550. $sql = 'SELECT rowid, * FROM sessions WHERE user_id=:user_id;';
  551. $args = array(':user_id' => $user_id);
  552. return Database::query_objects('Session', $sql, $args);
  553. }
  554. public static function delete_all_for_user(int $user_id): void {
  555. $sql = 'DELETE FROM sessions WHERE user_id=:user_id;';
  556. $args = array(':user_id' => $user_id);
  557. Database::query($sql, $args);
  558. }
  559. public function delete(): void {
  560. $sql = 'DELETE FROM sessions WHERE rowid=:rowid;';
  561. $args = array(':rowid' => $this->rowid);
  562. Database::query($sql, $args);
  563. $secure = str_starts_with(BASE_URL, 'https:');
  564. setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure,
  565. httponly: true);
  566. unset($_COOKIE[SESSION_COOKIE_NAME]);
  567. }
  568. }
  569. // -- Utility classes -------------------------------------
  570. /// Performs database operations.
  571. class Database {
  572. /// Tests if the configured database is available.
  573. public static function exists(): bool {
  574. return file_exists(DB_PATH);
  575. }
  576. /// Tests if the configured database is writable.
  577. public static function is_writable(): bool {
  578. return is_writable(DB_PATH);
  579. }
  580. /// Tests if the database file is accessible externally by a predictable
  581. /// URL. If it is, the user should be warned so they can fix its permissions.
  582. public static function is_accessible_by_web(): bool {
  583. if (!str_ends_with(DB_PATH, '/journal.db')) return false;
  584. $ctx = stream_context_create(array(
  585. 'http' => array(
  586. 'timeout' => 1, // shouldn't take long to reach own server
  587. )
  588. ));
  589. $journal_url = strip_filename(BASE_URL);
  590. if (!str_ends_with($journal_url, '/')) $journal_url .= '/';
  591. $journal_url .= 'journal.db';
  592. trace("Testing external accessibility of journal URL. This may take a second.");
  593. return (file_get_contents($journal_url, false, $ctx) !== false);
  594. }
  595. /// Performs an SQL query and returns the first row as an object of the
  596. /// given class. The given class must accept a row array as its only
  597. /// constructor argument. Adding a "LIMIT 1" clause to the query may help
  598. /// performance, since only the first result is used.
  599. ///
  600. /// @param $classname Class name of the class to use for each result row.
  601. /// @param $sql Query to execute.
  602. /// @param $params Map of SQL placeholders to values.
  603. /// @return ?object Object of given class from first result row, or null
  604. /// if no rows matched.
  605. public static function query_object(
  606. string $classname,
  607. string $sql,
  608. array $params = array()): ?object {
  609. $objs = self::query_objects($classname, $sql, $params);
  610. return (sizeof($objs) > 0) ? $objs[0] : null;
  611. }
  612. /// Performs an SQL query and returns the result set as an array of objects
  613. /// of the given class. The given class must accept a row array as its only
  614. /// constructor argument.
  615. ///
  616. /// @param $classname Class name of the class to use for each result row.
  617. /// @param $sql Query to execute.
  618. /// @param $params Map of SQL placeholders to values.
  619. /// @return array Array of records of the given class.
  620. public static function query_objects(
  621. string $classname,
  622. string $sql,
  623. array $params = array()): array {
  624. $rows = self::query($sql, $params);
  625. $objs = array();
  626. if ($rows !== false) {
  627. foreach ($rows as $row) {
  628. $objs[] = new $classname($row);
  629. }
  630. }
  631. return $objs;
  632. }
  633. /// Runs a query and returns the complete result. Select queries will return
  634. /// all rows as an array of row arrays. Insert/update/delete queries will
  635. /// return a boolean of success.
  636. ///
  637. /// @param $sql Query to execute.
  638. /// @param $params Map of SQL placeholders to values.
  639. /// @return array Array of arrays. The inner arrays contain both indexed
  640. /// and named column values.
  641. public static function query(
  642. string $sql,
  643. array $params = array()): bool|array {
  644. $db = new SQLite3(DB_PATH);
  645. trace('SQL: ' . $sql);
  646. $stmt = $db->prepare($sql);
  647. foreach ($params as $name => $value) {
  648. $type = self::sqlite_type($value);
  649. $stmt->bindValue($name, $value, $type);
  650. trace("\tbind {$name} => {$value} ({$type})");
  651. }
  652. $result = $stmt->execute();
  653. if (gettype($result) == 'bool') {
  654. return $result;
  655. }
  656. $rows = array();
  657. // XXX: Make sure it's a query with results, otherwise fetchArray will
  658. // cause non-selects (e.g. INSERT) to be executed again.
  659. if ($result->numColumns()) {
  660. while ($row = $result->fetchArray()) {
  661. $rows[] = $row;
  662. }
  663. }
  664. $stmt->close();
  665. $db->close();
  666. return $rows;
  667. }
  668. /// Returns the closest SQLite3 datatype for the given PHP value.
  669. private static function sqlite_type($value): int {
  670. switch (gettype($value)) {
  671. case 'boolean':
  672. case 'integer':
  673. return SQLITE3_INTEGER;
  674. case 'double':
  675. return SQLITE3_FLOAT;
  676. case 'string':
  677. return SQLITE3_TEXT;
  678. case 'NULL':
  679. return SQLITE3_NULL;
  680. default:
  681. fatal_error(L10n::error_bad_sql_datatype);
  682. }
  683. }
  684. /// Escapes a string for use in a LIKE clause.
  685. /// @param string $value String to escape.
  686. /// @param string $escape Character to use for escaping. Default is !
  687. /// @return string Escaped string
  688. public static function escape_like(string $value, string $escape='!'): string {
  689. $s = $value;
  690. $s = str_replace($escape, $escape . $escape, $s); // escape char
  691. $s = str_replace('%', $escape . '%', $s); // any chars
  692. $s = str_replace('_', $escape . '_', $s); // one char
  693. return $s;
  694. }
  695. }
  696. /// App configuration management. All config is stored in the 'config' table in
  697. /// the sqlite database.
  698. class Config {
  699. public static function get_is_configured(): bool {
  700. return self::get_config_value('is_configured', 'bool') ?: false;
  701. }
  702. public static function set_is_configured(bool $is_configured): void {
  703. self::set_config_value('is_configured', $is_configured);
  704. }
  705. public static function get_schema_version(): ?string {
  706. return self::get_config_value('schema_version', 'string');
  707. }
  708. public static function set_schema_version(string $schema_version): void {
  709. self::set_config_value('schema_version', $schema_version);
  710. }
  711. /// Fetches a config value from the database, or null if not found.
  712. private static function get_config_value(string $name, string $type = 'any') {
  713. $sql = 'SELECT value FROM config WHERE name=:name;';
  714. $params = array(':name' => $name);
  715. $rows = Database::query($sql, $params);
  716. if (sizeof($rows) == 0) return null;
  717. $value = $rows[0]['value'];
  718. if ($type != 'any') {
  719. switch ($type) {
  720. case 'string': $value = strval($value); break;
  721. case 'int': $value = intval($value); break;
  722. case 'bool': $value = boolval($value); break;
  723. }
  724. }
  725. return $value;
  726. }
  727. /// Saves a config value to the database.
  728. private static function set_config_value(string $name, $value): void {
  729. $args = array(':name' => $name, ':value' => $value);
  730. Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
  731. Database::query('INSERT OR IGNORE INTO config (name, value) ' .
  732. 'VALUES (:name, :value);', $args);
  733. }
  734. }
  735. // -- Misc utility functions ------------------------------
  736. /// Checks whether the app is setup correctly.
  737. function check_setup(): void {
  738. // Check user-configurable variables to make sure they aren't default values.
  739. if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
  740. fatal_error(L10n::error_not_configured);
  741. }
  742. if (!Database::exists()) {
  743. fatal_error(L10n::error_database_not_found);
  744. }
  745. if (!Database::is_writable()) {
  746. $user = trim(shell_exec('whoami'));
  747. $group = trim(shell_exec('id -gn'));
  748. fatal_error(L10n::error_database_not_writable($user, $group));
  749. }
  750. $schema_version = Config::get_schema_version();
  751. if ($schema_version === null) {
  752. fatal_error(L10n::error_no_schema_version);
  753. }
  754. if ($schema_version != SCHEMA_VERSION) {
  755. // TODO: If schema changes, migration paths will go here. For now just fail.
  756. fatal_error(L10n::error_unexpected_schema_version($schema_version));
  757. }
  758. if (Config::get_is_configured()) {
  759. // Already checked the more expensive tests below, so skip.
  760. return;
  761. }
  762. // If using default database name, make sure it's not accessible from the
  763. // web. It'd be far too easy to access.
  764. if (Database::is_accessible_by_web()) {
  765. fatal_error(L10n::error_database_web_accessible);
  766. }
  767. // Everything looks good!
  768. Config::set_is_configured(true);
  769. }
  770. /// Shows the user an error message and terminates the script. If called from
  771. /// a non-GET request, will first redirec to the main page then display the
  772. /// message.
  773. function fatal_error(string $message): never {
  774. if ($_SERVER['REQUEST_METHOD'] != 'GET') {
  775. $_SESSION[SESSION_KEY_ERROR] = $message;
  776. HTMLPage::redirect_home();
  777. }
  778. HTMLPage::render_page_start();
  779. HTMLPage::render_error($message);
  780. HTMLPage::render_page_end();
  781. exit();
  782. }
  783. /// Returns a path or URL without the filename. Result includes trailing /.
  784. function strip_filename(string $path): string {
  785. $start = 0;
  786. if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
  787. $start = 8;
  788. }
  789. $slash = strrpos($path, '/', $start);
  790. if ($slash === false) return $path + '/';
  791. return substr($path, 0, $slash + 1);
  792. }
  793. define('INPUT_TYPE_NONEMPTY', 0x100);
  794. define('INPUT_TYPE_TRIMMED', 0x200);
  795. define('INPUT_TYPE_INT', 0x1 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
  796. define('INPUT_TYPE_STRING', 0x2);
  797. define('INPUT_TYPE_USERNAME', 0x4 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
  798. /// Validates and casts a form input.
  799. /// @param array $source Source array, like $_GET or $_POST.
  800. /// @param string $name Form input variable name.
  801. /// @param int $type Bit mask of INPUT_TYPE_ constraints.
  802. /// @param bool $required Whether the value must be present. Will throw error
  803. /// if value is required and not found.
  804. /// @return Cast value, or null if not required and found
  805. function validate(array $source, string $name, int $type, bool $required = true) {
  806. if (!array_key_exists($name, $source)) {
  807. if ($required) {
  808. fatal_error(L10n::error_parameter_required($name));
  809. }
  810. return null;
  811. }
  812. $val = $source[$name];
  813. if ($type & INPUT_TYPE_TRIMMED) {
  814. $val = trim($val);
  815. }
  816. if (strlen($val) == 0) {
  817. if ($type & INPUT_TYPE_NONEMPTY) {
  818. fatal_error(L10n::error_parameter_empty($name));
  819. }
  820. }
  821. if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
  822. if (is_numeric($val)) return intval($val);
  823. fatal_error(L10n::error_parameter_integer($name));
  824. }
  825. if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
  826. return $val;
  827. }
  828. if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
  829. if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
  830. fatal_error(L10n::error_invalid_username);
  831. }
  832. return null;
  833. }
  834. // -- HTML renderers --------------------------------------
  835. class HTMLPage {
  836. public static function render_page_start(): void {
  837. $str_title = L10n::page_title;
  838. $str_app_icon_title = L10n::mobile_app_icon_title;
  839. print(<<<HTML
  840. <!DOCTYPE html>
  841. <html>
  842. <head>
  843. <title>{$str_title}</title>
  844. <meta charset="UTF-8" />
  845. <link rel="stylesheet" type="text/css" href="journal.css" />
  846. <link rel="apple-touch-icon" sizes="58x58" href="apple-touch-icon-58.png" />
  847. <link rel="apple-touch-icon" sizes="76x76" href="apple-touch-icon-76.png" />
  848. <link rel="apple-touch-icon" sizes="80x80" href="apple-touch-icon-80.png" />
  849. <link rel="apple-touch-icon" sizes="87x87" href="apple-touch-icon-87.png" />
  850. <link rel="apple-touch-icon" sizes="114x114" href="apple-touch-icon-114.png" />
  851. <link rel="apple-touch-icon" sizes="120x120" href="apple-touch-icon-120.png" />
  852. <link rel="apple-touch-icon" sizes="152x152" href="apple-touch-icon-152.png" />
  853. <link rel="apple-touch-icon" sizes="167x167" href="apple-touch-icon-167.png" />
  854. <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180.png" />
  855. <meta name="apple-mobile-web-app-title" content="{$str_app_icon_title}" />
  856. <meta name="apple-mobile-web-app-capable" content="yes" />
  857. <meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)" />
  858. <meta name="theme-color" content="#444" media="(prefers-color-scheme: dark)" />
  859. <meta name="viewport" content="user-scalable=no, width=device-width, viewport-fit=cover, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" />
  860. <link rel="icon" sizes="16x16" type="image/png" href="favicon-16.png" />
  861. <link rel="icon" sizes="32x32" type="image/png" href="favicon-32.png" />
  862. </head>
  863. <body>
  864. <nav>
  865. <div class="top-nav">
  866. <span class="title">{$str_title}</span>
  867. HTML
  868. );
  869. if (User::$current) {
  870. $str_hamburger = L10n::hamburger_menu_symbol;
  871. $str_search = L10n::menu_search;
  872. $str_log_out = L10n::menu_log_out;
  873. print(<<<HTML
  874. <div class="menu-container">
  875. <details class="menu">
  876. <summary class="no-indicator menu-button"><span>{$str_hamburger}</span></summary>
  877. <ul>
  878. <li><a href="?search">{$str_search}</a></li>
  879. <li class="menu-divider"></li>
  880. <li class="logout-item destructive"><a href="?logout">{$str_log_out}</a></li>
  881. </ul>
  882. </details>
  883. </div>
  884. HTML
  885. );
  886. }
  887. print(<<<HTML
  888. </div>
  889. </nav>
  890. <div class="content">
  891. HTML
  892. );
  893. }
  894. public static function render_page_end(): void {
  895. print(<<<HTML
  896. </div>
  897. </body>
  898. </html>
  899. HTML
  900. );
  901. }
  902. public static function render_error_if_needed(): void {
  903. if (array_key_exists(SESSION_KEY_ERROR, $_SESSION)) {
  904. $e = $_SESSION[SESSION_KEY_ERROR];
  905. unset($_SESSION[SESSION_KEY_ERROR]);
  906. self::render_error($e);
  907. }
  908. }
  909. public static function render_error(string $message): void {
  910. print("<div class=\"error\">" . htmlentities($message) . "</div>\n");
  911. }
  912. public static function render_post_form(): void {
  913. $action = 'post';
  914. $str_submit = L10n::post_form_submit_post;
  915. $body = '';
  916. if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
  917. if ($post = Post::get_by_id($edit_id)) {
  918. $body = $post->body;
  919. $action = 'edit';
  920. $str_submit = L10n::post_form_submit_update;
  921. } else {
  922. unset($edit_id);
  923. }
  924. } elseif (array_key_exists(SESSION_KEY_POST_BODY, $_SESSION)) {
  925. $body = $_SESSION[SESSION_KEY_POST_BODY];
  926. unset($_SESSION[SESSION_KEY_POST_BODY]);
  927. }
  928. $body_html = htmlentities($body);
  929. $str_placeholder = L10n::post_form_placeholder;
  930. print(<<<HTML
  931. <form id="post-form" method="POST">
  932. <div class="text-container"><textarea name="body" placeholder="$str_placeholder">$body_html</textarea></div>
  933. <input type="hidden" name="action" value="{$action}" />
  934. HTML
  935. );
  936. if ($edit_id) {
  937. print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
  938. }
  939. print("<div class=\"submit-post form-buttons\">");
  940. if ($edit_id) {
  941. $url = BASE_URL;
  942. $str_cancel = L10n::post_form_cancel;
  943. print("<a class=\"cancel-edit\" href=\"{$url}\">{$str_cancel}</a> ");
  944. }
  945. print(<<<HTML
  946. <input type="submit" value="{$str_submit}" /></div>
  947. </form>
  948. HTML
  949. );
  950. }
  951. public static function render_recent_posts(): void {
  952. $query = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
  953. $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
  954. [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
  955. print("<div class=\"post-container\">\n");
  956. if ($prev_url !== null) {
  957. $str_prev = L10n::posts_previous_page;
  958. print("<div class=\"previous\"><a href=\"{$prev_url}\">{$str_prev}</a></div>\n");
  959. }
  960. foreach ($posts as $post) {
  961. self::render_post($post);
  962. }
  963. if ($next_url !== null) {
  964. $str_next = L10n::posts_next_page;
  965. print("<div class=\"next\"><a href=\"{$next_url}\">{$str_next}</a></div>\n");
  966. }
  967. print("</div>\n");
  968. }
  969. /// Encodes a post body as HTML. Inserts linebreaks and paragraph tags.
  970. private static function post_body_html(string $body): string {
  971. $html_p_start = "<p>";
  972. $html_p_end = "</p>\n";
  973. $html_newline = "<br/>\n";
  974. $body_html = htmlentities($body);
  975. // Single newlines are turned into linebreaks.
  976. $body_html = str_replace("\n", $html_newline, $body_html);
  977. // Pairs of newlines are turned into paragraph separators.
  978. $body_html = $html_p_start .
  979. str_replace($html_newline . $html_newline,
  980. $html_p_end . $html_p_start,
  981. $body_html) .
  982. $html_p_end;
  983. return $body_html;
  984. }
  985. public static function render_post(Post $post): void {
  986. $body_html = MARKDOWN_ENABLED ? Markdown::markdown_to_html($post->body) :
  987. self::post_body_html($post->body);
  988. $str_posted = L10n::post_created($post->created);
  989. print(<<<HTML
  990. <div class="post">
  991. <article>
  992. <div class="post-body">{$body_html}</div>
  993. <div class="post-footer">
  994. <footer class="secondary-text">
  995. <div class="post-date">
  996. {$str_posted}
  997. HTML
  998. );
  999. if ($post->updated && $post->updated != $post->created) {
  1000. $str_updated = L10n::post_updated($post->updated);
  1001. print("<br/>\n$str_updated");
  1002. }
  1003. $str_menu = L10n::post_menu;
  1004. $str_edit = L10n::post_menu_edit;
  1005. $str_delete = L10n::post_menu_delete;
  1006. print(<<<HTML
  1007. </div>
  1008. <details class="post-actions menu">
  1009. <summary class="no-indicator menu-button"><span>$str_menu</span></summary>
  1010. <ul>
  1011. <li><a href="?edit={$post->post_id}">{$str_edit}</a></li>
  1012. <li class="menu-divider"></li>
  1013. <li class="post-action-delete destructive"><a href="?delete={$post->post_id}">{$str_delete}</a></li>
  1014. </ul>
  1015. </details>
  1016. </footer>
  1017. </div>
  1018. </article>
  1019. </div>
  1020. HTML
  1021. );
  1022. }
  1023. public static function render_search_form(): void {
  1024. $q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
  1025. $q_html = htmlentities($q);
  1026. $cancel = BASE_URL;
  1027. $str_submit = L10n::search_submit;
  1028. $str_cancel = L10n::search_cancel;
  1029. print(<<<HTML
  1030. <div class="form-container">
  1031. <form id="search-form" method="GET">
  1032. <div>
  1033. <input type="search" name="search" id="search" value="{$q_html}" size="40" autocapitalize="off" />
  1034. </div>
  1035. <div class="form-buttons">
  1036. <a class="cancel-search" href="{$cancel}">{$str_cancel}</a>
  1037. <input type="submit" value="{$str_submit}" />
  1038. </div>
  1039. </form>
  1040. </div>
  1041. HTML
  1042. );
  1043. }
  1044. public static function render_sign_in_form(): void {
  1045. $str_username = L10n::login_username_label;
  1046. $str_password = L10n::login_password_label;
  1047. $str_submit = L10n::login_submit;
  1048. print(<<<HTML
  1049. <div class="form-container">
  1050. <form id="signin-form" method="POST">
  1051. <div style="text-align: end;">
  1052. <label for="username">{$str_username}</label>
  1053. <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
  1054. </div>
  1055. <div style="text-align: end;">
  1056. <label for="password">{$str_password}</label>
  1057. <input type="password" name="password" id="password" />
  1058. </div>
  1059. <input type="hidden" name="action" value="signin" />
  1060. <div class="form-buttons">
  1061. <input type="submit" value="{$str_submit}" />
  1062. </div>
  1063. </form>
  1064. </div>
  1065. HTML
  1066. );
  1067. }
  1068. public static function render_setup_form(): void {
  1069. $str_prompt = L10n::create_prompt;
  1070. $str_username = L10n::create_username_label;
  1071. $str_password = L10n::create_password_label;
  1072. $str_time_zone = L10n::create_time_zone_label;
  1073. print(<<<HTML
  1074. <div class="important">{$str_prompt}</div>
  1075. <div class="form-container">
  1076. <form id="setup-form" method="POST">
  1077. <div style="text-align: end;">
  1078. <label for="username">{$str_username}</label>
  1079. <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
  1080. </div>
  1081. <div style="text-align: end;">
  1082. <label for="password">{$str_password}</label>
  1083. <input type="password" id="password" name="password" />
  1084. </div>
  1085. <div>
  1086. <label for="timezone">{$str_time_zone}</label>
  1087. <select name="timezone" id="timezone">
  1088. HTML
  1089. );
  1090. foreach (DateTimeZone::listIdentifiers() as $timezone) {
  1091. print("<option value=\"" . htmlentities($timezone) . "\"");
  1092. if ($timezone == TIME_ZONE_DEFAULT) {
  1093. print(" selected=\"selected\"");
  1094. }
  1095. print(">" . htmlentities($timezone) . "</option>\n");
  1096. }
  1097. $str_submit = L10n::create_submit;
  1098. print(<<<HTML
  1099. </select></div>
  1100. <input type="hidden" name="action" value="createaccount" />
  1101. <div class="form-buttons">
  1102. <input type="submit" value="{$str_submit}" />
  1103. </div>
  1104. </form>
  1105. </div>
  1106. HTML
  1107. );
  1108. }
  1109. /// Redirects to the main page. Used after operation requests. Terminates
  1110. /// the script.
  1111. public static function redirect_home(): never {
  1112. header("Location: " . BASE_URL, true, 302); // 302 = found/moved temporarily
  1113. exit();
  1114. }
  1115. }
  1116. class HTMLBlock {
  1117. public HTMLBlockType $type;
  1118. public int $indent;
  1119. public string $content_markdown;
  1120. function __construct(HTMLBlockType $type, int $indent, string $content_markdown) {
  1121. $this->type = $type;
  1122. $this->indent = $indent;
  1123. $this->content_markdown = $content_markdown;
  1124. }
  1125. }
  1126. enum HTMLBlockType {
  1127. case Plain;
  1128. case ListItem;
  1129. case BlockQuote;
  1130. case Preformatted;
  1131. case H1;
  1132. case H2;
  1133. case H3;
  1134. case H4;
  1135. case H5;
  1136. case H6;
  1137. }
  1138. class Markdown {
  1139. /// Converts one line of markdown to HTML.
  1140. /// @param string $markdown Markdown string
  1141. /// @return string HTML
  1142. public static function line_markdown_to_html(string $markdown): string {
  1143. $html = htmlentities($markdown);
  1144. // Explicit URL [label](URL)
  1145. $html = preg_replace('|\[(.*?)\]\((.*?)\)|',
  1146. '<a referrerpolicy="no-referrer" target="_new" href="$2">$1</a>', $html);
  1147. // Implicit URL
  1148. $html = preg_replace('|(?<!href=")(http(?:s)?://)(\S+[^\s\.,\?!:;"\'\)])|',
  1149. '<a referrerpolicy="no-referrer" target="_new" href="$1$2">$2</a>', $html);
  1150. // Italic
  1151. $html = preg_replace('/__(\S|\S.*?)__/', '<em>$1</em>', $html);
  1152. // Bold
  1153. $html = preg_replace('/\*\*(\S|\S.*?\S)\*\*/', '<strong>$1</strong>', $html);
  1154. // Strikethrough
  1155. $html = preg_replace('/~~(\S|\S.*?\S)~~/', '<strike>$1</strike>', $html);
  1156. // Code
  1157. $html = preg_replace('/`(\S|\S.*?\S)`/', '<code>$1</code>', $html);
  1158. // Hashtags
  1159. $html = preg_replace_callback('/(#[a-zA-Z][a-zA-Z0-9_]*)\b/', function($match) {
  1160. $hashtag = $match[1];
  1161. return '<a href="?search=' . urlencode($hashtag) . '">' .
  1162. htmlentities($hashtag) . '</a>';
  1163. }, $html);
  1164. return $html;
  1165. }
  1166. /// Converts markdown into an array of HTMLBlocks.
  1167. /// @param string $markdown Markdown string
  1168. /// @return array Array of HTMLBlocks
  1169. private static function markdown_to_blocks(string $markdown): array {
  1170. $prefix_to_linetype = array(
  1171. '*' => HTMLBlockType::ListItem,
  1172. '-' => HTMLBlockType::ListItem,
  1173. '+' => HTMLBlockType::ListItem,
  1174. '>' => HTMLBlockType::BlockQuote,
  1175. '######' => HTMLBlockType::H6,
  1176. '#####' => HTMLBlockType::H5,
  1177. '####' => HTMLBlockType::H4,
  1178. '###' => HTMLBlockType::H3,
  1179. '##' => HTMLBlockType::H2,
  1180. '#' => HTMLBlockType::H1,
  1181. );
  1182. $blocks = array();
  1183. foreach (explode("\n", $markdown) as $line) {
  1184. $trimmed_line = trim($line);
  1185. $indent = intval(round((strlen($line) - strlen($trimmed_line)) / 4));
  1186. $block_type = HTMLBlockType::Plain;
  1187. $block_content = $trimmed_line;
  1188. foreach ($prefix_to_linetype as $prefix => $type) {
  1189. if ($trimmed_line == $prefix ||
  1190. str_starts_with($trimmed_line, $prefix . ' ')) {
  1191. $block_content = substr($trimmed_line, strlen($prefix));
  1192. $block_type = $type;
  1193. break;
  1194. }
  1195. }
  1196. $blocks[] = new HTMLBlock($block_type, $indent, $block_content);
  1197. }
  1198. return $blocks;
  1199. }
  1200. /// Converts markdown to HTML
  1201. public static function markdown_to_html(string $markdown): string {
  1202. return self::blocks_to_html(self::markdown_to_blocks($markdown));
  1203. }
  1204. /// Converts an array of HTMLBlocks to HTML.
  1205. private static function blocks_to_html(array $blocks): string {
  1206. $html = '';
  1207. $last_block = null;
  1208. $tag_stack = array(); // stack of end tag strings for current open blocks
  1209. foreach ($blocks as $block) {
  1210. $is_empty = strlen($block->content_markdown) == 0;
  1211. $is_last_empty = strlen($last_block?->content_markdown ?? '') == 0;
  1212. $is_same_block = $block->type == $last_block?->type;
  1213. if (!$is_same_block && sizeof($tag_stack) > 0) {
  1214. foreach (array_reverse($tag_stack) as $tag) {
  1215. $html .= $tag;
  1216. }
  1217. $tag_stack = array();
  1218. }
  1219. switch ($block->type) {
  1220. case HTMLBlockType::Plain:
  1221. if ($is_empty) {
  1222. if ($is_last_empty) {
  1223. // ignore two consecutive empty lines
  1224. } else {
  1225. $html .= array_pop($tag_stack);
  1226. }
  1227. } elseif ($is_last_empty) {
  1228. $html .= "<p>" . self::line_markdown_to_html($block->content_markdown);
  1229. $tag_stack[] = "</p>\n\n";
  1230. } else {
  1231. $html .= "<br/>\n" . self::line_markdown_to_html($block->content_markdown);
  1232. }
  1233. break;
  1234. case HTMLBlockType::ListItem:
  1235. if (!$is_same_block) {
  1236. foreach (array_reverse($tag_stack) as $tag) {
  1237. $html .= $tag;
  1238. }
  1239. $tag_stack = array();
  1240. for ($i = 0; $i <= $block->indent; $i++) {
  1241. $html .= "<ul>\n";
  1242. $html .= "<li>";
  1243. $tag_stack[] = "</ul>\n";
  1244. $tag_stack[] = "</li>\n";
  1245. }
  1246. $html .= self::line_markdown_to_html($block->content_markdown);
  1247. } elseif ($block->indent == $last_block->indent) {
  1248. $html .= "</li>\n";
  1249. $html .= "<li>" . self::line_markdown_to_html($block->content_markdown);
  1250. } elseif ($block->indent > $last_block->indent) {
  1251. // Deeper indent level
  1252. for ($i = $last_block->indent; $i < $block->indent; $i++) {
  1253. $html .= "<ul>\n<li>" . self::line_markdown_to_html($block->content_markdown);
  1254. $tag_stack[] = "</ul>\n";
  1255. $tag_stack[] = "</li>\n";
  1256. }
  1257. } elseif ($block->indent < $last_block->indent) {
  1258. // Shallower indent level
  1259. for ($i = $block->indent; $i < $last_block->indent; $i++) {
  1260. $html .= array_pop($tag_stack);
  1261. $html .= array_pop($tag_stack);
  1262. }
  1263. $html .= "</li>\n";
  1264. $html .= "<li>" . self::line_markdown_to_html($block->content_markdown);
  1265. }
  1266. break;
  1267. case HTMLBlockType::BlockQuote:
  1268. if ($is_same_block) {
  1269. $html .= "<br/>\n";
  1270. } else {
  1271. $html .= "<blockquote>";
  1272. $tag_stack[] = "</blockquote>\n\n";
  1273. }
  1274. $html .= self::line_markdown_to_html($block->content_markdown);
  1275. break;
  1276. case HTMLBlockType::Preformatted:
  1277. if ($is_same_block) {
  1278. $html .= "\n";
  1279. } else {
  1280. $html .= "<pre>";
  1281. $tag_stack[] = "</pre>\n\n";
  1282. }
  1283. $html .= htmlentities($block->content_markdown);
  1284. break;
  1285. case HTMLBlockType::H1:
  1286. $html .= '<h1>' . self::line_markdown_to_html($block->content_markdown) . "</h1>\n\n";
  1287. break;
  1288. case HTMLBlockType::H2:
  1289. $html .= '<h2>' . self::line_markdown_to_html($block->content_markdown) . "</h2>\n\n";
  1290. break;
  1291. case HTMLBlockType::H3:
  1292. $html .= '<h3>' . self::line_markdown_to_html($block->content_markdown) . "</h3>\n\n";
  1293. break;
  1294. case HTMLBlockType::H4:
  1295. $html .= '<h4>' . self::line_markdown_to_html($block->content_markdown) . "</h4>\n\n";
  1296. break;
  1297. case HTMLBlockType::H5:
  1298. $html .= '<h5>' . self::line_markdown_to_html($block->content_markdown) . "</h5>\n\n";
  1299. break;
  1300. case HTMLBlockType::H6:
  1301. $html .= '<h6>' . self::line_markdown_to_html($block->content_markdown) . "</h6>\n\n";
  1302. break;
  1303. }
  1304. $last_block = $block;
  1305. }
  1306. if (sizeof($tag_stack) > 0) {
  1307. foreach (array_reverse($tag_stack) as $tag) {
  1308. $html .= $tag;
  1309. }
  1310. }
  1311. return $html;
  1312. }
  1313. }
  1314. // -- Main logic ------------------------------------------
  1315. check_setup();
  1316. Session::check_cookie();
  1317. User::update_current();
  1318. switch ($_SERVER['REQUEST_METHOD']) {
  1319. case 'GET':
  1320. if (array_key_exists('logout', $_GET)) {
  1321. User::sign_out();
  1322. HTMLPage::redirect_home();
  1323. }
  1324. HTMLPage::render_page_start();
  1325. HTMLPage::render_error_if_needed();
  1326. if (User::$current) {
  1327. if ($delete_id = validate($_GET, 'delete', INPUT_TYPE_INT, required: false)) {
  1328. Post::get_by_id($delete_id)?->delete();
  1329. HTMLPage::redirect_home();
  1330. }
  1331. if (array_key_exists('search', $_GET)) {
  1332. HTMLPage::render_search_form();
  1333. } else {
  1334. HTMLPage::render_post_form();
  1335. }
  1336. HTMLPage::render_recent_posts();
  1337. } elseif (User::any_exist()) {
  1338. HTMLPage::render_sign_in_form();
  1339. } else {
  1340. HTMLPage::render_setup_form();
  1341. }
  1342. HTMLPage::render_page_end();
  1343. exit();
  1344. case 'POST':
  1345. $nonempty_str_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY | INPUT_TYPE_TRIMMED;
  1346. $password_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY;
  1347. switch ($_POST['action']) {
  1348. case 'post':
  1349. $body = validate($_POST, 'body', $nonempty_str_type);
  1350. $author = User::$current->user_id;
  1351. $created = time();
  1352. if (!User::$current) {
  1353. // Not logged in. Save body for populating once they sign in.
  1354. $_SESSION[SESSION_KEY_POST_BODY] = $body;
  1355. fatal_error(L10n::error_sign_in_to_post);
  1356. }
  1357. Post::create($body, $author, $created);
  1358. break;
  1359. case 'edit':
  1360. $body = validate($_POST, 'body', $nonempty_str_type);
  1361. $edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
  1362. if (!User::$current) {
  1363. // Not logged in. Save body for populating once they sign in.
  1364. fatal_error(L10n::error_sign_in_to_edit);
  1365. }
  1366. Post::get_by_id($edit_id)?->update($body);
  1367. break;
  1368. case 'createaccount':
  1369. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
  1370. $password = validate($_POST, 'password', $password_type);
  1371. $timezone = validate($_POST, 'timezone', $nonempty_str_type);
  1372. User::create($username, $password, $timezone);
  1373. break;
  1374. case 'signin':
  1375. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
  1376. $password = validate($_POST, 'password', $password_type);
  1377. User::sign_in($username, $password);
  1378. break;
  1379. case 'signout':
  1380. User::sign_out();
  1381. break;
  1382. default:
  1383. trace('Invalid POST action: ' . ($_POST['action'] ?: 'null'));
  1384. http_response_code(400);
  1385. exit(1);
  1386. }
  1387. HTMLPage::redirect_home();
  1388. default:
  1389. trace("Invalid request method: " . $_SERVER['REQUEST_METHOD']);
  1390. http_response_code(405);
  1391. exit(1);
  1392. }
  1393. ?>