/// 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('SECOND', 1);
define('MINUTE', 60 * SECOND);
define('HOUR', 60 * MINUTE);
define('DAY', 24 * HOUR);
define('SESSION_COOKIE_TTL_SECONDS', 14 * DAY);
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);
}
// -- Localization ----------------------------------------
class L10n {
// General
public const page_title = "Microjournal";
public const mobile_app_icon_title = "μJournal";
// Nav menu
public const hamburger_menu_symbol = "☰";
public const menu_search = "Search";
public const menu_log_out = "Log out";
// Create post form
public const post_form_placeholder = "Your journal post here…";
public const post_form_submit_post = "Post";
public const post_form_submit_update = "Update";
public const post_form_cancel = "Cancel";
// Previous posts
public const posts_previous_page = "Previous";
public const posts_next_page = "Next";
public static function post_created(int $timestamp): string {
$date = self::date_string($timestamp);
$date_full = self::date_string($timestamp, full: true);
$date_iso = self::date_string($timestamp, iso: true);
return "Posted ";
}
public static function post_updated(int $timestamp): string {
$date = self::date_string($timestamp);
$date_full = self::date_string($timestamp, full: true);
$date_iso = self::date_string($timestamp, iso: true);
return "(updated )";
}
public const post_menu = "actions";
public const post_menu_edit = "Edit";
public const post_menu_delete = "Delete";
// Search form
public const search_submit = "Search";
public const search_cancel = "Cancel";
// Login form
public const login_username_label = "Username:";
public const login_password_label = "Password:";
public const login_submit = "Log in";
// Create account form
public const create_prompt = "Journal is not setup. Please create a " .
"username and password to secure your data.";
public const create_username_label = "Username:";
public const create_password_label = "Password:";
public const create_time_zone_label = "Time zone:";
public const create_submit = "Create account";
// Error messages
public const error_login_incorrect = "Login incorrect.";
public const error_bad_sql_datatype = "Bad datatype in sqlite statement.";
public const error_not_configured = "Journal not configured. Open " .
"index.php in an editor and configure the variables at the top of " .
"the file.";
public const error_database_not_found = "Database file cannot be found. " .
"Check configuration at the top of index.php.";
public static function error_database_not_writable(
string $user, string $group): string {
return "Database file exists but is not writable by web server " .
"user '{$user}' (user group '{$group}'). Check file permissions.";
}
public const error_no_schema_version = "No schema version in database. " .
"Corrupted?";
public static function error_unexpected_schema_version(
string $schema_version): string {
return "Unexpected schema version {$schema_version}.";
}
public const error_database_web_accessible = "Journal database is " .
"accessible from the web! Either alter your .htaccess permissions " .
"or rename the database with a UUID filename.";
public static function error_parameter_required(string $name): string {
return "Parameter {$name} is required.";
}
public static function error_parameter_empty(string $name): string {
return "Parameter {$name} cannot be empty.";
}
public static function error_parameter_integer(string $name): string {
return "Parameter {$name} must be integer.";
}
public const error_invalid_username = "Invalid username. Can be letters, " .
"numbers, underscores, and dashes, or an email address.";
public const error_sign_in_to_post = "Please sign in to post.";
public const error_sign_in_to_edit = "Please sign in to edit.";
public static function date_string(int $timestamp, bool $full=false, bool $iso=false): string {
static $fmtTime = null;
static $fmtDayOfWeek = null;
static $fmtMonthDay = null;
static $fmtLong = null;
static $fmtFull = null;
static $fmtIso = null;
$locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
$timezone = User::$current->timezone;
if (!$fmtTime) {
$fmtTime = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::NONE,
timeType: IntlDateFormatter::MEDIUM,
timezone: $timezone);
$fmtDayOfWeek = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::FULL,
timeType: IntlDateFormatter::NONE,
timezone: $timezone,
pattern: "cccc");
$fmtMonthDay = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::MEDIUM,
timeType: IntlDateFormatter::NONE,
timezone: $timezone,
pattern: "MMMM d");
$fmtLong = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::MEDIUM,
timeType: IntlDateFormatter::MEDIUM,
timezone: $timezone);
$fmtFull = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::FULL,
timeType: IntlDateFormatter::FULL,
timezone: $timezone);
$fmtIso = IntlDateFormatter::create(
locale: $locale_code,
dateType: IntlDateFormatter::FULL,
timeType: IntlDateFormatter::FULL,
timezone: $timezone,
pattern: "yyyy-MM-dd'T'HH:mm:ssxxx");
}
$now = time();
if ($full) {
return $fmtFull->format($timestamp);
}
if ($iso) {
return $fmtIso->format($timestamp);
}
if ($timestamp > $now) { // future
return $fmtLong->format($timestamp);
}
if ($now - $timestamp < 12 * HOUR) {
return $fmtTime->format($timestamp);
}
if ($now - $timestamp < 4 * DAY) {
return $fmtDayOfWeek->format($timestamp) . ', ' . $time->format($timestamp);
}
if ($now - $timestamp < 180 * DAY) {
return $fmtMonthDay->format($timestamp) . ', ' . $time->format($timestamp);
}
return $fmtLong->format($timestamp);
}
}
// -- 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
/// 2: relative URL to show next page, or null
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(L10n::error_login_incorrect);
return null;
}
if (!password_verify($password, $user->password_hash)) {
trace("Login failed. Bad password for {$username}.");
fatal_error(L10n::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 ' . L10n::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(L10n::error_bad_sql_datatype);
}
}
/// 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(L10n::error_not_configured);
}
if (!Database::exists()) {
fatal_error(L10n::error_database_not_found);
}
if (!Database::is_writable()) {
$user = trim(shell_exec('whoami'));
$group = trim(shell_exec('id -gn'));
fatal_error(L10n::error_database_not_writable($user, $group));
}
$schema_version = Config::get_schema_version();
if ($schema_version === null) {
fatal_error(L10n::error_no_schema_version);
}
if ($schema_version != SCHEMA_VERSION) {
// TODO: If schema changes, migration paths will go here. For now just fail.
fatal_error(L10n::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(L10n::error_database_web_accessible);
}
// 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(L10n::error_parameter_required($name));
}
return null;
}
$val = $source[$name];
if ($type & INPUT_TYPE_TRIMMED) {
$val = trim($val);
}
if (strlen($val) == 0) {
if ($type & INPUT_TYPE_NONEMPTY) {
fatal_error(L10n::error_parameter_empty($name));
}
}
if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
if (is_numeric($val)) return intval($val);
fatal_error(L10n::error_parameter_integer($name));
}
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(L10n::error_invalid_username);
}
return null;
}
// -- HTML renderers --------------------------------------
class HTMLPage {
public static function render_page_start(): void {
$str_title = L10n::page_title;
$str_app_icon_title = L10n::mobile_app_icon_title;
print(<<
{$str_title}
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("
\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);
$str_posted = L10n::post_created($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;
$str_submit = L10n::search_submit;
$str_cancel = L10n::search_cancel;
print(<<
HTML
);
}
public static function render_sign_in_form(): void {
$str_username = L10n::login_username_label;
$str_password = L10n::login_password_label;
$str_submit = L10n::login_submit;
print(<<
HTML
);
}
public static function render_setup_form(): void {
$str_prompt = L10n::create_prompt;
$str_username = L10n::create_username_label;
$str_password = L10n::create_password_label;
$str_time_zone = L10n::create_time_zone_label;
print(<<{$str_prompt}
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 .= "