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 kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

index.php 43KB

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