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
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

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