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
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

index.php 32KB

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