/// 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 /// 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'); // -- Core setup ------------------------------------------ session_set_cookie_params(SESSION_COOKIE_TTL_SECONDS, httponly: true); session_start(); $_SESSION['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; function __construct(array $row) { $this->post_id = $row['rowid']; $this->body = $row['body']; $this->author_id = $row['author_id']; $this->created = $row['created']; } /// 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, ?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; } $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 ORDER BY created ASC LIMIT :count;'; $args = array( ':author_id' => $user_id, ':count' => $count + 1, // to see if it's the newest page ':before_time' => $before_time, ); $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; } } return array($posts, $prev_page, $next_page); } } /// Represents a user. class User { public int $user_id; public string $username; public string $password_hash; public string $timezone; public static ?User $current = null; 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['user_id'] = $user->user_id; self::$current = $user; return $user; } /// Updates self::$current from $_SESSION. public static function update_current(): void { if (self::any_exist() && array_key_exists('user_id', $_SESSION) && is_int($_SESSION['user_id'])) { $id = $_SESSION['user_id']; self::$current = User::get_by_id($id); } else { self::$current = null; } } public static function sign_out(): void { self::$current = null; unset($_SESSION['user_id']); } } // -- 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); $stmt = $db->prepare($sql); foreach ($params as $name => $value) { $stmt->bindValue($name, $value, self::sqlite_type($value)); } $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"); } } } /// 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['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('error', $_SESSION)) { $e = $_SESSION['error']; unset($_SESSION['error']); self::render_error($e); } } public static function render_error(string $message): void { print("
" . htmlentities($message) . "
\n"); } public static function render_post_form(): void { print(<<
HTML ); } public static function render_recent_posts(): void { $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false); [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, before_time: $before); print("
\n"); if ($prev_url !== null) { print("\n"); } foreach ($posts as $post) { self::render_post($post); } if ($next_url !== null) { print("\n"); } print("
\n"); } /// Encodes a post body as HTML. Inserts linebreaks and paragraph tags. private static function post_body_html(string $body): string { $body_html = htmlentities($body); // Single newlines are turned into linebreaks. $body_html = str_replace("\n", "
\n", $body_html); // Pairs of newlines are turned into paragraph separators. $paragraphs = explode("
\n
\n", $body_html); $body_html = "

" . implode("

\n\n

", $paragraphs) . "

\n"; return $body_html; } public static function render_post(Post $post): void { $body_html = self::post_body_html($post->body); $date = localized_date_string($post->created); print(<<
{$body_html}
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(); } } // -- Main logic ------------------------------------------ check_setup(); 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) { 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(); Post::create($body, $author, $created); 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); } ?>