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
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

index.php 31KB

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