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
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

index.php 43KB

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