post_id = $row['rowid']; $this->body = $row['body']; $this->author_id = $row['author_id']; $this->created = $row['created']; } /// 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 { error_log('Creating a post "' . $body . '"'); // Make sure we didn't just post the exact same thing (double submit) $body = str_replace("\r\n", "\n", $body); // normalize CRLF to newlines $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND created >= :too_new;'; $args = array( ':body' => $body, ':author_id' => $author_id, ':too_new' => $created - 10, ); if (sizeof(Database::query($sql, $args)) > 0) { // Ignore double submit 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); } /// 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 tuple [ array $posts, ?string $prev_page, ?string $next_page ] public static function get_posts(int $user_id, int $count = PAGE_SIZE, ?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); error_log('There are (at least) ' . sizeof($newer_posts) . ' newer posts'); if (sizeof($newer_posts) > $count) { $prev_page = '?before=' . $newer_posts[array_key_last($newer_posts)]->created; } 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 { $users = Database::query_objects('User', 'SELECT * FROM users WHERE user_id=:user_id;', array(':user_id' => $user_id)); return (sizeof($users) > 0) ? $users[0] : null; } /// Fetches a user by their username. public static function get_by_username(string $username): ?User { $users = Database::query_objects('User', 'SELECT * FROM users WHERE username=:username COLLATE NOCASE;', array(':username' => $username)); return (sizeof($users) > 0) ? $users[0] : null; } /// 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 { error_log('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) { error_log('No such user ' . $username); fatal_error("Login incorrect."); return null; } if (password_verify($password, $user->password_hash)) { error_log('Username and password matched for ' . $username); $_SESSION['user_id'] = $user->user_id; self::$current = $user; } else { error_log('Bad password for ' . $username); fatal_error("Login incorrect."); } return $user; } public static function sign_out(): void { 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. If it is, the /// user should be warned so they can fix its permissions. public static function is_accessible_by_web(): bool { $ctx = stream_context_create(array( 'http' => array( 'timeout' => 1, // shouldn't take long to reach own server ) )); $journal_url = strip_filename(BASE_URL) . 'journal.db'; error_log('Testing external accessibility of journal URL'); return (file_get_contents($journal_url, false, $ctx) !== false); } /// 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(); 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 twice. 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); } 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) { error_log('No config value for ' . $name); 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; } } error_log('Config value for ' . $name . ' is ' . $value); return $value; } private static function set_config_value(string $name, $value): void { error_log('Setting config ' . $name . ' to ' . $value); $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); } } /// Checks whether the app is setup correctly. function check_config(): void { // Check for unconfigured defaults 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()) { fatal_error("Database file exists but is not writable by " . trim(shell_exec('whoami')) . ". 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: Put migration paths here. fatal_error("Schema version unsupported."); } if (Config::get_is_configured()) { // Already checked these things. Bail out. return; } // If using default database name, make sure it's not accessible from the web. if (str_contains(DB_PATH, 'journal.db')) { 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."); } error_log('Journal not accessible at default URL! :)'); } Config::set_is_configured(true); } function check_auth(): void { if (User::any_exist() && array_key_exists('user_id', $_SESSION) && is_int($_SESSION['user_id'])) { $id = $_SESSION['user_id']; User::$current = User::get_by_id($id); } } function fatal_error(string $message): never { if ($_SERVER['REQUEST_METHOD'] != 'GET') { $_SESSION['error'] = $message; redirect_home(); } render_page_start(); render_error($message); render_page_end(); exit(); } /// Returns a path or URL without the filename. Result includes trailing /. function strip_filename(string $path): string { $slash = strrpos($path, '/'); if ($slash === false) return $path; return substr($path, 0, $slash + 1); } function redirect_home(): never { header('Location: ' . BASE_URL, true, 302); // 302 = found/moved temporarily exit(); } define('INPUT_TYPE_INT', 0x1); define('INPUT_TYPE_STRING', 0x2); define('INPUT_TYPE_USERNAME', 0x4); define('INPUT_TYPE_NONEMPTY', 0x100); 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 (strlen($val) == 0) { if ($type & INPUT_TYPE_NONEMPTY) { fatal_error('Parameter ' . $name . ' cannot be empty.'); } } if ($type & INPUT_TYPE_INT) { if (is_numeric($val)) return intval($val); fatal_error('Parameter ' . $name . ' must be integer.'); } if ($type & INPUT_TYPE_STRING) { return $val; } if ($type & INPUT_TYPE_USERNAME) { if (preg_match('/[a-zA-Z0-9_@-]+/', $val)) return $val; fatal_error('Parameter ' . $name . ' is invalid username.'); } 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 -------------------------------------- function render_page_start(): void { ?> Microjournal
' . htmlentities($message) . ''); } function render_post_form(): void { ?>
user_id, before_time: $before); $last_post = null; print('
'); if ($prev_page !== null) { print(''); } foreach ($posts as $post) { render_post($post); $last_post = $post; } if ($next_page !== null) { print(''); } print('
'); } function render_post(Post $post): void { $body_html = htmlentities($post->body); $paragraphs = explode("\n\n", $body_html); $body_html = '

' . implode('

', $paragraphs) . '

'; print('
'); print('
'); print('
' . $body_html . '
'); print('
'); print('
'); print('
'); } function render_sign_in_form(): void { ?>
Journal is not setup. Please create a username and password to secure your journal.