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 25KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. <?php declare(strict_types=1);
  2. // --------------------------------------------------------
  3. // -- Begin required configuration ------------------------
  4. /// Absolute path to the journal.db file on your web server. File must be
  5. /// writable by the web server user. THIS FILE SHOULD BE SECURED FROM WEB
  6. /// ACCESS! Otherwise someone can download your journal database if they know
  7. /// the default URL. Options:
  8. ///
  9. /// 1. Put journal.db somewhere that isn't under your web directory. (BEST OPTION)
  10. /// 2. Rename journal.db to something randomized, like the output of the
  11. /// `uuidgen` command, so the URL would be too obfuscated to guess.
  12. /// 3. Modify your `.htaccess` to forbid access to .db files. e.g.
  13. /// <Files ~ "\.db$">
  14. /// Order allow,deny
  15. /// Deny from all
  16. /// </Files>
  17. /// This example may or may not work for you. Be sure to test your
  18. /// server configuration!
  19. define('DB_PATH', '/path/to/journal.db');
  20. // URL of this page. You can omit the index.php and just keep the path if you'd
  21. // prefer a cleaner URL.
  22. define('BASE_URL', 'https://example.com/path/index.php');
  23. // -- End required configuration --------------------------
  24. // --------------------------------------------------------
  25. // -- Constants -------------------------------------------
  26. /// For detecting database migrations.
  27. define('SCHEMA_VERSION', '20230107');
  28. define('RECENT_POSTS_PER_PAGE', 50);
  29. define('SESSION_COOKIE_TTL_SECONDS', 14 * 24 * 60 * 60); // 14 days
  30. /// If a new post matches an existing post created less than this many seconds
  31. /// ago, the new duplicate post will be ignored.
  32. define('DUPLICATE_POST_SECONDS', 10);
  33. define('TIME_ZONE_DEFAULT', 'America/Los_Angeles');
  34. // -- Core setup ------------------------------------------
  35. session_set_cookie_params(SESSION_COOKIE_TTL_SECONDS, httponly: true);
  36. session_start();
  37. $_SESSION['last_access'] = time(); // keep alive
  38. function handle_error($error_level = E_WARNING, $error_message = '',
  39. $error_file = '', $error_line = 0, $error_context = null): void {
  40. $lvl = 'WARNING';
  41. switch ($error_level) {
  42. case E_WARNING: case E_USER_WARNING: $lvl = 'WARNING'; break;
  43. case E_ERROR: case E_USER_ERROR: $lvl = 'ERROR'; break;
  44. case E_NOTICE: case E_USER_NOTICE: $lvl = 'NOTICE'; break;
  45. }
  46. error_log("[{$lvl} - {$error_file}:{$error_line}] {$error_message}", 0);
  47. }
  48. set_error_handler('handle_error');
  49. error_reporting(E_ERROR);
  50. /// Logs a debug message.
  51. function trace(string $message): void {
  52. //error_log($message, 0);
  53. }
  54. // -- Data classes ----------------------------------------
  55. /// Represents a journal post.
  56. class Post {
  57. public int $post_id;
  58. public string $body;
  59. public int $author_id;
  60. public int $created;
  61. function __construct(array $row) {
  62. $this->post_id = $row['rowid'];
  63. $this->body = $row['body'];
  64. $this->author_id = $row['author_id'];
  65. $this->created = $row['created'];
  66. }
  67. /// Normalizes the body of a post.
  68. private static function normalize_body(string $body): string {
  69. $s = $body;
  70. $s = str_replace("\r\n", "\n", $s); // CRLF -> LF
  71. $s = trim($s);
  72. return $s;
  73. }
  74. /// Creates a new post.
  75. /// @param string $body Text content of the journal entry.
  76. /// @param int $author_id User ID of the post author.
  77. /// @param int $created Unix timestamp of when the post was created.
  78. public static function create(
  79. string $body,
  80. int $author_id,
  81. int $created): void {
  82. trace("Creating a post \"{$body}\"");
  83. $body = self::normalize_body($body);
  84. if (self::recent_duplicate_exists($body, $author_id, $created)) {
  85. trace("Same post already created recently. Skipping.");
  86. return;
  87. }
  88. $sql = 'INSERT INTO posts (body, author_id, created) VALUES (:body, :author_id, :created);';
  89. $args = array(':body' => $body, ':author_id' => $author_id, ':created' => $created);
  90. Database::query($sql, $args);
  91. }
  92. /// Tests if an identical post was created recently. For preventing double
  93. /// submits.
  94. /// @param string $body Text content of the journal entry.
  95. /// @param int $author_id User ID of the post author.
  96. /// @param int $created Unix timestamp of when the post was created.
  97. /// @return bool True if a similar recent post exists, false if not.
  98. public static function recent_duplicate_exists(
  99. string $body,
  100. int $author_id,
  101. int $created): bool {
  102. $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND ABS(created - :created) < :seconds LIMIT 1;';
  103. $args = array(
  104. ':body' => $body,
  105. ':author_id' => $author_id,
  106. ':created' => $created,
  107. ':seconds' => DUPLICATE_POST_SECONDS,
  108. );
  109. return sizeof(Database::query($sql, $args)) > 0;
  110. }
  111. /// Fetches existing posts, newest first.
  112. /// @param int $user_id User ID of author to fetch posts for.
  113. /// @param int $count Maximum number of posts to return.
  114. /// @param ?int $before_time If provided, only posts older than this Unix
  115. /// timestamp are returned.
  116. /// @return array Three-element tuple - 0: array of Posts,
  117. /// 1: relative URL to show previous page, or null if none
  118. /// 2: relative URL to show next page, or null if none
  119. public static function get_posts(
  120. int $user_id,
  121. int $count = RECENT_POSTS_PER_PAGE,
  122. ?int $before_time = null): array {
  123. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id';
  124. $args = array(
  125. ':author_id' => $user_id,
  126. ':count' => $count + 1, // to see if there's another page
  127. );
  128. if ($before_time) {
  129. $sql .= ' AND created < :before_time';
  130. $args[':before_time'] = $before_time;
  131. }
  132. $sql .= ' ORDER BY created DESC LIMIT :count;';
  133. $posts = Database::query_objects('Post', $sql, $args);
  134. $prev_page = null;
  135. $next_page = null;
  136. if (sizeof($posts) > $count) {
  137. $posts = array_slice($posts, 0, $count);
  138. $next_page = '?before=' . $posts[array_key_last($posts)]->created;
  139. }
  140. if ($before_time) {
  141. // We're paged forward. Check if there are newer posts.
  142. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
  143. 'created >= :before_time ORDER BY created ASC LIMIT :count;';
  144. $args = array(
  145. ':author_id' => $user_id,
  146. ':count' => $count + 1, // to see if it's the newest page
  147. ':before_time' => $before_time,
  148. );
  149. $newer_posts = Database::query_objects('Post', $sql, $args);
  150. if (sizeof($newer_posts) > $count) {
  151. $prev_date = $newer_posts[array_key_last($newer_posts)]->created;
  152. $prev_page = '?before=' . $prev_date;
  153. } else {
  154. $prev_page = BASE_URL;
  155. }
  156. }
  157. return array($posts, $prev_page, $next_page);
  158. }
  159. }
  160. /// Represents a user.
  161. class User {
  162. public int $user_id;
  163. public string $username;
  164. public string $password_hash;
  165. public string $timezone;
  166. public static ?User $current = null;
  167. function __construct(array $row) {
  168. $this->user_id = $row['user_id'];
  169. $this->username = $row['username'];
  170. $this->password_hash = $row['password_hash'];
  171. $this->timezone = $row['timezone'];
  172. }
  173. /// Tests if any users exist in the database.
  174. public static function any_exist(): bool {
  175. static $has_any = null;
  176. if ($has_any !== null) return $has_any;
  177. $rows = Database::query('SELECT * FROM users;');
  178. $has_any = sizeof($rows) > 0;
  179. return $has_any;
  180. }
  181. /// Fetches a user by their user ID.
  182. public static function get_by_id(int $user_id): ?User {
  183. $sql = 'SELECT * FROM users WHERE user_id=:user_id LIMIT 1;';
  184. $args = array(':user_id' => $user_id);
  185. return Database::query_object('User', $sql, $args);
  186. }
  187. /// Fetches a user by their username.
  188. public static function get_by_username(string $username): ?User {
  189. $sql = 'SELECT * FROM users WHERE username=:username LIMIT 1;';
  190. $args = array(':username' => $username);
  191. return Database::query_object('User', $sql, $args);
  192. }
  193. /// Creates a new user.
  194. ///
  195. /// @param string $username Username
  196. /// @param string $password Password for the user
  197. /// @param string $timezone User's preferred time zone
  198. public static function create(
  199. string $username,
  200. string $password,
  201. string $timezone): void {
  202. trace("Creating user {$username}");
  203. $sql = 'INSERT OR IGNORE INTO users (username, password_hash, timezone) VALUES (:username, :password_hash, :timezone);';
  204. $args = array(
  205. ':username' => $username,
  206. ':password_hash' => password_hash($password, PASSWORD_DEFAULT),
  207. ':timezone' => $timezone,
  208. );
  209. Database::query($sql, $args);
  210. }
  211. /// Signs in an existing user and returns their User object, or null if
  212. /// sign in failed. Sets $_SESSION if successful.
  213. ///
  214. /// @param string $username User's username
  215. /// @param string $password User's password
  216. /// @return User object if sign in successful, otherwise null
  217. public static function sign_in(string $username, string $password): ?User {
  218. $user = self::get_by_username($username);
  219. if ($user === null) {
  220. trace("Login failed. No such user {$username}.");
  221. fatal_error("Login incorrect.");
  222. return null;
  223. }
  224. if (!password_verify($password, $user->password_hash)) {
  225. trace("Login failed. Bad password for {$username}.");
  226. fatal_error("Login incorrect.");
  227. return null;
  228. }
  229. trace("Login succeeded for {$username}.");
  230. $_SESSION['user_id'] = $user->user_id;
  231. self::$current = $user;
  232. return $user;
  233. }
  234. /// Updates self::$current from $_SESSION.
  235. public static function update_current(): void {
  236. if (self::any_exist() && array_key_exists('user_id', $_SESSION) && is_int($_SESSION['user_id'])) {
  237. $id = $_SESSION['user_id'];
  238. self::$current = User::get_by_id($id);
  239. } else {
  240. self::$current = null;
  241. }
  242. }
  243. public static function sign_out(): void {
  244. self::$current = null;
  245. unset($_SESSION['user_id']);
  246. }
  247. }
  248. // -- Utility classes -------------------------------------
  249. /// Performs database operations.
  250. class Database {
  251. /// Tests if the configured database is available.
  252. public static function exists(): bool {
  253. return file_exists(DB_PATH);
  254. }
  255. /// Tests if the configured database is writable.
  256. public static function is_writable(): bool {
  257. return is_writable(DB_PATH);
  258. }
  259. /// Tests if the database file is accessible externally by a predictable
  260. /// URL. If it is, the user should be warned so they can fix its permissions.
  261. public static function is_accessible_by_web(): bool {
  262. if (!str_ends_with(DB_PATH, '/journal.db')) return false;
  263. $ctx = stream_context_create(array(
  264. 'http' => array(
  265. 'timeout' => 1, // shouldn't take long to reach own server
  266. )
  267. ));
  268. $journal_url = strip_filename(BASE_URL);
  269. if (!str_ends_with($journal_url, '/')) $journal_url .= '/';
  270. $journal_url .= 'journal.db';
  271. trace("Testing external accessibility of journal URL. This may take a second.");
  272. return (file_get_contents($journal_url, false, $ctx) !== false);
  273. }
  274. /// Performs an SQL query and returns the first row as an object of the
  275. /// given class. The given class must accept a row array as its only
  276. /// constructor argument. Adding a "LIMIT 1" clause to the query may help
  277. /// performance, since only the first result is used.
  278. ///
  279. /// @param $classname Class name of the class to use for each result row.
  280. /// @param $sql Query to execute.
  281. /// @param $params Map of SQL placeholders to values.
  282. /// @return ?object Object of given class from first result row, or null
  283. /// if no rows matched.
  284. public static function query_object(string $classname, string $sql, array $params = array()): ?object {
  285. $objs = self::query_objects($classname, $sql, $params);
  286. return (sizeof($objs) > 0) ? $objs[0] : null;
  287. }
  288. /// Performs an SQL query and returns the result set as an array of objects
  289. /// of the given class. The given class must accept a row array as its only
  290. /// constructor argument.
  291. ///
  292. /// @param $classname Class name of the class to use for each result row.
  293. /// @param $sql Query to execute.
  294. /// @param $params Map of SQL placeholders to values.
  295. /// @return array Array of records of the given class.
  296. public static function query_objects(string $classname, string $sql, array $params = array()): array {
  297. $rows = self::query($sql, $params);
  298. $objs = array();
  299. if ($rows !== false) {
  300. foreach ($rows as $row) {
  301. $objs[] = new $classname($row);
  302. }
  303. }
  304. return $objs;
  305. }
  306. /// Runs a query and returns the complete result. Select queries will return
  307. /// all rows as an array of row arrays. Insert/update/delete queries will
  308. /// return a boolean of success.
  309. ///
  310. /// @param $sql Query to execute.
  311. /// @param $params Map of SQL placeholders to values.
  312. /// @return array Array of arrays. The inner arrays contain both indexed
  313. /// and named column values.
  314. public static function query(string $sql, array $params = array()): bool|array {
  315. $db = new SQLite3(DB_PATH);
  316. $stmt = $db->prepare($sql);
  317. foreach ($params as $name => $value) {
  318. $stmt->bindValue($name, $value, self::sqlite_type($value));
  319. }
  320. $result = $stmt->execute();
  321. if (gettype($result) == 'bool') {
  322. return $result;
  323. }
  324. $rows = array();
  325. // XXX: Make sure it's a query with results, otherwise fetchArray will
  326. // cause non-selects (e.g. INSERT) to be executed again.
  327. if ($result->numColumns()) {
  328. while ($row = $result->fetchArray()) {
  329. $rows[] = $row;
  330. }
  331. }
  332. $stmt->close();
  333. $db->close();
  334. return $rows;
  335. }
  336. /// Returns the closest SQLite3 datatype for the given PHP value.
  337. private static function sqlite_type($value): int {
  338. switch (gettype($value)) {
  339. case 'boolean':
  340. case 'integer':
  341. return SQLITE3_INTEGER;
  342. case 'double':
  343. return SQLITE3_FLOAT;
  344. case 'string':
  345. return SQLITE3_TEXT;
  346. case 'NULL':
  347. return SQLITE3_NULL;
  348. default:
  349. fatal_error("Bad datatype in sqlite statement");
  350. }
  351. }
  352. }
  353. /// App configuration management. All config is stored in the 'config' table in
  354. /// the sqlite database.
  355. class Config {
  356. public static function get_is_configured(): bool {
  357. return self::get_config_value('is_configured', 'bool') ?: false;
  358. }
  359. public static function set_is_configured(bool $is_configured): void {
  360. self::set_config_value('is_configured', $is_configured);
  361. }
  362. public static function get_schema_version(): ?string {
  363. return self::get_config_value('schema_version', 'string');
  364. }
  365. public static function set_schema_version(string $schema_version): void {
  366. self::set_config_value('schema_version', $schema_version);
  367. }
  368. /// Fetches a config value from the database, or null if not found.
  369. private static function get_config_value(string $name, string $type = 'any') {
  370. $sql = 'SELECT value FROM config WHERE name=:name;';
  371. $params = array(':name' => $name);
  372. $rows = Database::query($sql, $params);
  373. if (sizeof($rows) == 0) return null;
  374. $value = $rows[0]['value'];
  375. if ($type != 'any') {
  376. switch ($type) {
  377. case 'string': $value = strval($value); break;
  378. case 'int': $value = intval($value); break;
  379. case 'bool': $value = boolval($value); break;
  380. }
  381. }
  382. return $value;
  383. }
  384. /// Saves a config value to the database.
  385. private static function set_config_value(string $name, $value): void {
  386. $args = array(':name' => $name, ':value' => $value);
  387. Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
  388. Database::query('INSERT OR IGNORE INTO config (name, value) VALUES (:name, :value);', $args);
  389. }
  390. }
  391. // -- Misc utility functions ------------------------------
  392. /// Checks whether the app is setup correctly.
  393. function check_setup(): void {
  394. // Check user-configurable variables to make sure they aren't default values.
  395. if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
  396. fatal_error("Journal not configured. Open index.php in an editor and configure the variables at the top of the file.");
  397. }
  398. if (!Database::exists()) {
  399. fatal_error("Database file cannot be found. Check configuration at the top of index.php.");
  400. }
  401. if (!Database::is_writable()) {
  402. $user = trim(shell_exec('whoami'));
  403. $group = trim(shell_exec('id -gn'));
  404. fatal_error("Database file exists but is not writable by web server user '{$user}' (user group '{$group}'). Check file permissions.");
  405. }
  406. $schema_version = Config::get_schema_version();
  407. if ($schema_version === null) {
  408. fatal_error("No schema version in database. Corrupted?");
  409. }
  410. if ($schema_version != SCHEMA_VERSION) {
  411. // TODO: If schema changes, migration paths will go here. For now just fail.
  412. fatal_error("Unexpected schema version $schema_version.");
  413. }
  414. if (Config::get_is_configured()) {
  415. // Already checked the more expensive tests below, so skip.
  416. return;
  417. }
  418. // If using default database name, make sure it's not accessible from the
  419. // web. It'd be far too easy to access.
  420. if (Database::is_accessible_by_web()) {
  421. fatal_error("Journal database is accessible from the web! Either alter your .htaccess permissions or rename the database with a UUID filename.");
  422. }
  423. // Everything looks good!
  424. Config::set_is_configured(true);
  425. }
  426. /// Shows the user an error message and terminates the script. If called from
  427. /// a non-GET request, will first redirec to the main page then display the
  428. /// message.
  429. function fatal_error(string $message): never {
  430. if ($_SERVER['REQUEST_METHOD'] != 'GET') {
  431. $_SESSION['error'] = $message;
  432. HTMLPage::redirect_home();
  433. }
  434. HTMLPage::render_page_start();
  435. HTMLPage::render_error($message);
  436. HTMLPage::render_page_end();
  437. exit();
  438. }
  439. /// Returns a path or URL without the filename. Result includes trailing /.
  440. function strip_filename(string $path): string {
  441. $start = 0;
  442. if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
  443. $start = 8;
  444. }
  445. $slash = strrpos($path, '/', $start);
  446. if ($slash === false) return $path + '/';
  447. return substr($path, 0, $slash + 1);
  448. }
  449. define('INPUT_TYPE_NONEMPTY', 0x100);
  450. define('INPUT_TYPE_TRIMMED', 0x200);
  451. define('INPUT_TYPE_INT', 0x1 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
  452. define('INPUT_TYPE_STRING', 0x2);
  453. define('INPUT_TYPE_USERNAME', 0x4 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
  454. /// Validates and casts a form input.
  455. /// @param array $source Source array, like $_GET or $_POST.
  456. /// @param string $name Form input variable name.
  457. /// @param int $type Bit mask of INPUT_TYPE_ constraints.
  458. /// @param bool $required Whether the value must be present. Will throw error
  459. /// if value is required and not found.
  460. /// @return Cast value, or null if not required and found
  461. function validate(array $source, string $name, int $type, bool $required = true) {
  462. if (!array_key_exists($name, $source)) {
  463. if ($required) {
  464. fatal_error("Parameter {$name} is required.");
  465. }
  466. return null;
  467. }
  468. $val = $source[$name];
  469. if ($type & INPUT_TYPE_TRIMMED) {
  470. $val = trim($val);
  471. }
  472. if (strlen($val) == 0) {
  473. if ($type & INPUT_TYPE_NONEMPTY) {
  474. fatal_error("Parameter {$name} cannot be empty.");
  475. }
  476. }
  477. if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
  478. if (is_numeric($val)) return intval($val);
  479. fatal_error("Parameter {$name} must be integer.");
  480. }
  481. if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
  482. return $val;
  483. }
  484. if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
  485. if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
  486. fatal_error("Invalid username. Can be letters, numbers, underscores, and dashes, or an email address.");
  487. }
  488. return null;
  489. }
  490. function localized_date_string(int $timestamp): string {
  491. $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
  492. $timezone = User::$current->timezone;
  493. $formatter = IntlDateFormatter::create(
  494. $locale_code,
  495. dateType: IntlDateFormatter::MEDIUM,
  496. timeType: IntlDateFormatter::MEDIUM,
  497. timezone: $timezone,
  498. calendar: IntlDateFormatter::GREGORIAN);
  499. return $formatter->format($timestamp);
  500. }
  501. // -- HTML renderers --------------------------------------
  502. class HTMLPage {
  503. public static function render_page_start(): void {
  504. print(<<<HTML
  505. <!DOCTYPE html>
  506. <html>
  507. <head>
  508. <title>Microjournal</title>
  509. <meta charset="UTF-8" />
  510. <link rel="stylesheet" type="text/css" href="journal.css" />
  511. <meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
  512. </head>
  513. <body>
  514. <div class="content">
  515. HTML
  516. );
  517. }
  518. public static function render_page_end(): void {
  519. print(<<<HTML
  520. </div>
  521. </body>
  522. </html>
  523. HTML
  524. );
  525. }
  526. public static function render_error_if_needed(): void {
  527. if (array_key_exists('error', $_SESSION)) {
  528. $e = $_SESSION['error'];
  529. unset($_SESSION['error']);
  530. self::render_error($e);
  531. }
  532. }
  533. public static function render_error(string $message): void {
  534. print("<div class=\"error\">" . htmlentities($message) . "</div>\n");
  535. }
  536. public static function render_post_form(): void {
  537. print(<<<HTML
  538. <form id="post-form" method="POST">
  539. <div class="post-container"><textarea name="body" placeholder="Your journal post here…"></textarea></div>
  540. <input type="hidden" name="action" value="post" />
  541. <div class="submit-post"><input type="submit" value="Post" /></div>
  542. </form>
  543. HTML
  544. );
  545. }
  546. public static function render_recent_posts(): void {
  547. $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
  548. [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, before_time: $before);
  549. print("<div class=\"post-container\">\n");
  550. if ($prev_url !== null) {
  551. print("<div class=\"previous\"><a href=\"{$prev_url}\">Previous</a></div>\n");
  552. }
  553. foreach ($posts as $post) {
  554. self::render_post($post);
  555. }
  556. if ($next_url !== null) {
  557. print("<div class=\"next\"><a href=\"{$next_url}\">Next</a></div>\n");
  558. }
  559. print("</div>\n");
  560. }
  561. /// Encodes a post body as HTML. Inserts linebreaks and paragraph tags.
  562. private static function post_body_html(string $body): string {
  563. $body_html = htmlentities($body);
  564. // Single newlines are turned into linebreaks.
  565. $body_html = str_replace("\n", "<br/>\n", $body_html);
  566. // Pairs of newlines are turned into paragraph separators.
  567. $paragraphs = explode("<br/>\n<br/>\n", $body_html);
  568. $body_html = "<p>" . implode("</p>\n\n<p>", $paragraphs) . "</p>\n";
  569. return $body_html;
  570. }
  571. public static function render_post(Post $post): void {
  572. $body_html = self::post_body_html($post->body);
  573. $date = localized_date_string($post->created);
  574. print(<<<HTML
  575. <div class="post">
  576. <article>
  577. <div class="post-body">{$body_html}</div>
  578. <footer>
  579. <div class="post-date">
  580. Posted {$date}
  581. </div>
  582. </footer>
  583. </article>
  584. </div>
  585. HTML
  586. );
  587. }
  588. public static function render_sign_in_form(): void {
  589. print(<<<HTML
  590. <form id="signin-form" method="POST">
  591. <div>
  592. <label for="username">Username:</label>
  593. <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
  594. </div>
  595. <div>
  596. <label for="password">Password:</label>
  597. <input type="password" name="password" id="password" />
  598. </div>
  599. <input type="hidden" name="action" value="signin" />
  600. <input type="submit" value="Sign in" />
  601. </form>
  602. HTML
  603. );
  604. }
  605. public static function render_setup_form(): void {
  606. print(<<<HTML
  607. <div class="important">Journal is not setup. Please create a username and password to secure your data.</div>
  608. <form id="setup-form" method="POST">
  609. <div>
  610. <label for="username">Username:</label>
  611. <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
  612. </div>
  613. <div>
  614. <label for="password">Password:</label>
  615. <input type="password" id="password" name="password" />
  616. </div>
  617. <div>
  618. <label for="timezone">Time zone:</label>
  619. <select name="timezone" id="timezone">
  620. HTML
  621. );
  622. foreach (DateTimeZone::listIdentifiers() as $timezone) {
  623. print("<option value=\"" . htmlentities($timezone) . "\"");
  624. if ($timezone == TIME_ZONE_DEFAULT) {
  625. print(" selected=\"selected\"");
  626. }
  627. print(">" . htmlentities($timezone) . "</option>\n");
  628. }
  629. print(<<<HTML
  630. </select></div>
  631. <input type="hidden" name="action" value="createaccount" />
  632. <div><input type="submit" value="Create account" /></div>
  633. </form>
  634. HTML
  635. );
  636. }
  637. /// Redirects to the main page. Used after operation requests. Terminates
  638. /// the script.
  639. public static function redirect_home(): never {
  640. header("Location: " . BASE_URL, true, 302); // 302 = found/moved temporarily
  641. exit();
  642. }
  643. }
  644. // -- Main logic ------------------------------------------
  645. check_setup();
  646. User::update_current();
  647. switch ($_SERVER['REQUEST_METHOD']) {
  648. case 'GET':
  649. HTMLPage::render_page_start();
  650. HTMLPage::render_error_if_needed();
  651. if (User::$current) {
  652. HTMLPage::render_post_form();
  653. HTMLPage::render_recent_posts();
  654. } elseif (User::any_exist()) {
  655. HTMLPage::render_sign_in_form();
  656. } else {
  657. HTMLPage::render_setup_form();
  658. }
  659. HTMLPage::render_page_end();
  660. exit();
  661. case 'POST':
  662. $nonempty_str_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY | INPUT_TYPE_TRIMMED;
  663. $password_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY;
  664. switch ($_POST['action']) {
  665. case 'post':
  666. $body = validate($_POST, 'body', $nonempty_str_type);
  667. $author = User::$current->user_id;
  668. $created = time();
  669. Post::create($body, $author, $created);
  670. break;
  671. case 'createaccount':
  672. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
  673. $password = validate($_POST, 'password', $password_type);
  674. $timezone = validate($_POST, 'timezone', $nonempty_str_type);
  675. User::create($username, $password, $timezone);
  676. break;
  677. case 'signin':
  678. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
  679. $password = validate($_POST, 'password', $password_type);
  680. User::sign_in($username, $password);
  681. break;
  682. case 'signout':
  683. User::sign_out();
  684. break;
  685. default:
  686. trace('Invalid POST action: ' . ($_POST['action'] ?: 'null'));
  687. http_response_code(400);
  688. exit(1);
  689. }
  690. HTMLPage::redirect_home();
  691. default:
  692. trace("Invalid request method: " . $_SERVER['REQUEST_METHOD']);
  693. http_response_code(405);
  694. exit(1);
  695. }
  696. ?>