/// 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");
}
/// 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':
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);
}
?>