| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282 |
- <?php declare(strict_types=1);
- // Copyright © 2023 Ian Albert
- //
- // Permission is hereby granted, free of charge, to any person obtaining a copy
- // of this software and associated documentation files (the “Software”), to deal
- // in the Software without restriction, including without limitation the rights
- // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- // copies of the Software, and to permit persons to whom the Software is
- // furnished to do so, subject to the following conditions:
- //
- // The above copyright notice and this permission notice shall be included in
- // all copies or substantial portions of the Software.
- //
- // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- // SOFTWARE.
-
-
-
- // --------------------------------------------------------
- // -- Begin required configuration ------------------------
-
- /// Absolute path to the journal.db file on your web server. File must be
- /// writable by the web server user. THIS FILE SHOULD BE SECURED FROM WEB
- /// ACCESS! Otherwise someone can download your journal database if they know
- /// the default URL. Options:
- ///
- /// 1. Put journal.db somewhere that isn't under your web directory. (BEST OPTION)
- /// 2. Rename journal.db to something randomized, like the output of the
- /// `uuidgen` command, so the URL would be too obfuscated to guess.
- /// 3. Modify your `.htaccess` to forbid access to .db files. e.g.
- /// <Files ~ "\.db$">
- /// Order allow,deny
- /// Deny from all
- /// </Files>
- /// This example may or may not work for you. Be sure to test your
- /// server configuration!
- define('DB_PATH', '/path/to/journal.db');
-
- // URL of this page. You can omit the index.php and just keep the path if you'd
- // prefer a cleaner URL.
- define('BASE_URL', 'https://example.com/path/index.php');
-
- // -- End required configuration --------------------------
- // --------------------------------------------------------
-
-
-
-
-
-
- // -- Constants -------------------------------------------
-
- /// For detecting database migrations.
- define('SCHEMA_VERSION', '20230107');
- define('RECENT_POSTS_PER_PAGE', 50);
- define('SESSION_COOKIE_TTL_SECONDS', 14 * 24 * 60 * 60); // 14 days
- define('SESSION_COOKIE_NAME', 'journal_token');
- /// If a new post matches an existing post created less than this many seconds
- /// ago, the new duplicate post will be ignored.
- define('DUPLICATE_POST_SECONDS', 10);
- define('TIME_ZONE_DEFAULT', 'America/Los_Angeles');
- define('TOKEN_BYTES', 16);
- define('SESSION_KEY_ERROR', 'error');
- define('SESSION_KEY_LAST_ACCESS', 'last_access');
- define('SESSION_KEY_POST_BODY', 'post_body');
- define('SESSION_KEY_USER_ID', 'user_id');
-
- // -- Core setup ------------------------------------------
-
- session_start();
- $_SESSION[SESSION_KEY_LAST_ACCESS] = time(); // keep alive
-
- function handle_error($error_level = E_WARNING, $error_message = '',
- $error_file = '', $error_line = 0, $error_context = null): void {
- $lvl = 'WARNING';
- switch ($error_level) {
- case E_WARNING: case E_USER_WARNING: $lvl = 'WARNING'; break;
- case E_ERROR: case E_USER_ERROR: $lvl = 'ERROR'; break;
- case E_NOTICE: case E_USER_NOTICE: $lvl = 'NOTICE'; break;
- }
- error_log("[{$lvl} - {$error_file}:{$error_line}] {$error_message}", 0);
- }
- set_error_handler('handle_error');
- error_reporting(E_ERROR);
-
- /// Logs a debug message.
- function trace(string $message): void {
- // error_log($message, 0);
- }
-
- // -- Data classes ----------------------------------------
-
- /// Represents a journal post.
- class Post {
- public ?int $post_id;
- public string $body;
- public int $author_id;
- public int $created;
- public ?int $updated;
-
- function __construct(array $row) {
- $this->post_id = $row['rowid'];
- $this->body = $row['body'];
- $this->author_id = $row['author_id'];
- $this->created = $row['created'];
- $this->updated = $row['updated'];
- }
-
- /// Normalizes the body of a post.
- private static function normalize_body(string $body): string {
- $s = $body;
- $s = str_replace("\r\n", "\n", $s); // CRLF -> LF
- $s = trim($s);
- return $s;
- }
-
- /// Creates a new post.
- /// @param string $body Text content of the journal entry.
- /// @param int $author_id User ID of the post author.
- /// @param int $created Unix timestamp of when the post was created.
- public static function create(
- string $body,
- int $author_id,
- int $created): void {
- trace("Creating a post \"{$body}\"");
- $body = self::normalize_body($body);
- if (self::recent_duplicate_exists($body, $author_id, $created)) {
- trace("Same post already created recently. Skipping.");
- return;
- }
- $sql = 'INSERT INTO posts (body, author_id, created) VALUES (:body, :author_id, :created);';
- $args = array(':body' => $body, ':author_id' => $author_id, ':created' => $created);
- Database::query($sql, $args);
- }
-
- /// Tests if an identical post was created recently. For preventing double
- /// submits.
- /// @param string $body Text content of the journal entry.
- /// @param int $author_id User ID of the post author.
- /// @param int $created Unix timestamp of when the post was created.
- /// @return bool True if a similar recent post exists, false if not.
- public static function recent_duplicate_exists(
- string $body,
- int $author_id,
- int $created): bool {
- $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND ABS(created - :created) < :seconds LIMIT 1;';
- $args = array(
- ':body' => $body,
- ':author_id' => $author_id,
- ':created' => $created,
- ':seconds' => DUPLICATE_POST_SECONDS,
- );
- return sizeof(Database::query($sql, $args)) > 0;
- }
-
- /// Fetches existing posts, newest first.
- /// @param int $user_id User ID of author to fetch posts for.
- /// @param int $count Maximum number of posts to return.
- /// @param ?int $before_time If provided, only posts older than this Unix
- /// timestamp are returned.
- /// @return array Three-element tuple - 0: array of Posts,
- /// 1: relative URL to show previous page, or null if none
- /// 2: relative URL to show next page, or null if none
- public static function get_posts(
- int $user_id,
- int $count = RECENT_POSTS_PER_PAGE,
- ?string $query = null,
- ?int $before_time = null): array {
- $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id';
- $args = array(
- ':author_id' => $user_id,
- ':count' => $count + 1, // to see if there's another page
- );
- if ($before_time) {
- $sql .= ' AND created < :before_time';
- $args[':before_time'] = $before_time;
- }
- $search_where = '';
- if ($query) {
- foreach (explode(' ', $query) as $i => $term) {
- if (strlen($term) == 0) continue;
- $symbol = ":wordpattern{$i}";
- $search_where .= " AND body LIKE $symbol ESCAPE '!'";
- $args[$symbol] = '%' . Database::escape_like($term, '!') . '%';
- }
- $sql .= $search_where;
- }
- $sql .= ' ORDER BY created DESC LIMIT :count;';
- $posts = Database::query_objects('Post', $sql, $args);
-
- $prev_page = null;
- $next_page = null;
- if (sizeof($posts) > $count) {
- $posts = array_slice($posts, 0, $count);
- $next_page = '?before=' . $posts[array_key_last($posts)]->created;
- }
- if ($before_time) {
- // We're paged forward. Check if there are newer posts.
- $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
- 'created >= :before_time ' . $search_where . ' ORDER BY created ASC LIMIT :count;';
- // Reusing same $args
- $newer_posts = Database::query_objects('Post', $sql, $args);
- if (sizeof($newer_posts) > $count) {
- $prev_date = $newer_posts[array_key_last($newer_posts)]->created;
- $prev_page = '?before=' . $prev_date;
- } else {
- $prev_page = BASE_URL;
- }
- }
- if ($query) {
- if ($prev_page) {
- $prev_page .= (str_contains($prev_page, '?') ? '&' : '?') .
- 'search=' . urlencode($query);
- }
- if ($next_page) {
- $next_page .= (str_contains($next_page, '?') ? '&' : '?') .
- 'search=' . urlencode($query);
- }
- }
-
- return array($posts, $prev_page, $next_page);
- }
-
- /// Fetches a post by its post ID.
- /// @param int $post_id ID of the post.
- /// @return ?Post The Post, or null if not found.
- public static function get_by_id(int $post_id): ?Post {
- $sql = 'SELECT rowid, * FROM posts WHERE rowid=:post_id;';
- $args = array(':post_id' => $post_id);
- return Database::query_object('Post', $sql, $args);
- }
-
- /// Deletes this post.
- public function delete(): void {
- $sql = 'DELETE FROM posts WHERE rowid=:post_id;';
- $args = array(':post_id' => $this->post_id);
- Database::query($sql, $args);
- $this->post_id = null;
- }
-
- /// Update text of post.
- public function update(string $new_body): void {
- $new_body = self::normalize_body($new_body);
- $sql = 'UPDATE posts SET body=:body, updated=:updated WHERE rowid=:rowid;';
- $args = array(
- ':body' => $new_body,
- ':updated' => time(),
- ':rowid' => $this->post_id,
- );
- Database::query($sql, $args);
- }
- }
-
- /// Represents a user.
- class User {
- public static ?User $current = null;
-
- public int $user_id;
- public string $username;
- public string $password_hash;
- public string $timezone;
-
- function __construct(array $row) {
- $this->user_id = $row['user_id'];
- $this->username = $row['username'];
- $this->password_hash = $row['password_hash'];
- $this->timezone = $row['timezone'];
- }
-
- /// Tests if any users exist in the database.
- public static function any_exist(): bool {
- static $has_any = null;
- if ($has_any !== null) return $has_any;
- $rows = Database::query('SELECT * FROM users;');
- $has_any = sizeof($rows) > 0;
- return $has_any;
- }
-
- /// Fetches a user by their user ID.
- public static function get_by_id(int $user_id): ?User {
- $sql = 'SELECT * FROM users WHERE user_id=:user_id LIMIT 1;';
- $args = array(':user_id' => $user_id);
- return Database::query_object('User', $sql, $args);
- }
-
- /// Fetches a user by their username.
- public static function get_by_username(string $username): ?User {
- $sql = 'SELECT * FROM users WHERE username=:username LIMIT 1;';
- $args = array(':username' => $username);
- return Database::query_object('User', $sql, $args);
- }
-
- /// Creates a new user.
- ///
- /// @param string $username Username
- /// @param string $password Password for the user
- /// @param string $timezone User's preferred time zone
- public static function create(
- string $username,
- string $password,
- string $timezone): void {
- trace("Creating user {$username}");
- $sql = 'INSERT OR IGNORE INTO users (username, password_hash, timezone) VALUES (:username, :password_hash, :timezone);';
- $args = array(
- ':username' => $username,
- ':password_hash' => password_hash($password, PASSWORD_DEFAULT),
- ':timezone' => $timezone,
- );
- Database::query($sql, $args);
- }
-
- /// Signs in an existing user and returns their User object, or null if
- /// sign in failed. Sets $_SESSION if successful.
- ///
- /// @param string $username User's username
- /// @param string $password User's password
- /// @return User object if sign in successful, otherwise null
- public static function sign_in(string $username, string $password): ?User {
- $user = self::get_by_username($username);
- if ($user === null) {
- trace("Login failed. No such user {$username}.");
- fatal_error("Login incorrect.");
- return null;
- }
- if (!password_verify($password, $user->password_hash)) {
- trace("Login failed. Bad password for {$username}.");
- fatal_error("Login incorrect.");
- return null;
- }
- trace("Login succeeded for {$username}.");
- $_SESSION[SESSION_KEY_USER_ID] = $user->user_id;
- self::$current = $user;
- Session::create($user->user_id);
- return $user;
- }
-
- /// Updates self::$current from $_SESSION.
- public static function update_current(): void {
- if (self::any_exist() && array_key_exists(SESSION_KEY_USER_ID, $_SESSION) && is_int($_SESSION[SESSION_KEY_USER_ID])) {
- $id = $_SESSION[SESSION_KEY_USER_ID];
- self::$current = User::get_by_id($id);
- } else {
- self::$current = null;
- }
- }
-
- public static function sign_out(): void {
- self::$current = null;
- Session::$current?->delete();
- unset($_SESSION[SESSION_KEY_USER_ID]);
- }
- }
-
- class Session {
- public static ?Session $current = null;
-
- public int $rowid;
- public string $token;
- public int $user_id;
- public int $created;
- public int $updated;
-
- function __construct(array $row) {
- $this->rowid = $row['rowid'];
- $this->token = $row['token'];
- $this->user_id = $row['user_id'];
- $this->created = $row['created'];
- $this->updated = $row['updated'];
- }
-
- /// Logs in from the journal token cookie if needed.
- public static function check_cookie(): void {
- if (User::$current) return;
- if (!array_key_exists(SESSION_COOKIE_NAME, $_COOKIE)) return;
- $token = $_COOKIE[SESSION_COOKIE_NAME];
- trace("Found token cookie");
- self::$current = self::get_by_token($token);
- if (!self::$current) return;
- $user = User::get_by_id(self::$current->user_id);
- if (!$user) {
- self::$current->delete();
- return;
- }
- trace("Found user from cookie");
- User::$current = $user;
- $_SESSION[SESSION_KEY_USER_ID] = $user->user_id;
- self::$current->touch();
- }
-
- public static function create(int $user_id): Session {
- $token = bin2hex(random_bytes(TOKEN_BYTES));
- $time = intval(time());
- $sql = 'INSERT INTO sessions (token, user_id, created, updated) ' .
- 'VALUES (:token, :user_id, :created, :updated);';
- $args = array(
- ':token' => $token,
- ':user_id' => $user_id,
- ':created' => $time,
- ':updated' => $time,
- );
- Database::query($sql, $args);
- $session = self::get_by_token($token);
- $session->touch(false);
- return $session;
- }
-
- public function touch(bool $update_table = true): void {
- $this->updated = time();
- $secure = str_starts_with(BASE_URL, 'https:');
- $expires = intval(time()) + SESSION_COOKIE_TTL_SECONDS;
- trace('Updating cookie to ' . localized_date_string($expires));
- setcookie(SESSION_COOKIE_NAME, $this->token, $expires, path: '/',
- secure: $secure, httponly: true);
- if ($update_table) {
- $sql = 'UPDATE sessions SET updated=:updated WHERE rowid=:rowid;';
- $args = array(':updated' => $this->updated, ':rowid' => $this->rowid);
- Database::query($sql, $args);
- }
- }
-
- public static function get_by_token(string $token): ?Session {
- $sql = 'SELECT rowid, * FROM sessions WHERE token=:token LIMIT 1';
- $args = array(':token' => $token);
- return Database::query_object('Session', $sql, $args);
- }
-
- public static function get_by_user_id(int $user_id): array {
- $sql = 'SELECT rowid, * FROM sessions WHERE user_id=:user_id;';
- $args = array(':user_id' => $user_id);
- return Database::query_objects('Session', $sql, $args);
- }
-
- public static function delete_all_for_user(int $user_id): void {
- $sql = 'DELETE FROM sessions WHERE user_id=:user_id;';
- $args = array(':user_id' => $user_id);
- Database::query($sql, $args);
- }
-
- public function delete(): void {
- $sql = 'DELETE FROM sessions WHERE rowid=:rowid;';
- $args = array(':rowid' => $this->rowid);
- Database::query($sql, $args);
- $secure = str_starts_with(BASE_URL, 'https:');
- setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure, httponly: true);
- unset($_COOKIE[SESSION_COOKIE_NAME]);
- }
- }
-
- // -- Utility classes -------------------------------------
-
- /// Performs database operations.
- class Database {
- /// Tests if the configured database is available.
- public static function exists(): bool {
- return file_exists(DB_PATH);
- }
-
- /// Tests if the configured database is writable.
- public static function is_writable(): bool {
- return is_writable(DB_PATH);
- }
-
- /// Tests if the database file is accessible externally by a predictable
- /// URL. If it is, the user should be warned so they can fix its permissions.
- public static function is_accessible_by_web(): bool {
- if (!str_ends_with(DB_PATH, '/journal.db')) return false;
- $ctx = stream_context_create(array(
- 'http' => array(
- 'timeout' => 1, // shouldn't take long to reach own server
- )
- ));
- $journal_url = strip_filename(BASE_URL);
- if (!str_ends_with($journal_url, '/')) $journal_url .= '/';
- $journal_url .= 'journal.db';
- trace("Testing external accessibility of journal URL. This may take a second.");
- return (file_get_contents($journal_url, false, $ctx) !== false);
- }
-
- /// Performs an SQL query and returns the first row as an object of the
- /// given class. The given class must accept a row array as its only
- /// constructor argument. Adding a "LIMIT 1" clause to the query may help
- /// performance, since only the first result is used.
- ///
- /// @param $classname Class name of the class to use for each result row.
- /// @param $sql Query to execute.
- /// @param $params Map of SQL placeholders to values.
- /// @return ?object Object of given class from first result row, or null
- /// if no rows matched.
- public static function query_object(string $classname, string $sql, array $params = array()): ?object {
- $objs = self::query_objects($classname, $sql, $params);
- return (sizeof($objs) > 0) ? $objs[0] : null;
- }
-
- /// Performs an SQL query and returns the result set as an array of objects
- /// of the given class. The given class must accept a row array as its only
- /// constructor argument.
- ///
- /// @param $classname Class name of the class to use for each result row.
- /// @param $sql Query to execute.
- /// @param $params Map of SQL placeholders to values.
- /// @return array Array of records of the given class.
- public static function query_objects(string $classname, string $sql, array $params = array()): array {
- $rows = self::query($sql, $params);
- $objs = array();
- if ($rows !== false) {
- foreach ($rows as $row) {
- $objs[] = new $classname($row);
- }
- }
- return $objs;
- }
-
- /// Runs a query and returns the complete result. Select queries will return
- /// all rows as an array of row arrays. Insert/update/delete queries will
- /// return a boolean of success.
- ///
- /// @param $sql Query to execute.
- /// @param $params Map of SQL placeholders to values.
- /// @return array Array of arrays. The inner arrays contain both indexed
- /// and named column values.
- public static function query(string $sql, array $params = array()): bool|array {
- $db = new SQLite3(DB_PATH);
- trace('SQL: ' . $sql);
- $stmt = $db->prepare($sql);
- foreach ($params as $name => $value) {
- $type = self::sqlite_type($value);
- $stmt->bindValue($name, $value, $type);
- trace("\tbind {$name} => {$value} ({$type})");
- }
- $result = $stmt->execute();
- if (gettype($result) == 'bool') {
- return $result;
- }
- $rows = array();
- // XXX: Make sure it's a query with results, otherwise fetchArray will
- // cause non-selects (e.g. INSERT) to be executed again.
- if ($result->numColumns()) {
- while ($row = $result->fetchArray()) {
- $rows[] = $row;
- }
- }
- $stmt->close();
- $db->close();
- return $rows;
- }
-
- /// Returns the closest SQLite3 datatype for the given PHP value.
- private static function sqlite_type($value): int {
- switch (gettype($value)) {
- case 'boolean':
- case 'integer':
- return SQLITE3_INTEGER;
- case 'double':
- return SQLITE3_FLOAT;
- case 'string':
- return SQLITE3_TEXT;
- case 'NULL':
- return SQLITE3_NULL;
- default:
- fatal_error("Bad datatype in sqlite statement");
- }
- }
-
- /// Escapes a string for use in a LIKE clause.
- /// @param string $value String to escape.
- /// @param string $escape Character to use for escaping. Default is !
- /// @return string Escaped string
- public static function escape_like(string $value, string $escape='!'): string {
- $s = $value;
- $s = str_replace($escape, $escape . $escape, $s); // escape char
- $s = str_replace('%', $escape . '%', $s); // any chars
- $s = str_replace('_', $escape . '_', $s); // one char
- return $s;
- }
- }
-
- /// App configuration management. All config is stored in the 'config' table in
- /// the sqlite database.
- class Config {
- public static function get_is_configured(): bool {
- return self::get_config_value('is_configured', 'bool') ?: false;
- }
- public static function set_is_configured(bool $is_configured): void {
- self::set_config_value('is_configured', $is_configured);
- }
- public static function get_schema_version(): ?string {
- return self::get_config_value('schema_version', 'string');
- }
- public static function set_schema_version(string $schema_version): void {
- self::set_config_value('schema_version', $schema_version);
- }
-
- /// Fetches a config value from the database, or null if not found.
- private static function get_config_value(string $name, string $type = 'any') {
- $sql = 'SELECT value FROM config WHERE name=:name;';
- $params = array(':name' => $name);
- $rows = Database::query($sql, $params);
- if (sizeof($rows) == 0) return null;
- $value = $rows[0]['value'];
- if ($type != 'any') {
- switch ($type) {
- case 'string': $value = strval($value); break;
- case 'int': $value = intval($value); break;
- case 'bool': $value = boolval($value); break;
- }
- }
- return $value;
- }
-
- /// Saves a config value to the database.
- private static function set_config_value(string $name, $value): void {
- $args = array(':name' => $name, ':value' => $value);
- Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
- Database::query('INSERT OR IGNORE INTO config (name, value) VALUES (:name, :value);', $args);
- }
- }
-
- // -- Misc utility functions ------------------------------
-
- /// Checks whether the app is setup correctly.
- function check_setup(): void {
- // Check user-configurable variables to make sure they aren't default values.
- if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
- fatal_error("Journal not configured. Open index.php in an editor and configure the variables at the top of the file.");
- }
-
- if (!Database::exists()) {
- fatal_error("Database file cannot be found. Check configuration at the top of index.php.");
- }
-
- if (!Database::is_writable()) {
- $user = trim(shell_exec('whoami'));
- $group = trim(shell_exec('id -gn'));
- fatal_error("Database file exists but is not writable by web server user '{$user}' (user group '{$group}'). Check file permissions.");
- }
-
- $schema_version = Config::get_schema_version();
- if ($schema_version === null) {
- fatal_error("No schema version in database. Corrupted?");
- }
-
- if ($schema_version != SCHEMA_VERSION) {
- // TODO: If schema changes, migration paths will go here. For now just fail.
- fatal_error("Unexpected schema version $schema_version.");
- }
-
- if (Config::get_is_configured()) {
- // Already checked the more expensive tests below, so skip.
- return;
- }
-
- // If using default database name, make sure it's not accessible from the
- // web. It'd be far too easy to access.
- if (Database::is_accessible_by_web()) {
- fatal_error("Journal database is accessible from the web! Either alter your .htaccess permissions or rename the database with a UUID filename.");
- }
-
- // Everything looks good!
- Config::set_is_configured(true);
- }
-
- /// Shows the user an error message and terminates the script. If called from
- /// a non-GET request, will first redirec to the main page then display the
- /// message.
- function fatal_error(string $message): never {
- if ($_SERVER['REQUEST_METHOD'] != 'GET') {
- $_SESSION[SESSION_KEY_ERROR] = $message;
- HTMLPage::redirect_home();
- }
- HTMLPage::render_page_start();
- HTMLPage::render_error($message);
- HTMLPage::render_page_end();
- exit();
- }
-
- /// Returns a path or URL without the filename. Result includes trailing /.
- function strip_filename(string $path): string {
- $start = 0;
- if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
- $start = 8;
- }
- $slash = strrpos($path, '/', $start);
- if ($slash === false) return $path + '/';
- return substr($path, 0, $slash + 1);
- }
-
- define('INPUT_TYPE_NONEMPTY', 0x100);
- define('INPUT_TYPE_TRIMMED', 0x200);
- define('INPUT_TYPE_INT', 0x1 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
- define('INPUT_TYPE_STRING', 0x2);
- define('INPUT_TYPE_USERNAME', 0x4 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
- /// Validates and casts a form input.
- /// @param array $source Source array, like $_GET or $_POST.
- /// @param string $name Form input variable name.
- /// @param int $type Bit mask of INPUT_TYPE_ constraints.
- /// @param bool $required Whether the value must be present. Will throw error
- /// if value is required and not found.
- /// @return Cast value, or null if not required and found
- function validate(array $source, string $name, int $type, bool $required = true) {
- if (!array_key_exists($name, $source)) {
- if ($required) {
- fatal_error("Parameter {$name} is required.");
- }
- return null;
- }
- $val = $source[$name];
- if ($type & INPUT_TYPE_TRIMMED) {
- $val = trim($val);
- }
- if (strlen($val) == 0) {
- if ($type & INPUT_TYPE_NONEMPTY) {
- fatal_error("Parameter {$name} cannot be empty.");
- }
- }
- if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
- if (is_numeric($val)) return intval($val);
- fatal_error("Parameter {$name} must be integer.");
- }
- if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
- return $val;
- }
- if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
- if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
- fatal_error("Invalid username. Can be letters, numbers, underscores, and dashes, or an email address.");
- }
- return null;
- }
-
- function localized_date_string(int $timestamp): string {
- $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
- $timezone = User::$current->timezone;
- $formatter = IntlDateFormatter::create(
- $locale_code,
- dateType: IntlDateFormatter::MEDIUM,
- timeType: IntlDateFormatter::MEDIUM,
- timezone: $timezone,
- calendar: IntlDateFormatter::GREGORIAN);
- return $formatter->format($timestamp);
- }
-
- // -- HTML renderers --------------------------------------
-
- class HTMLPage {
- public static function render_page_start(): void {
- print(<<<HTML
- <!DOCTYPE html>
- <html>
- <head>
- <title>Microjournal</title>
- <meta charset="UTF-8" />
- <link rel="stylesheet" type="text/css" href="journal.css" />
- <link rel="apple-touch-icon" sizes="58x58" href="apple-touch-icon-58.png" />
- <link rel="apple-touch-icon" sizes="76x76" href="apple-touch-icon-76.png" />
- <link rel="apple-touch-icon" sizes="80x80" href="apple-touch-icon-80.png" />
- <link rel="apple-touch-icon" sizes="87x87" href="apple-touch-icon-87.png" />
- <link rel="apple-touch-icon" sizes="114x114" href="apple-touch-icon-114.png" />
- <link rel="apple-touch-icon" sizes="120x120" href="apple-touch-icon-120.png" />
- <link rel="apple-touch-icon" sizes="152x152" href="apple-touch-icon-152.png" />
- <link rel="apple-touch-icon" sizes="167x167" href="apple-touch-icon-167.png" />
- <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180.png" />
- <meta name="apple-mobile-web-app-title" content="μJournal" />
- <meta name="apple-mobile-web-app-capable" content="yes" />
- <meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)" />
- <meta name="theme-color" content="#444" media="(prefers-color-scheme: dark)" />
- <meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
- <link rel="icon" sizes="16x16" type="image/png" href="favicon-16.png" />
- <link rel="icon" sizes="32x32" type="image/png" href="favicon-32.png" />
- </head>
- <body>
- <nav>
- <div class="top-nav">
- <span class="title">Microjournal</span>
- HTML
- );
- if (User::$current) {
- print(<<<HTML
- <div class="menu-container">
- <details class="menu">
- <summary class="no-indicator menu-button"><span>☰</span></summary>
-
- <ul>
- <li><a href="?search">Search</a></li>
- <li class="menu-divider"></li>
- <li class="logout-item destructive"><a href="?logout">Log out</a></li>
- </ul>
- </details>
- </div>
- HTML
- );
- }
- print(<<<HTML
- </div>
- </nav>
- <div class="content">
- HTML
- );
- }
-
- public static function render_page_end(): void {
- print(<<<HTML
- </div>
- </body>
- </html>
- HTML
- );
- }
-
- public static function render_error_if_needed(): void {
- if (array_key_exists(SESSION_KEY_ERROR, $_SESSION)) {
- $e = $_SESSION[SESSION_KEY_ERROR];
- unset($_SESSION[SESSION_KEY_ERROR]);
- self::render_error($e);
- }
- }
-
- public static function render_error(string $message): void {
- print("<div class=\"error\">" . htmlentities($message) . "</div>\n");
- }
-
- public static function render_post_form(): void {
- $action = 'post';
- $verb = 'Post';
- $body = '';
- if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
- if ($post = Post::get_by_id($edit_id)) {
- $body = $post->body;
- $action = 'edit';
- $verb = 'Update';
- } else {
- unset($edit_id);
- }
- } elseif (array_key_exists(SESSION_KEY_POST_BODY, $_SESSION)) {
- $body = $_SESSION[SESSION_KEY_POST_BODY];
- unset($_SESSION[SESSION_KEY_POST_BODY]);
- }
- $body_html = htmlentities($body);
- print(<<<HTML
- <form id="post-form" method="POST">
- <div class="text-container"><textarea name="body" placeholder="Your journal post here…">$body_html</textarea></div>
- <input type="hidden" name="action" value="{$action}" />
- HTML
- );
- if ($edit_id) {
- print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
- }
- print(<<<HTML
- <div class="submit-post"><input type="submit" value="{$verb}" /></div>
- </form>
- HTML
- );
- }
-
- public static function render_recent_posts(): void {
- $query = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
- $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
- [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
- print("<div class=\"post-container\">\n");
- if ($prev_url !== null) {
- print("<div class=\"previous\"><a href=\"{$prev_url}\">Previous</a></div>\n");
- }
- foreach ($posts as $post) {
- self::render_post($post);
- }
- if ($next_url !== null) {
- print("<div class=\"next\"><a href=\"{$next_url}\">Next</a></div>\n");
- }
- print("</div>\n");
- }
-
- /// Encodes a post body as HTML. Inserts linebreaks and paragraph tags.
- private static function post_body_html(string $body): string {
- $html_p_start = "<p>";
- $html_p_end = "</p>\n";
- $html_newline = "<br/>\n";
- $body_html = htmlentities($body);
- // Single newlines are turned into linebreaks.
- $body_html = str_replace("\n", $html_newline, $body_html);
- // Pairs of newlines are turned into paragraph separators.
- $body_html = $html_p_start .
- str_replace($html_newline . $html_newline,
- $html_p_end . $html_p_start,
- $body_html) .
- $html_p_end;
- return $body_html;
- }
-
- public static function render_post(Post $post): void {
- $body_html = Markdown::markdown_to_html($post->body);
- $date = localized_date_string($post->created);
- print(<<<HTML
- <div class="post">
- <article>
- <div class="post-body">{$body_html}</div>
- <div class="post-footer">
- <footer class="secondary-text">
- <div class="post-date">
- Posted {$date}
- HTML
- );
- if ($post->updated && $post->updated != $post->created) {
- print('<br/>(updated ' . localized_date_string($post->updated) . ')');
- }
- print(<<<HTML
- </div>
- <details class="post-actions menu">
- <summary class="no-indicator menu-button"><span>actions</span></summary>
-
- <ul>
- <li><a href="?edit={$post->post_id}">Edit</a></li>
- <li class="menu-divider"></li>
- <li class="post-action-delete destructive"><a href="?delete={$post->post_id}">Delete</a></li>
- </ul>
- </details>
- </footer>
- </div>
- </article>
- </div>
- HTML
- );
- }
-
- public static function render_search_form(): void {
- $q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
- $q_html = htmlentities($q);
- $cancel = BASE_URL;
- print(<<<HTML
- <form id="search-form" method="GET">
- <div>
- <label for="search">Search:</label>
- <input type="text" name="search" id="search" value="{$q_html}" autocapitalize="off" />
- <input type="submit" value="Search" />
- <a href="{$cancel}">Cancel</a>
- </div>
- </form>
- HTML
- );
- }
-
- public static function render_sign_in_form(): void {
- print(<<<HTML
- <form id="signin-form" method="POST">
- <div>
- <label for="username">Username:</label>
- <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
- </div>
- <div>
- <label for="password">Password:</label>
- <input type="password" name="password" id="password" />
- </div>
- <input type="hidden" name="action" value="signin" />
- <input type="submit" value="Sign in" />
- </form>
- HTML
- );
- }
-
- public static function render_setup_form(): void {
- print(<<<HTML
- <div class="important">Journal is not setup. Please create a username and password to secure your data.</div>
- <form id="setup-form" method="POST">
- <div>
- <label for="username">Username:</label>
- <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
- </div>
- <div>
- <label for="password">Password:</label>
- <input type="password" id="password" name="password" />
- </div>
- <div>
- <label for="timezone">Time zone:</label>
- <select name="timezone" id="timezone">
- HTML
- );
- foreach (DateTimeZone::listIdentifiers() as $timezone) {
- print("<option value=\"" . htmlentities($timezone) . "\"");
- if ($timezone == TIME_ZONE_DEFAULT) {
- print(" selected=\"selected\"");
- }
- print(">" . htmlentities($timezone) . "</option>\n");
- }
- print(<<<HTML
- </select></div>
- <input type="hidden" name="action" value="createaccount" />
- <div><input type="submit" value="Create account" /></div>
- </form>
- HTML
- );
- }
-
- /// Redirects to the main page. Used after operation requests. Terminates
- /// the script.
- public static function redirect_home(): never {
- header("Location: " . BASE_URL, true, 302); // 302 = found/moved temporarily
- exit();
- }
- }
-
- class HTMLBlock {
- public HTMLBlockType $type;
- public int $indent;
- public string $content_markdown;
-
- function __construct(HTMLBlockType $type, int $indent, string $content_markdown) {
- $this->type = $type;
- $this->indent = $indent;
- $this->content_markdown = $content_markdown;
- }
- }
-
- enum HTMLBlockType {
- case Plain;
- case ListItem;
- case BlockQuote;
- case Preformatted;
- case H1;
- case H2;
- case H3;
- case H4;
- case H5;
- case H6;
- }
-
- class Markdown {
- /// Converts one line of markdown to HTML.
- /// @param string $markdown Markdown string
- /// @return string HTML
- public static function line_markdown_to_html(string $markdown): string {
- $html = htmlentities($markdown);
- // Explicit URL [label](URL)
- $html = preg_replace('|\[(.*?)\]\((.*?)\)|',
- '<a referrerpolicy="no-referrer" target="_new" href="$2">$1</a>', $html);
- // Implicit URL
- $html = preg_replace('|(?<!href=")(http(?:s)?://)(\S+[^\s\.,\?!:;"\'\)])|',
- '<a referrerpolicy="no-referrer" target="_new" href="$1$2">$2</a>', $html);
- // Italic
- $html = preg_replace('/__(\S|\S.*?)__/', '<em>$1</em>', $html);
- // Bold
- $html = preg_replace('/\*\*(\S|\S.*?\S)\*\*/', '<strong>$1</strong>', $html);
- // Strikethrough
- $html = preg_replace('/~~(\S|\S.*?\S)~~/', '<strike>$1</strike>', $html);
- // Code
- $html = preg_replace('/`(\S|\S.*?\S)`/', '<code>$1</code>', $html);
- return $html;
- }
-
- /// Converts markdown into an array of HTMLBlocks.
- /// @param string $markdown Markdown string
- /// @return array Array of HTMLBlocks
- private static function markdown_to_blocks(string $markdown): array {
- $prefix_to_linetype = array(
- '*' => HTMLBlockType::ListItem,
- '-' => HTMLBlockType::ListItem,
- '+' => HTMLBlockType::ListItem,
- '>' => HTMLBlockType::BlockQuote,
- '######' => HTMLBlockType::H6,
- '#####' => HTMLBlockType::H5,
- '####' => HTMLBlockType::H4,
- '###' => HTMLBlockType::H3,
- '##' => HTMLBlockType::H2,
- '#' => HTMLBlockType::H1,
- );
- $blocks = array();
- foreach (explode("\n", $markdown) as $line) {
- $trimmed_line = trim($line);
- $indent = intval(round((strlen($line) - strlen($trimmed_line)) / 4));
- $block_type = HTMLBlockType::Plain;
- $block_content = $trimmed_line;
- foreach ($prefix_to_linetype as $prefix => $type) {
- if ($trimmed_line == $prefix ||
- str_starts_with($trimmed_line, $prefix . ' ')) {
- $block_content = substr($trimmed_line, strlen($prefix));
- $block_type = $type;
- break;
- }
- }
- $blocks[] = new HTMLBlock($block_type, $indent, $block_content);
- }
- return $blocks;
- }
-
- /// Converts markdown to HTML
- public static function markdown_to_html(string $markdown): string {
- return self::blocks_to_html(self::markdown_to_blocks($markdown));
- }
-
- /// Converts an array of HTMLBlocks to HTML.
- private static function blocks_to_html(array $blocks): string {
- $html = '';
- $last_block = null;
- $tag_stack = array(); // stack of end tag strings for current open blocks
- foreach ($blocks as $block) {
- $is_empty = strlen($block->content_markdown) == 0;
- $is_last_empty = strlen($last_block?->content_markdown ?? '') == 0;
- $is_same_block = $block->type == $last_block?->type;
- if (!$is_same_block && sizeof($tag_stack) > 0) {
- foreach (array_reverse($tag_stack) as $tag) {
- $html .= $tag;
- }
- $tag_stack = array();
- }
- switch ($block->type) {
- case HTMLBlockType::Plain:
- if ($is_empty) {
- if ($is_last_empty) {
- // ignore two consecutive empty lines
- } else {
- $html .= array_pop($tag_stack);
- }
- } elseif ($is_last_empty) {
- $html .= "<p>" . self::line_markdown_to_html($block->content_markdown);
- $tag_stack[] = "</p>\n\n";
- } else {
- $html .= "<br/>\n" . self::line_markdown_to_html($block->content_markdown);
- }
- break;
- case HTMLBlockType::ListItem:
- if (!$is_same_block) {
- foreach (array_reverse($tag_stack) as $tag) {
- $html .= $tag;
- }
- $tag_stack = array();
- for ($i = 0; $i <= $block->indent; $i++) {
- $html .= "<ul>\n";
- $html .= "<li>";
- $tag_stack[] = "</ul>\n";
- $tag_stack[] = "</li>\n";
- }
- $html .= self::line_markdown_to_html($block->content_markdown);
- } elseif ($block->indent == $last_block->indent) {
- $html .= "</li>\n";
- $html .= "<li>" . self::line_markdown_to_html($block->content_markdown);
- } elseif ($block->indent > $last_block->indent) {
- // Deeper indent level
- for ($i = $last_block->indent; $i < $block->indent; $i++) {
- $html .= "<ul>\n<li>" . self::line_markdown_to_html($block->content_markdown);
- $tag_stack[] = "</ul>\n";
- $tag_stack[] = "</li>\n";
- }
- } elseif ($block->indent < $last_block->indent) {
- // Shallower indent level
- for ($i = $block->indent; $i < $last_block->indent; $i++) {
- $html .= array_pop($tag_stack);
- $html .= array_pop($tag_stack);
- }
- $html .= "</li>\n";
- $html .= "<li>" . self::line_markdown_to_html($block->content_markdown);
- }
- break;
- case HTMLBlockType::BlockQuote:
- if ($is_same_block) {
- $html .= "<br/>\n";
- } else {
- $html .= "<blockquote>";
- $tag_stack[] = "</blockquote>\n\n";
- }
- $html .= self::line_markdown_to_html($block->content_markdown);
- break;
- case HTMLBlockType::Preformatted:
- if ($is_same_block) {
- $html .= "\n";
- } else {
- $html .= "<pre>";
- $tag_stack[] = "</pre>\n\n";
- }
- $html .= htmlentities($block->content_markdown);
- break;
- case HTMLBlockType::H1:
- $html .= '<h1>' . self::line_markdown_to_html($block->content_markdown) . "</h1>\n\n";
- break;
- case HTMLBlockType::H2:
- $html .= '<h2>' . self::line_markdown_to_html($block->content_markdown) . "</h2>\n\n";
- break;
- case HTMLBlockType::H3:
- $html .= '<h3>' . self::line_markdown_to_html($block->content_markdown) . "</h3>\n\n";
- break;
- case HTMLBlockType::H4:
- $html .= '<h4>' . self::line_markdown_to_html($block->content_markdown) . "</h4>\n\n";
- break;
- case HTMLBlockType::H5:
- $html .= '<h5>' . self::line_markdown_to_html($block->content_markdown) . "</h5>\n\n";
- break;
- case HTMLBlockType::H6:
- $html .= '<h6>' . self::line_markdown_to_html($block->content_markdown) . "</h6>\n\n";
- break;
- }
- $last_block = $block;
- }
- if (sizeof($tag_stack) > 0) {
- foreach (array_reverse($tag_stack) as $tag) {
- $html .= $tag;
- }
- }
- return $html;
- }
- }
-
- // -- Main logic ------------------------------------------
-
- check_setup();
- Session::check_cookie();
- User::update_current();
- switch ($_SERVER['REQUEST_METHOD']) {
- case 'GET':
- if (array_key_exists('logout', $_GET)) {
- User::sign_out();
- HTMLPage::redirect_home();
- }
- HTMLPage::render_page_start();
- HTMLPage::render_error_if_needed();
- if (User::$current) {
- if ($delete_id = validate($_GET, 'delete', INPUT_TYPE_INT, required: false)) {
- Post::get_by_id($delete_id)?->delete();
- HTMLPage::redirect_home();
- }
- if (array_key_exists('search', $_GET)) {
- HTMLPage::render_search_form();
- } else {
- HTMLPage::render_post_form();
- }
- HTMLPage::render_recent_posts();
- } elseif (User::any_exist()) {
- HTMLPage::render_sign_in_form();
- } else {
- HTMLPage::render_setup_form();
- }
- HTMLPage::render_page_end();
- exit();
- case 'POST':
- $nonempty_str_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY | INPUT_TYPE_TRIMMED;
- $password_type = INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY;
- switch ($_POST['action']) {
- case 'post':
- $body = validate($_POST, 'body', $nonempty_str_type);
- $author = User::$current->user_id;
- $created = time();
- if (!User::$current) {
- // Not logged in. Save body for populating once they sign in.
- $_SESSION[SESSION_KEY_POST_BODY] = $body;
- fatal_error('Please sign in to post.');
- }
- Post::create($body, $author, $created);
- break;
- case 'edit':
- $body = validate($_POST, 'body', $nonempty_str_type);
- $edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
- if (!User::$current) {
- // Not logged in. Save body for populating once they sign in.
- fatal_error('Please sign in to edit.');
- }
- Post::get_by_id($edit_id)?->update($body);
- break;
- case 'createaccount':
- $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
- $password = validate($_POST, 'password', $password_type);
- $timezone = validate($_POST, 'timezone', $nonempty_str_type);
- User::create($username, $password, $timezone);
- break;
- case 'signin':
- $username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
- $password = validate($_POST, 'password', $password_type);
- User::sign_in($username, $password);
- break;
- case 'signout':
- User::sign_out();
- break;
- default:
- trace('Invalid POST action: ' . ($_POST['action'] ?: 'null'));
- http_response_code(400);
- exit(1);
- }
- HTMLPage::redirect_home();
- default:
- trace("Invalid request method: " . $_SERVER['REQUEST_METHOD']);
- http_response_code(405);
- exit(1);
- }
- ?>
|