| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025 |
- <?php declare(strict_types=1);
-
-
- // -- Begin mandatory configuration -----------------------
-
- // Absolute path to the journal.db file
- define('DB_PATH', '/path/to/journal.db');
-
- // URL to this page (the index.php is optional if you'd rather lop it off)
- define('BASE_URL', 'https://example.com/path/index.php');
-
- // -- End mandatory configuration -------------------------
-
-
-
-
-
-
-
- session_start();
- error_log('Requesting ' . $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI']);
- 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);
-
- define('SCHEMA_VERSION', '20230107');
- define('PAGE_SIZE', 25);
- define('TIME_ZONES', array(
- 'Africa/Abidjan',
- 'Africa/Accra',
- 'Africa/Addis_Ababa',
- 'Africa/Algiers',
- 'Africa/Asmara',
- 'Africa/Bamako',
- 'Africa/Bangui',
- 'Africa/Banjul',
- 'Africa/Bissau',
- 'Africa/Blantyre',
- 'Africa/Brazzaville',
- 'Africa/Bujumbura',
- 'Africa/Cairo',
- 'Africa/Casablanca',
- 'Africa/Ceuta',
- 'Africa/Conakry',
- 'Africa/Dakar',
- 'Africa/Dar_es_Salaam',
- 'Africa/Djibouti',
- 'Africa/Douala',
- 'Africa/El_Aaiun',
- 'Africa/Freetown',
- 'Africa/Gaborone',
- 'Africa/Harare',
- 'Africa/Johannesburg',
- 'Africa/Juba',
- 'Africa/Kampala',
- 'Africa/Khartoum',
- 'Africa/Kigali',
- 'Africa/Kinshasa',
- 'Africa/Lagos',
- 'Africa/Libreville',
- 'Africa/Lome',
- 'Africa/Luanda',
- 'Africa/Lubumbashi',
- 'Africa/Lusaka',
- 'Africa/Malabo',
- 'Africa/Maputo',
- 'Africa/Maseru',
- 'Africa/Mbabane',
- 'Africa/Mogadishu',
- 'Africa/Monrovia',
- 'Africa/Nairobi',
- 'Africa/Ndjamena',
- 'Africa/Niamey',
- 'Africa/Nouakchott',
- 'Africa/Ouagadougou',
- 'Africa/Porto-Novo',
- 'Africa/Sao_Tome',
- 'Africa/Tripoli',
- 'Africa/Tunis',
- 'Africa/Windhoek',
- 'America/Adak',
- 'America/Anchorage',
- 'America/Anguilla',
- 'America/Antigua',
- 'America/Araguaina',
- 'America/Argentina/Buenos_Aires',
- 'America/Argentina/Catamarca',
- 'America/Argentina/Cordoba',
- 'America/Argentina/Jujuy',
- 'America/Argentina/La_Rioja',
- 'America/Argentina/Mendoza',
- 'America/Argentina/Rio_Gallegos',
- 'America/Argentina/Salta',
- 'America/Argentina/San_Juan',
- 'America/Argentina/San_Luis',
- 'America/Argentina/Tucuman',
- 'America/Argentina/Ushuaia',
- 'America/Aruba',
- 'America/Asuncion',
- 'America/Atikokan',
- 'America/Bahia',
- 'America/Bahia_Banderas',
- 'America/Barbados',
- 'America/Belem',
- 'America/Belize',
- 'America/Blanc-Sablon',
- 'America/Boa_Vista',
- 'America/Bogota',
- 'America/Boise',
- 'America/Cambridge_Bay',
- 'America/Campo_Grande',
- 'America/Cancun',
- 'America/Caracas',
- 'America/Cayenne',
- 'America/Cayman',
- 'America/Chicago',
- 'America/Chihuahua',
- 'America/Ciudad_Juarez',
- 'America/Costa_Rica',
- 'America/Creston',
- 'America/Cuiaba',
- 'America/Curacao',
- 'America/Danmarkshavn',
- 'America/Dawson',
- 'America/Dawson_Creek',
- 'America/Denver',
- 'America/Detroit',
- 'America/Dominica',
- 'America/Edmonton',
- 'America/Eirunepe',
- 'America/El_Salvador',
- 'America/Fort_Nelson',
- 'America/Fortaleza',
- 'America/Glace_Bay',
- 'America/Goose_Bay',
- 'America/Grand_Turk',
- 'America/Grenada',
- 'America/Guadeloupe',
- 'America/Guatemala',
- 'America/Guayaquil',
- 'America/Guyana',
- 'America/Halifax',
- 'America/Havana',
- 'America/Hermosillo',
- 'America/Indiana/Indianapolis',
- 'America/Indiana/Knox',
- 'America/Indiana/Marengo',
- 'America/Indiana/Petersburg',
- 'America/Indiana/Tell_City',
- 'America/Indiana/Vevay',
- 'America/Indiana/Vincennes',
- 'America/Indiana/Winamac',
- 'America/Inuvik',
- 'America/Iqaluit',
- 'America/Jamaica',
- 'America/Juneau',
- 'America/Kentucky/Louisville',
- 'America/Kentucky/Monticello',
- 'America/Kralendijk',
- 'America/La_Paz',
- 'America/Lima',
- 'America/Los_Angeles',
- 'America/Lower_Princes',
- 'America/Maceio',
- 'America/Managua',
- 'America/Manaus',
- 'America/Marigot',
- 'America/Martinique',
- 'America/Matamoros',
- 'America/Mazatlan',
- 'America/Menominee',
- 'America/Merida',
- 'America/Metlakatla',
- 'America/Mexico_City',
- 'America/Miquelon',
- 'America/Moncton',
- 'America/Monterrey',
- 'America/Montevideo',
- 'America/Montserrat',
- 'America/Nassau',
- 'America/New_York',
- 'America/Nome',
- 'America/Noronha',
- 'America/North_Dakota/Beulah',
- 'America/North_Dakota/Center',
- 'America/North_Dakota/New_Salem',
- 'America/Nuuk',
- 'America/Ojinaga',
- 'America/Panama',
- 'America/Paramaribo',
- 'America/Phoenix',
- 'America/Port-au-Prince',
- 'America/Port_of_Spain',
- 'America/Porto_Velho',
- 'America/Puerto_Rico',
- 'America/Punta_Arenas',
- 'America/Rankin_Inlet',
- 'America/Recife',
- 'America/Regina',
- 'America/Resolute',
- 'America/Rio_Branco',
- 'America/Santarem',
- 'America/Santiago',
- 'America/Santo_Domingo',
- 'America/Sao_Paulo',
- 'America/Scoresbysund',
- 'America/Sitka',
- 'America/St_Barthelemy',
- 'America/St_Johns',
- 'America/St_Kitts',
- 'America/St_Lucia',
- 'America/St_Thomas',
- 'America/St_Vincent',
- 'America/Swift_Current',
- 'America/Tegucigalpa',
- 'America/Thule',
- 'America/Tijuana',
- 'America/Toronto',
- 'America/Tortola',
- 'America/Vancouver',
- 'America/Whitehorse',
- 'America/Winnipeg',
- 'America/Yakutat',
- 'America/Yellowknife',
- 'Antarctica/Casey',
- 'Antarctica/Davis',
- 'Antarctica/DumontDUrville',
- 'Antarctica/Macquarie',
- 'Antarctica/Mawson',
- 'Antarctica/McMurdo',
- 'Antarctica/Palmer',
- 'Antarctica/Rothera',
- 'Antarctica/Syowa',
- 'Antarctica/Troll',
- 'Antarctica/Vostok',
- 'Arctic/Longyearbyen',
- 'Asia/Aden',
- 'Asia/Almaty',
- 'Asia/Amman',
- 'Asia/Anadyr',
- 'Asia/Aqtau',
- 'Asia/Aqtobe',
- 'Asia/Ashgabat',
- 'Asia/Atyrau',
- 'Asia/Baghdad',
- 'Asia/Bahrain',
- 'Asia/Baku',
- 'Asia/Bangkok',
- 'Asia/Barnaul',
- 'Asia/Beirut',
- 'Asia/Bishkek',
- 'Asia/Brunei',
- 'Asia/Chita',
- 'Asia/Choibalsan',
- 'Asia/Colombo',
- 'Asia/Damascus',
- 'Asia/Dhaka',
- 'Asia/Dili',
- 'Asia/Dubai',
- 'Asia/Dushanbe',
- 'Asia/Famagusta',
- 'Asia/Gaza',
- 'Asia/Hebron',
- 'Asia/Ho_Chi_Minh',
- 'Asia/Hong_Kong',
- 'Asia/Hovd',
- 'Asia/Irkutsk',
- 'Asia/Jakarta',
- 'Asia/Jayapura',
- 'Asia/Jerusalem',
- 'Asia/Kabul',
- 'Asia/Kamchatka',
- 'Asia/Karachi',
- 'Asia/Kathmandu',
- 'Asia/Khandyga',
- 'Asia/Kolkata',
- 'Asia/Krasnoyarsk',
- 'Asia/Kuala_Lumpur',
- 'Asia/Kuching',
- 'Asia/Kuwait',
- 'Asia/Macau',
- 'Asia/Magadan',
- 'Asia/Makassar',
- 'Asia/Manila',
- 'Asia/Muscat',
- 'Asia/Nicosia',
- 'Asia/Novokuznetsk',
- 'Asia/Novosibirsk',
- 'Asia/Omsk',
- 'Asia/Oral',
- 'Asia/Phnom_Penh',
- 'Asia/Pontianak',
- 'Asia/Pyongyang',
- 'Asia/Qatar',
- 'Asia/Qostanay',
- 'Asia/Qyzylorda',
- 'Asia/Riyadh',
- 'Asia/Sakhalin',
- 'Asia/Samarkand',
- 'Asia/Seoul',
- 'Asia/Shanghai',
- 'Asia/Singapore',
- 'Asia/Srednekolymsk',
- 'Asia/Taipei',
- 'Asia/Tashkent',
- 'Asia/Tbilisi',
- 'Asia/Tehran',
- 'Asia/Thimphu',
- 'Asia/Tokyo',
- 'Asia/Tomsk',
- 'Asia/Ulaanbaatar',
- 'Asia/Urumqi',
- 'Asia/Ust-Nera',
- 'Asia/Vientiane',
- 'Asia/Vladivostok',
- 'Asia/Yakutsk',
- 'Asia/Yangon',
- 'Asia/Yekaterinburg',
- 'Asia/Yerevan',
- 'Atlantic/Azores',
- 'Atlantic/Bermuda',
- 'Atlantic/Canary',
- 'Atlantic/Cape_Verde',
- 'Atlantic/Faroe',
- 'Atlantic/Madeira',
- 'Atlantic/Reykjavik',
- 'Atlantic/South_Georgia',
- 'Atlantic/St_Helena',
- 'Atlantic/Stanley',
- 'Australia/Adelaide',
- 'Australia/Brisbane',
- 'Australia/Broken_Hill',
- 'Australia/Darwin',
- 'Australia/Eucla',
- 'Australia/Hobart',
- 'Australia/Lindeman',
- 'Australia/Lord_Howe',
- 'Australia/Melbourne',
- 'Australia/Perth',
- 'Australia/Sydney',
- 'Europe/Amsterdam',
- 'Europe/Andorra',
- 'Europe/Astrakhan',
- 'Europe/Athens',
- 'Europe/Belgrade',
- 'Europe/Berlin',
- 'Europe/Bratislava',
- 'Europe/Brussels',
- 'Europe/Bucharest',
- 'Europe/Budapest',
- 'Europe/Busingen',
- 'Europe/Chisinau',
- 'Europe/Copenhagen',
- 'Europe/Dublin',
- 'Europe/Gibraltar',
- 'Europe/Guernsey',
- 'Europe/Helsinki',
- 'Europe/Isle_of_Man',
- 'Europe/Istanbul',
- 'Europe/Jersey',
- 'Europe/Kaliningrad',
- 'Europe/Kirov',
- 'Europe/Kyiv',
- 'Europe/Lisbon',
- 'Europe/Ljubljana',
- 'Europe/London',
- 'Europe/Luxembourg',
- 'Europe/Madrid',
- 'Europe/Malta',
- 'Europe/Mariehamn',
- 'Europe/Minsk',
- 'Europe/Monaco',
- 'Europe/Moscow',
- 'Europe/Oslo',
- 'Europe/Paris',
- 'Europe/Podgorica',
- 'Europe/Prague',
- 'Europe/Riga',
- 'Europe/Rome',
- 'Europe/Samara',
- 'Europe/San_Marino',
- 'Europe/Sarajevo',
- 'Europe/Saratov',
- 'Europe/Simferopol',
- 'Europe/Skopje',
- 'Europe/Sofia',
- 'Europe/Stockholm',
- 'Europe/Tallinn',
- 'Europe/Tirane',
- 'Europe/Ulyanovsk',
- 'Europe/Vaduz',
- 'Europe/Vatican',
- 'Europe/Vienna',
- 'Europe/Vilnius',
- 'Europe/Volgograd',
- 'Europe/Warsaw',
- 'Europe/Zagreb',
- 'Europe/Zurich',
- 'Indian/Antananarivo',
- 'Indian/Chagos',
- 'Indian/Christmas',
- 'Indian/Cocos',
- 'Indian/Comoro',
- 'Indian/Kerguelen',
- 'Indian/Mahe',
- 'Indian/Maldives',
- 'Indian/Mauritius',
- 'Indian/Mayotte',
- 'Indian/Reunion',
- 'Pacific/Apia',
- 'Pacific/Auckland',
- 'Pacific/Bougainville',
- 'Pacific/Chatham',
- 'Pacific/Chuuk',
- 'Pacific/Easter',
- 'Pacific/Efate',
- 'Pacific/Fakaofo',
- 'Pacific/Fiji',
- 'Pacific/Funafuti',
- 'Pacific/Galapagos',
- 'Pacific/Gambier',
- 'Pacific/Guadalcanal',
- 'Pacific/Guam',
- 'Pacific/Honolulu',
- 'Pacific/Kanton',
- 'Pacific/Kiritimati',
- 'Pacific/Kosrae',
- 'Pacific/Kwajalein',
- 'Pacific/Majuro',
- 'Pacific/Marquesas',
- 'Pacific/Midway',
- 'Pacific/Nauru',
- 'Pacific/Niue',
- 'Pacific/Norfolk',
- 'Pacific/Noumea',
- 'Pacific/Pago_Pago',
- 'Pacific/Palau',
- 'Pacific/Pitcairn',
- 'Pacific/Pohnpei',
- 'Pacific/Port_Moresby',
- 'Pacific/Rarotonga',
- 'Pacific/Saipan',
- 'Pacific/Tahiti',
- 'Pacific/Tarawa',
- 'Pacific/Tongatapu',
- 'Pacific/Wake',
- 'Pacific/Wallis',
- 'UTC'
- ));
- define('TIME_ZONE_DEFAULT', 'America/Los_Angeles');
-
-
- // -- 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'];
- }
-
- /// 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<Post> $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 {
- ?><!DOCTYPE html>
- <html>
- <head>
- <title>Microjournal</title>
- <meta charset="UTF-8" />
- <link rel="stylesheet" type="text/css" href="journal.css" />
- <meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
- </head>
- <body><div class="content"><?php
- $render_started = true;
- }
-
- function render_page_end(): void {
- ?></div></body>
- </html><?php
- }
-
- function render_error_if_needed(): void {
- if (array_key_exists('error', $_SESSION)) {
- $e = $_SESSION['error'];
- unset($_SESSION['error']);
- render_error($e);
- }
- }
-
- function render_error(string $message): void {
- print('<div class="error">' . htmlentities($message) . '</div>');
- }
-
- function render_post_form(): void {
- ?><form id="post-form" method="POST">
- <div class="post-container"><textarea name="body" placeholder="Your journal post here…"></textarea></div>
- <input type="hidden" name="action" value="post" />
- <div class="submit-post"><input type="submit" value="Post" /></div>
- </form><?php
- }
-
- function render_recent_posts(): void {
- $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
- [ $posts, $prev_page, $next_page ] = Post::get_posts(User::$current->user_id, before_time: $before);
- $last_post = null;
- print('<div class="post-container">');
- if ($prev_page !== null) {
- print('<div class="previous"><a href="' . $prev_page . '">Previous</a></div>');
- }
- foreach ($posts as $post) {
- render_post($post);
- $last_post = $post;
- }
- if ($next_page !== null) {
- print('<div class="next"><a href="' . $next_page . '">Next</a></div>');
- }
- print('</div>');
- }
-
- function render_post(Post $post): void {
- $body_html = htmlentities($post->body);
- $paragraphs = explode("\n\n", $body_html);
- $body_html = '<p>' . implode('</p><p>', $paragraphs) . '</p>';
- print('<div class="post">');
- print('<article>');
- print('<div class="post-body">' . $body_html . '</div>');
- print('<footer><div class="post-date">Posted ' . localized_date_string($post->created) . '</div></footer>');
- print('</article>');
- print('</div>');
- }
-
- function render_sign_in_form(): void {
- ?><form id="signin-form" method="POST">
- <div><label for="username">Username:</label> <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" /></div>
- <div><label for="password">Password:</label> <input type="password" name="password" id="password" /></div>
- <input type="hidden" name="action" value="signin" />
- <input type="submit" value="Sign in" />
- </form><?php
- }
-
- function render_setup_form(): void {
- ?>
- <div class="important">Journal is not setup. Please create a username and password to secure your journal.</div>
- <form id="setup-form" method="POST">
- <div><label for="username">Username:</label>
- <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" /></div>
- <div><label for="password">Password:</label>
- <input type="password" id="password" name="password" /></div>
- <div><label for="timezone">Time zone:</label>
- <select name="timezone" id="timezone">
- <?php
- foreach (TIME_ZONES as $timezone) {
- print('<option value="' . htmlentities($timezone) . '"');
- if ($timezone == TIME_ZONE_DEFAULT) {
- print(' selected="selected"');
- }
- print('>' . htmlentities($timezone) . '</option>');
- }
- ?>
- </select></div>
- <input type="hidden" name="action" value="createaccount" />
- <div><input type="submit" value="Create account" /></div>
- </form><?php
- }
-
- // -- Main logic ------------------------------------------
-
- check_config();
- check_auth();
- switch ($_SERVER['REQUEST_METHOD']) {
- case 'GET':
- render_page_start();
- render_error_if_needed();
- if (User::$current) {
- render_post_form();
- render_recent_posts();
- } elseif (User::any_exist()) {
- render_sign_in_form();
- } else {
- render_setup_form();
- }
- render_page_end();
- exit();
- case 'POST':
- switch ($_POST['action']) {
- case 'post':
- $body = validate($_POST, 'body', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
- $author = $_SESSION['user_id'];
- $created = time();
- Post::create($body, $author, $created);
- break;
- case 'createaccount':
- $username = validate($_POST, 'username', INPUT_TYPE_USERNAME | INPUT_TYPE_NONEMPTY);
- $password = validate($_POST, 'password', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
- $timezone = validate($_POST, 'timezone', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
- User::create($username, $password, $timezone);
- break;
- case 'signin':
- $username = validate($_POST, 'username', INPUT_TYPE_USERNAME | INPUT_TYPE_NONEMPTY);
- $password = validate($_POST, 'password', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
- User::sign_in($username, $password);
- break;
- case 'signout':
- User::sign_out();
- break;
- default:
- error_log('Bad action ' . ($_POST['action'] ?: 'null'));
- break;
- }
- break;
- }
- redirect_home();
- ?>
|