/// Order allow,deny /// Deny from all /// /// 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('MARKDOWN_ENABLED', true); 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(<< Microjournal
HTML ); } public static function render_page_end(): void { print(<< 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("
" . htmlentities($message) . "
\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 ); if ($edit_id) { print(""); } print(<<
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("
\n"); if ($prev_url !== null) { print("
Previous
\n"); } foreach ($posts as $post) { self::render_post($post); } if ($next_url !== null) { print("
Next
\n"); } print("
\n"); } /// Encodes a post body as HTML. Inserts linebreaks and paragraph tags. private static function post_body_html(string $body): string { $html_p_start = "

"; $html_p_end = "

\n"; $html_newline = "
\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_ENABLED ? Markdown::markdown_to_html($post->body) : self::post_body_html($post->body); $date = localized_date_string($post->created); print(<<
{$body_html}
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(<<
Cancel
HTML ); } public static function render_sign_in_form(): void { print(<<
HTML ); } public static function render_setup_form(): void { print(<<Journal is not setup. Please create a username and password to secure your data.
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('|\[(.*?)\]\((.*?)\)|', '$1', $html); // Implicit URL $html = preg_replace('|(?$2', $html); // Italic $html = preg_replace('/__(\S|\S.*?)__/', '$1', $html); // Bold $html = preg_replace('/\*\*(\S|\S.*?\S)\*\*/', '$1', $html); // Strikethrough $html = preg_replace('/~~(\S|\S.*?\S)~~/', '$1', $html); // Code $html = preg_replace('/`(\S|\S.*?\S)`/', '$1', $html); // Hashtags $html = preg_replace_callback('/(#[a-zA-Z][a-zA-Z0-9_]*)\b/', function($match) { $hashtag = $match[1]; return '' . htmlentities($hashtag) . ''; }, $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 .= "

" . self::line_markdown_to_html($block->content_markdown); $tag_stack[] = "

\n\n"; } else { $html .= "
\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 .= "\n"; $tag_stack[] = "\n"; } $html .= self::line_markdown_to_html($block->content_markdown); } elseif ($block->indent == $last_block->indent) { $html .= "\n"; $html .= "
  • " . 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 .= "
      \n
    • " . self::line_markdown_to_html($block->content_markdown); $tag_stack[] = "
    \n"; $tag_stack[] = "
  • \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 .= "\n"; $html .= "
  • " . self::line_markdown_to_html($block->content_markdown); } break; case HTMLBlockType::BlockQuote: if ($is_same_block) { $html .= "
    \n"; } else { $html .= "
    "; $tag_stack[] = "
    \n\n"; } $html .= self::line_markdown_to_html($block->content_markdown); break; case HTMLBlockType::Preformatted: if ($is_same_block) { $html .= "\n"; } else { $html .= "
    ";
    						$tag_stack[] = "
    \n\n"; } $html .= htmlentities($block->content_markdown); break; case HTMLBlockType::H1: $html .= '

    ' . self::line_markdown_to_html($block->content_markdown) . "

    \n\n"; break; case HTMLBlockType::H2: $html .= '

    ' . self::line_markdown_to_html($block->content_markdown) . "

    \n\n"; break; case HTMLBlockType::H3: $html .= '

    ' . self::line_markdown_to_html($block->content_markdown) . "

    \n\n"; break; case HTMLBlockType::H4: $html .= '

    ' . self::line_markdown_to_html($block->content_markdown) . "

    \n\n"; break; case HTMLBlockType::H5: $html .= '
    ' . self::line_markdown_to_html($block->content_markdown) . "
    \n\n"; break; case HTMLBlockType::H6: $html .= '
    ' . self::line_markdown_to_html($block->content_markdown) . "
    \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); } ?>