A tiny self-hostable "microjournaling" web app. Helps fill the void when abstaining from social media by encouraging journaling small thoughts throughout the day. # Requirements - web host with PHP
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.php 28KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
  1. <?php declare(strict_types=1);
  2. // -- Begin mandatory configuration -----------------------
  3. // Absolute path to the journal.db file
  4. define('DB_PATH', '/path/to/journal.db');
  5. // URL to this page (the index.php is optional if you'd rather lop it off)
  6. define('BASE_URL', 'https://example.com/path/index.php');
  7. // -- End mandatory configuration -------------------------
  8. session_start();
  9. error_log('Requesting ' . $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI']);
  10. function handle_error($error_level=E_WARNING, $error_message='', $error_file='', $error_line=0, $error_context=null): void {
  11. $lvl = 'WARNING';
  12. switch ($error_level) {
  13. case E_WARNING: case E_USER_WARNING: $lvl = 'WARNING'; break;
  14. case E_ERROR: case E_USER_ERROR: $lvl = 'ERROR'; break;
  15. case E_NOTICE: case E_USER_NOTICE: $lvl = 'NOTICE'; break;
  16. }
  17. error_log('[' . $lvl . ' - ' . $error_file . ':' . $error_line . '] ' . $error_message, 0);
  18. }
  19. set_error_handler('handle_error');
  20. error_reporting(E_ERROR);
  21. define('SCHEMA_VERSION', '20230107');
  22. define('PAGE_SIZE', 25);
  23. define('TIME_ZONES', array(
  24. 'Africa/Abidjan',
  25. 'Africa/Accra',
  26. 'Africa/Addis_Ababa',
  27. 'Africa/Algiers',
  28. 'Africa/Asmara',
  29. 'Africa/Bamako',
  30. 'Africa/Bangui',
  31. 'Africa/Banjul',
  32. 'Africa/Bissau',
  33. 'Africa/Blantyre',
  34. 'Africa/Brazzaville',
  35. 'Africa/Bujumbura',
  36. 'Africa/Cairo',
  37. 'Africa/Casablanca',
  38. 'Africa/Ceuta',
  39. 'Africa/Conakry',
  40. 'Africa/Dakar',
  41. 'Africa/Dar_es_Salaam',
  42. 'Africa/Djibouti',
  43. 'Africa/Douala',
  44. 'Africa/El_Aaiun',
  45. 'Africa/Freetown',
  46. 'Africa/Gaborone',
  47. 'Africa/Harare',
  48. 'Africa/Johannesburg',
  49. 'Africa/Juba',
  50. 'Africa/Kampala',
  51. 'Africa/Khartoum',
  52. 'Africa/Kigali',
  53. 'Africa/Kinshasa',
  54. 'Africa/Lagos',
  55. 'Africa/Libreville',
  56. 'Africa/Lome',
  57. 'Africa/Luanda',
  58. 'Africa/Lubumbashi',
  59. 'Africa/Lusaka',
  60. 'Africa/Malabo',
  61. 'Africa/Maputo',
  62. 'Africa/Maseru',
  63. 'Africa/Mbabane',
  64. 'Africa/Mogadishu',
  65. 'Africa/Monrovia',
  66. 'Africa/Nairobi',
  67. 'Africa/Ndjamena',
  68. 'Africa/Niamey',
  69. 'Africa/Nouakchott',
  70. 'Africa/Ouagadougou',
  71. 'Africa/Porto-Novo',
  72. 'Africa/Sao_Tome',
  73. 'Africa/Tripoli',
  74. 'Africa/Tunis',
  75. 'Africa/Windhoek',
  76. 'America/Adak',
  77. 'America/Anchorage',
  78. 'America/Anguilla',
  79. 'America/Antigua',
  80. 'America/Araguaina',
  81. 'America/Argentina/Buenos_Aires',
  82. 'America/Argentina/Catamarca',
  83. 'America/Argentina/Cordoba',
  84. 'America/Argentina/Jujuy',
  85. 'America/Argentina/La_Rioja',
  86. 'America/Argentina/Mendoza',
  87. 'America/Argentina/Rio_Gallegos',
  88. 'America/Argentina/Salta',
  89. 'America/Argentina/San_Juan',
  90. 'America/Argentina/San_Luis',
  91. 'America/Argentina/Tucuman',
  92. 'America/Argentina/Ushuaia',
  93. 'America/Aruba',
  94. 'America/Asuncion',
  95. 'America/Atikokan',
  96. 'America/Bahia',
  97. 'America/Bahia_Banderas',
  98. 'America/Barbados',
  99. 'America/Belem',
  100. 'America/Belize',
  101. 'America/Blanc-Sablon',
  102. 'America/Boa_Vista',
  103. 'America/Bogota',
  104. 'America/Boise',
  105. 'America/Cambridge_Bay',
  106. 'America/Campo_Grande',
  107. 'America/Cancun',
  108. 'America/Caracas',
  109. 'America/Cayenne',
  110. 'America/Cayman',
  111. 'America/Chicago',
  112. 'America/Chihuahua',
  113. 'America/Ciudad_Juarez',
  114. 'America/Costa_Rica',
  115. 'America/Creston',
  116. 'America/Cuiaba',
  117. 'America/Curacao',
  118. 'America/Danmarkshavn',
  119. 'America/Dawson',
  120. 'America/Dawson_Creek',
  121. 'America/Denver',
  122. 'America/Detroit',
  123. 'America/Dominica',
  124. 'America/Edmonton',
  125. 'America/Eirunepe',
  126. 'America/El_Salvador',
  127. 'America/Fort_Nelson',
  128. 'America/Fortaleza',
  129. 'America/Glace_Bay',
  130. 'America/Goose_Bay',
  131. 'America/Grand_Turk',
  132. 'America/Grenada',
  133. 'America/Guadeloupe',
  134. 'America/Guatemala',
  135. 'America/Guayaquil',
  136. 'America/Guyana',
  137. 'America/Halifax',
  138. 'America/Havana',
  139. 'America/Hermosillo',
  140. 'America/Indiana/Indianapolis',
  141. 'America/Indiana/Knox',
  142. 'America/Indiana/Marengo',
  143. 'America/Indiana/Petersburg',
  144. 'America/Indiana/Tell_City',
  145. 'America/Indiana/Vevay',
  146. 'America/Indiana/Vincennes',
  147. 'America/Indiana/Winamac',
  148. 'America/Inuvik',
  149. 'America/Iqaluit',
  150. 'America/Jamaica',
  151. 'America/Juneau',
  152. 'America/Kentucky/Louisville',
  153. 'America/Kentucky/Monticello',
  154. 'America/Kralendijk',
  155. 'America/La_Paz',
  156. 'America/Lima',
  157. 'America/Los_Angeles',
  158. 'America/Lower_Princes',
  159. 'America/Maceio',
  160. 'America/Managua',
  161. 'America/Manaus',
  162. 'America/Marigot',
  163. 'America/Martinique',
  164. 'America/Matamoros',
  165. 'America/Mazatlan',
  166. 'America/Menominee',
  167. 'America/Merida',
  168. 'America/Metlakatla',
  169. 'America/Mexico_City',
  170. 'America/Miquelon',
  171. 'America/Moncton',
  172. 'America/Monterrey',
  173. 'America/Montevideo',
  174. 'America/Montserrat',
  175. 'America/Nassau',
  176. 'America/New_York',
  177. 'America/Nome',
  178. 'America/Noronha',
  179. 'America/North_Dakota/Beulah',
  180. 'America/North_Dakota/Center',
  181. 'America/North_Dakota/New_Salem',
  182. 'America/Nuuk',
  183. 'America/Ojinaga',
  184. 'America/Panama',
  185. 'America/Paramaribo',
  186. 'America/Phoenix',
  187. 'America/Port-au-Prince',
  188. 'America/Port_of_Spain',
  189. 'America/Porto_Velho',
  190. 'America/Puerto_Rico',
  191. 'America/Punta_Arenas',
  192. 'America/Rankin_Inlet',
  193. 'America/Recife',
  194. 'America/Regina',
  195. 'America/Resolute',
  196. 'America/Rio_Branco',
  197. 'America/Santarem',
  198. 'America/Santiago',
  199. 'America/Santo_Domingo',
  200. 'America/Sao_Paulo',
  201. 'America/Scoresbysund',
  202. 'America/Sitka',
  203. 'America/St_Barthelemy',
  204. 'America/St_Johns',
  205. 'America/St_Kitts',
  206. 'America/St_Lucia',
  207. 'America/St_Thomas',
  208. 'America/St_Vincent',
  209. 'America/Swift_Current',
  210. 'America/Tegucigalpa',
  211. 'America/Thule',
  212. 'America/Tijuana',
  213. 'America/Toronto',
  214. 'America/Tortola',
  215. 'America/Vancouver',
  216. 'America/Whitehorse',
  217. 'America/Winnipeg',
  218. 'America/Yakutat',
  219. 'America/Yellowknife',
  220. 'Antarctica/Casey',
  221. 'Antarctica/Davis',
  222. 'Antarctica/DumontDUrville',
  223. 'Antarctica/Macquarie',
  224. 'Antarctica/Mawson',
  225. 'Antarctica/McMurdo',
  226. 'Antarctica/Palmer',
  227. 'Antarctica/Rothera',
  228. 'Antarctica/Syowa',
  229. 'Antarctica/Troll',
  230. 'Antarctica/Vostok',
  231. 'Arctic/Longyearbyen',
  232. 'Asia/Aden',
  233. 'Asia/Almaty',
  234. 'Asia/Amman',
  235. 'Asia/Anadyr',
  236. 'Asia/Aqtau',
  237. 'Asia/Aqtobe',
  238. 'Asia/Ashgabat',
  239. 'Asia/Atyrau',
  240. 'Asia/Baghdad',
  241. 'Asia/Bahrain',
  242. 'Asia/Baku',
  243. 'Asia/Bangkok',
  244. 'Asia/Barnaul',
  245. 'Asia/Beirut',
  246. 'Asia/Bishkek',
  247. 'Asia/Brunei',
  248. 'Asia/Chita',
  249. 'Asia/Choibalsan',
  250. 'Asia/Colombo',
  251. 'Asia/Damascus',
  252. 'Asia/Dhaka',
  253. 'Asia/Dili',
  254. 'Asia/Dubai',
  255. 'Asia/Dushanbe',
  256. 'Asia/Famagusta',
  257. 'Asia/Gaza',
  258. 'Asia/Hebron',
  259. 'Asia/Ho_Chi_Minh',
  260. 'Asia/Hong_Kong',
  261. 'Asia/Hovd',
  262. 'Asia/Irkutsk',
  263. 'Asia/Jakarta',
  264. 'Asia/Jayapura',
  265. 'Asia/Jerusalem',
  266. 'Asia/Kabul',
  267. 'Asia/Kamchatka',
  268. 'Asia/Karachi',
  269. 'Asia/Kathmandu',
  270. 'Asia/Khandyga',
  271. 'Asia/Kolkata',
  272. 'Asia/Krasnoyarsk',
  273. 'Asia/Kuala_Lumpur',
  274. 'Asia/Kuching',
  275. 'Asia/Kuwait',
  276. 'Asia/Macau',
  277. 'Asia/Magadan',
  278. 'Asia/Makassar',
  279. 'Asia/Manila',
  280. 'Asia/Muscat',
  281. 'Asia/Nicosia',
  282. 'Asia/Novokuznetsk',
  283. 'Asia/Novosibirsk',
  284. 'Asia/Omsk',
  285. 'Asia/Oral',
  286. 'Asia/Phnom_Penh',
  287. 'Asia/Pontianak',
  288. 'Asia/Pyongyang',
  289. 'Asia/Qatar',
  290. 'Asia/Qostanay',
  291. 'Asia/Qyzylorda',
  292. 'Asia/Riyadh',
  293. 'Asia/Sakhalin',
  294. 'Asia/Samarkand',
  295. 'Asia/Seoul',
  296. 'Asia/Shanghai',
  297. 'Asia/Singapore',
  298. 'Asia/Srednekolymsk',
  299. 'Asia/Taipei',
  300. 'Asia/Tashkent',
  301. 'Asia/Tbilisi',
  302. 'Asia/Tehran',
  303. 'Asia/Thimphu',
  304. 'Asia/Tokyo',
  305. 'Asia/Tomsk',
  306. 'Asia/Ulaanbaatar',
  307. 'Asia/Urumqi',
  308. 'Asia/Ust-Nera',
  309. 'Asia/Vientiane',
  310. 'Asia/Vladivostok',
  311. 'Asia/Yakutsk',
  312. 'Asia/Yangon',
  313. 'Asia/Yekaterinburg',
  314. 'Asia/Yerevan',
  315. 'Atlantic/Azores',
  316. 'Atlantic/Bermuda',
  317. 'Atlantic/Canary',
  318. 'Atlantic/Cape_Verde',
  319. 'Atlantic/Faroe',
  320. 'Atlantic/Madeira',
  321. 'Atlantic/Reykjavik',
  322. 'Atlantic/South_Georgia',
  323. 'Atlantic/St_Helena',
  324. 'Atlantic/Stanley',
  325. 'Australia/Adelaide',
  326. 'Australia/Brisbane',
  327. 'Australia/Broken_Hill',
  328. 'Australia/Darwin',
  329. 'Australia/Eucla',
  330. 'Australia/Hobart',
  331. 'Australia/Lindeman',
  332. 'Australia/Lord_Howe',
  333. 'Australia/Melbourne',
  334. 'Australia/Perth',
  335. 'Australia/Sydney',
  336. 'Europe/Amsterdam',
  337. 'Europe/Andorra',
  338. 'Europe/Astrakhan',
  339. 'Europe/Athens',
  340. 'Europe/Belgrade',
  341. 'Europe/Berlin',
  342. 'Europe/Bratislava',
  343. 'Europe/Brussels',
  344. 'Europe/Bucharest',
  345. 'Europe/Budapest',
  346. 'Europe/Busingen',
  347. 'Europe/Chisinau',
  348. 'Europe/Copenhagen',
  349. 'Europe/Dublin',
  350. 'Europe/Gibraltar',
  351. 'Europe/Guernsey',
  352. 'Europe/Helsinki',
  353. 'Europe/Isle_of_Man',
  354. 'Europe/Istanbul',
  355. 'Europe/Jersey',
  356. 'Europe/Kaliningrad',
  357. 'Europe/Kirov',
  358. 'Europe/Kyiv',
  359. 'Europe/Lisbon',
  360. 'Europe/Ljubljana',
  361. 'Europe/London',
  362. 'Europe/Luxembourg',
  363. 'Europe/Madrid',
  364. 'Europe/Malta',
  365. 'Europe/Mariehamn',
  366. 'Europe/Minsk',
  367. 'Europe/Monaco',
  368. 'Europe/Moscow',
  369. 'Europe/Oslo',
  370. 'Europe/Paris',
  371. 'Europe/Podgorica',
  372. 'Europe/Prague',
  373. 'Europe/Riga',
  374. 'Europe/Rome',
  375. 'Europe/Samara',
  376. 'Europe/San_Marino',
  377. 'Europe/Sarajevo',
  378. 'Europe/Saratov',
  379. 'Europe/Simferopol',
  380. 'Europe/Skopje',
  381. 'Europe/Sofia',
  382. 'Europe/Stockholm',
  383. 'Europe/Tallinn',
  384. 'Europe/Tirane',
  385. 'Europe/Ulyanovsk',
  386. 'Europe/Vaduz',
  387. 'Europe/Vatican',
  388. 'Europe/Vienna',
  389. 'Europe/Vilnius',
  390. 'Europe/Volgograd',
  391. 'Europe/Warsaw',
  392. 'Europe/Zagreb',
  393. 'Europe/Zurich',
  394. 'Indian/Antananarivo',
  395. 'Indian/Chagos',
  396. 'Indian/Christmas',
  397. 'Indian/Cocos',
  398. 'Indian/Comoro',
  399. 'Indian/Kerguelen',
  400. 'Indian/Mahe',
  401. 'Indian/Maldives',
  402. 'Indian/Mauritius',
  403. 'Indian/Mayotte',
  404. 'Indian/Reunion',
  405. 'Pacific/Apia',
  406. 'Pacific/Auckland',
  407. 'Pacific/Bougainville',
  408. 'Pacific/Chatham',
  409. 'Pacific/Chuuk',
  410. 'Pacific/Easter',
  411. 'Pacific/Efate',
  412. 'Pacific/Fakaofo',
  413. 'Pacific/Fiji',
  414. 'Pacific/Funafuti',
  415. 'Pacific/Galapagos',
  416. 'Pacific/Gambier',
  417. 'Pacific/Guadalcanal',
  418. 'Pacific/Guam',
  419. 'Pacific/Honolulu',
  420. 'Pacific/Kanton',
  421. 'Pacific/Kiritimati',
  422. 'Pacific/Kosrae',
  423. 'Pacific/Kwajalein',
  424. 'Pacific/Majuro',
  425. 'Pacific/Marquesas',
  426. 'Pacific/Midway',
  427. 'Pacific/Nauru',
  428. 'Pacific/Niue',
  429. 'Pacific/Norfolk',
  430. 'Pacific/Noumea',
  431. 'Pacific/Pago_Pago',
  432. 'Pacific/Palau',
  433. 'Pacific/Pitcairn',
  434. 'Pacific/Pohnpei',
  435. 'Pacific/Port_Moresby',
  436. 'Pacific/Rarotonga',
  437. 'Pacific/Saipan',
  438. 'Pacific/Tahiti',
  439. 'Pacific/Tarawa',
  440. 'Pacific/Tongatapu',
  441. 'Pacific/Wake',
  442. 'Pacific/Wallis',
  443. 'UTC'
  444. ));
  445. define('TIME_ZONE_DEFAULT', 'America/Los_Angeles');
  446. // -- Data classes ----------------------------------------
  447. /// Represents a journal post.
  448. class Post {
  449. public int $post_id;
  450. public string $body;
  451. public int $author_id;
  452. public int $created;
  453. function __construct(array $row) {
  454. $this->post_id = $row['rowid'];
  455. $this->body = $row['body'];
  456. $this->author_id = $row['author_id'];
  457. $this->created = $row['created'];
  458. }
  459. /// Creates a new post.
  460. /// @param string $body Text content of the journal entry.
  461. /// @param int $author_id User ID of the post author.
  462. /// @param int $created Unix timestamp of when the post was created.
  463. public static function create(string $body, int $author_id, int $created): void {
  464. error_log('Creating a post "' . $body . '"');
  465. // Make sure we didn't just post the exact same thing (double submit)
  466. $body = str_replace("\r\n", "\n", $body); // normalize CRLF to newlines
  467. $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND created >= :too_new;';
  468. $args = array(
  469. ':body' => $body,
  470. ':author_id' => $author_id,
  471. ':too_new' => $created - 10,
  472. );
  473. if (sizeof(Database::query($sql, $args)) > 0) {
  474. // Ignore double submit
  475. return;
  476. }
  477. $sql = 'INSERT INTO posts (body, author_id, created) VALUES (:body, :author_id, :created);';
  478. $args = array(':body' => $body, ':author_id' => $author_id, ':created' => $created);
  479. Database::query($sql, $args);
  480. }
  481. /// Fetches existing posts, newest first.
  482. /// @param int $user_id User ID of author to fetch posts for.
  483. /// @param int $count Maximum number of posts to return.
  484. /// @param ?int $before_time If provided, only posts older than this Unix
  485. /// timestamp are returned.
  486. /// @return array tuple [ array<Post> $posts, ?string $prev_page, ?string $next_page ]
  487. public static function get_posts(int $user_id, int $count = PAGE_SIZE, ?int $before_time = null): array {
  488. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id';
  489. $args = array(
  490. ':author_id' => $user_id,
  491. ':count' => $count + 1, // to see if there's another page
  492. );
  493. if ($before_time) {
  494. $sql .= ' AND created < :before_time';
  495. $args[':before_time'] = $before_time;
  496. }
  497. $sql .= ' ORDER BY created DESC LIMIT :count;';
  498. $posts = Database::query_objects('Post', $sql, $args);
  499. $prev_page = null;
  500. $next_page = null;
  501. if (sizeof($posts) > $count) {
  502. $posts = array_slice($posts, 0, $count);
  503. $next_page = '?before=' . $posts[array_key_last($posts)]->created;
  504. }
  505. if ($before_time) {
  506. // We're paged forward. Check if there are newer posts.
  507. $sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND created >= :before_time ORDER BY created ASC LIMIT :count;';
  508. $args = array(
  509. ':author_id' => $user_id,
  510. ':count' => $count + 1, // to see if it's the newest page
  511. ':before_time' => $before_time,
  512. );
  513. $newer_posts = Database::query_objects('Post', $sql, $args);
  514. error_log('There are (at least) ' . sizeof($newer_posts) . ' newer posts');
  515. if (sizeof($newer_posts) > $count) {
  516. $prev_page = '?before=' . $newer_posts[array_key_last($newer_posts)]->created;
  517. } else {
  518. $prev_page = BASE_URL;
  519. }
  520. }
  521. return array($posts, $prev_page, $next_page);
  522. }
  523. }
  524. /// Represents a user.
  525. class User {
  526. public int $user_id;
  527. public string $username;
  528. public string $password_hash;
  529. public string $timezone;
  530. public static ?User $current = null;
  531. function __construct(array $row) {
  532. $this->user_id = $row['user_id'];
  533. $this->username = $row['username'];
  534. $this->password_hash = $row['password_hash'];
  535. $this->timezone = $row['timezone'];
  536. }
  537. /// Tests if any users exist in the database.
  538. public static function any_exist(): bool {
  539. static $has_any = null;
  540. if ($has_any !== null) return $has_any;
  541. $rows = Database::query('SELECT * FROM users;');
  542. $has_any = sizeof($rows) > 0;
  543. return $has_any;
  544. }
  545. /// Fetches a user by their user ID.
  546. public static function get_by_id(int $user_id): ?User {
  547. $users = Database::query_objects('User', 'SELECT * FROM users WHERE user_id=:user_id;', array(':user_id' => $user_id));
  548. return (sizeof($users) > 0) ? $users[0] : null;
  549. }
  550. /// Fetches a user by their username.
  551. public static function get_by_username(string $username): ?User {
  552. $users = Database::query_objects('User', 'SELECT * FROM users WHERE username=:username COLLATE NOCASE;', array(':username' => $username));
  553. return (sizeof($users) > 0) ? $users[0] : null;
  554. }
  555. /// Creates a new user.
  556. ///
  557. /// @param string $username Username
  558. /// @param string $password Password for the user
  559. /// @param string $timezone User's preferred time zone
  560. public static function create(string $username, string $password, string $timezone): void {
  561. error_log('Creating user ' . $username);
  562. $sql = 'INSERT OR IGNORE INTO users (username, password_hash, timezone) VALUES (:username, :password_hash, :timezone);';
  563. $args = array(
  564. ':username' => $username,
  565. ':password_hash' => password_hash($password, PASSWORD_DEFAULT),
  566. ':timezone' => $timezone,
  567. );
  568. Database::query($sql, $args);
  569. }
  570. /// Signs in an existing user and returns their User object, or null if
  571. /// sign in failed. Sets $_SESSION if successful.
  572. ///
  573. /// @param string $username User's username
  574. /// @param string $password User's password
  575. /// @return User object if sign in successful, otherwise null
  576. public static function sign_in(string $username, string $password): ?User {
  577. $user = self::get_by_username($username);
  578. if ($user === null) {
  579. error_log('No such user ' . $username);
  580. fatal_error("Login incorrect.");
  581. return null;
  582. }
  583. if (password_verify($password, $user->password_hash)) {
  584. error_log('Username and password matched for ' . $username);
  585. $_SESSION['user_id'] = $user->user_id;
  586. self::$current = $user;
  587. } else {
  588. error_log('Bad password for ' . $username);
  589. fatal_error("Login incorrect.");
  590. }
  591. return $user;
  592. }
  593. public static function sign_out(): void {
  594. unset($_SESSION['user_id']);
  595. }
  596. }
  597. // -- Utility classes -------------------------------------
  598. /// Performs database operations.
  599. class Database {
  600. /// Tests if the configured database is available.
  601. public static function exists(): bool {
  602. return file_exists(DB_PATH);
  603. }
  604. /// Tests if the configured database is writable.
  605. public static function is_writable(): bool {
  606. return is_writable(DB_PATH);
  607. }
  608. /// Tests if the database file is accessible externally. If it is, the
  609. /// user should be warned so they can fix its permissions.
  610. public static function is_accessible_by_web(): bool {
  611. $ctx = stream_context_create(array(
  612. 'http' => array(
  613. 'timeout' => 1, // shouldn't take long to reach own server
  614. )
  615. ));
  616. $journal_url = strip_filename(BASE_URL) . 'journal.db';
  617. error_log('Testing external accessibility of journal URL');
  618. return (file_get_contents($journal_url, false, $ctx) !== false);
  619. }
  620. /// Performs an SQL query and returns the result set as an array of objects
  621. /// of the given class. The given class must accept a row array as its only
  622. /// constructor argument.
  623. ///
  624. /// @param $classname Class name of the class to use for each result row.
  625. /// @param $sql Query to execute.
  626. /// @param $params Map of SQL placeholders to values.
  627. /// @return array Array of records of the given class.
  628. public static function query_objects(string $classname, string $sql, array $params = array()): array {
  629. $rows = self::query($sql, $params);
  630. $objs = array();
  631. foreach ($rows as $row) {
  632. $objs[] = new $classname($row);
  633. }
  634. return $objs;
  635. }
  636. /// Runs a query and returns the complete result. Select queries will return
  637. /// all rows as an array of row arrays. Insert/update/delete queries will
  638. /// return a boolean of success.
  639. ///
  640. /// @param $sql Query to execute.
  641. /// @param $params Map of SQL placeholders to values.
  642. /// @return array Array of arrays. The inner arrays contain both indexed
  643. /// and named column values.
  644. public static function query(string $sql, array $params = array()): bool|array {
  645. $db = new SQLite3(DB_PATH);
  646. $stmt = $db->prepare($sql);
  647. foreach ($params as $name => $value) {
  648. $stmt->bindValue($name, $value, self::sqlite_type($value));
  649. }
  650. $result = $stmt->execute();
  651. if (gettype($result) == 'bool') {
  652. return $result;
  653. }
  654. $rows = array();
  655. // XXX: Make sure it's a query with results, otherwise fetchArray will
  656. // cause non-selects (e.g. INSERT) to be executed twice.
  657. if ($result->numColumns()) {
  658. while ($row = $result->fetchArray()) {
  659. $rows[] = $row;
  660. }
  661. }
  662. $stmt->close();
  663. $db->close();
  664. return $rows;
  665. }
  666. /// Returns the closest SQLite3 datatype for the given PHP value.
  667. private static function sqlite_type($value): int {
  668. switch (gettype($value)) {
  669. case 'boolean':
  670. case 'integer':
  671. return SQLITE3_INTEGER;
  672. case 'double':
  673. return SQLITE3_FLOAT;
  674. case 'string':
  675. return SQLITE3_TEXT;
  676. case 'NULL':
  677. return SQLITE3_NULL;
  678. default:
  679. fatal_error("Bad datatype in sqlite statement");
  680. }
  681. }
  682. }
  683. /// App configuration management. All config is stored in the config table in
  684. /// the sqlite database.
  685. class Config {
  686. public static function get_is_configured(): bool {
  687. return self::get_config_value('is_configured', 'bool') ?: false;
  688. }
  689. public static function set_is_configured(bool $is_configured): void {
  690. self::set_config_value('is_configured', $is_configured);
  691. }
  692. public static function get_schema_version(): ?string {
  693. return self::get_config_value('schema_version', 'string');
  694. }
  695. public static function set_schema_version(string $schema_version): void {
  696. self::set_config_value('schema_version', $schema_version);
  697. }
  698. private static function get_config_value(string $name, string $type = 'any') {
  699. $sql = 'SELECT value FROM config WHERE name=:name;';
  700. $params = array(':name' => $name);
  701. $rows = Database::query($sql, $params);
  702. if (sizeof($rows) == 0) {
  703. error_log('No config value for ' . $name);
  704. return null;
  705. }
  706. $value = $rows[0]['value'];
  707. if ($type != 'any') {
  708. switch ($type) {
  709. case 'string': $value = strval($value); break;
  710. case 'int': $value = intval($value); break;
  711. case 'bool': $value = boolval($value); break;
  712. }
  713. }
  714. error_log('Config value for ' . $name . ' is ' . $value);
  715. return $value;
  716. }
  717. private static function set_config_value(string $name, $value): void {
  718. error_log('Setting config ' . $name . ' to ' . $value);
  719. $args = array(':name' => $name, ':value' => $value);
  720. Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
  721. Database::query('INSERT OR IGNORE INTO config (name, value) VALUES (:name, :value);', $args);
  722. }
  723. }
  724. /// Checks whether the app is setup correctly.
  725. function check_config(): void {
  726. // Check for unconfigured defaults
  727. if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
  728. fatal_error("Journal not configured. Open index.php in an editor and configure the variables at the top of the file.");
  729. }
  730. if (!Database::exists()) {
  731. fatal_error("Database file cannot be found. Check configuration at the top of index.php.");
  732. }
  733. if (!Database::is_writable()) {
  734. fatal_error("Database file exists but is not writable by " . trim(shell_exec('whoami')) . ". Check file permissions.");
  735. }
  736. $schema_version = Config::get_schema_version();
  737. if ($schema_version === null) {
  738. fatal_error("No schema version in database. Corrupted?");
  739. }
  740. if ($schema_version != SCHEMA_VERSION) {
  741. // TODO: Put migration paths here.
  742. fatal_error("Schema version unsupported.");
  743. }
  744. if (Config::get_is_configured()) {
  745. // Already checked these things. Bail out.
  746. return;
  747. }
  748. // If using default database name, make sure it's not accessible from the web.
  749. if (str_contains(DB_PATH, 'journal.db')) {
  750. if (Database::is_accessible_by_web()) {
  751. fatal_error("Journal database is accessible from the web! Either alter your .htaccess permissions or rename the database with a UUID filename.");
  752. }
  753. error_log('Journal not accessible at default URL! :)');
  754. }
  755. Config::set_is_configured(true);
  756. }
  757. function check_auth(): void {
  758. if (User::any_exist() && array_key_exists('user_id', $_SESSION) && is_int($_SESSION['user_id'])) {
  759. $id = $_SESSION['user_id'];
  760. User::$current = User::get_by_id($id);
  761. }
  762. }
  763. function fatal_error(string $message): never {
  764. if ($_SERVER['REQUEST_METHOD'] != 'GET') {
  765. $_SESSION['error'] = $message;
  766. redirect_home();
  767. }
  768. render_page_start();
  769. render_error($message);
  770. render_page_end();
  771. exit();
  772. }
  773. /// Returns a path or URL without the filename. Result includes trailing /.
  774. function strip_filename(string $path): string {
  775. $slash = strrpos($path, '/');
  776. if ($slash === false) return $path;
  777. return substr($path, 0, $slash + 1);
  778. }
  779. function redirect_home(): never {
  780. header('Location: ' . BASE_URL, true, 302); // 302 = found/moved temporarily
  781. exit();
  782. }
  783. define('INPUT_TYPE_INT', 0x1);
  784. define('INPUT_TYPE_STRING', 0x2);
  785. define('INPUT_TYPE_USERNAME', 0x4);
  786. define('INPUT_TYPE_NONEMPTY', 0x100);
  787. function validate(array $source, string $name, int $type, bool $required = true) {
  788. if (!array_key_exists($name, $source)) {
  789. if ($required) {
  790. fatal_error('Parameter ' . $name . ' is required.');
  791. }
  792. return null;
  793. }
  794. $val = $source[$name];
  795. if (strlen($val) == 0) {
  796. if ($type & INPUT_TYPE_NONEMPTY) {
  797. fatal_error('Parameter ' . $name . ' cannot be empty.');
  798. }
  799. }
  800. if ($type & INPUT_TYPE_INT) {
  801. if (is_numeric($val)) return intval($val);
  802. fatal_error('Parameter ' . $name . ' must be integer.');
  803. }
  804. if ($type & INPUT_TYPE_STRING) {
  805. return $val;
  806. }
  807. if ($type & INPUT_TYPE_USERNAME) {
  808. if (preg_match('/[a-zA-Z0-9_@-]+/', $val)) return $val;
  809. fatal_error('Parameter ' . $name . ' is invalid username.');
  810. }
  811. return null;
  812. }
  813. function localized_date_string(int $timestamp): string {
  814. $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
  815. $timezone = User::$current->timezone;
  816. $formatter = IntlDateFormatter::create(
  817. $locale_code,
  818. dateType: IntlDateFormatter::MEDIUM,
  819. timeType: IntlDateFormatter::MEDIUM,
  820. timezone: $timezone,
  821. calendar: IntlDateFormatter::GREGORIAN);
  822. return $formatter->format($timestamp);
  823. }
  824. // -- HTML renderers --------------------------------------
  825. function render_page_start(): void {
  826. ?><!DOCTYPE html>
  827. <html>
  828. <head>
  829. <title>Microjournal</title>
  830. <meta charset="UTF-8" />
  831. <link rel="stylesheet" type="text/css" href="journal.css" />
  832. <meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
  833. </head>
  834. <body><div class="content"><?php
  835. $render_started = true;
  836. }
  837. function render_page_end(): void {
  838. ?></div></body>
  839. </html><?php
  840. }
  841. function render_error_if_needed(): void {
  842. if (array_key_exists('error', $_SESSION)) {
  843. $e = $_SESSION['error'];
  844. unset($_SESSION['error']);
  845. render_error($e);
  846. }
  847. }
  848. function render_error(string $message): void {
  849. print('<div class="error">' . htmlentities($message) . '</div>');
  850. }
  851. function render_post_form(): void {
  852. ?><form id="post-form" method="POST">
  853. <div class="post-container"><textarea name="body" placeholder="Your journal post here…"></textarea></div>
  854. <input type="hidden" name="action" value="post" />
  855. <div class="submit-post"><input type="submit" value="Post" /></div>
  856. </form><?php
  857. }
  858. function render_recent_posts(): void {
  859. $before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
  860. [ $posts, $prev_page, $next_page ] = Post::get_posts(User::$current->user_id, before_time: $before);
  861. $last_post = null;
  862. print('<div class="post-container">');
  863. if ($prev_page !== null) {
  864. print('<div class="previous"><a href="' . $prev_page . '">Previous</a></div>');
  865. }
  866. foreach ($posts as $post) {
  867. render_post($post);
  868. $last_post = $post;
  869. }
  870. if ($next_page !== null) {
  871. print('<div class="next"><a href="' . $next_page . '">Next</a></div>');
  872. }
  873. print('</div>');
  874. }
  875. function render_post(Post $post): void {
  876. $body_html = htmlentities($post->body);
  877. $paragraphs = explode("\n\n", $body_html);
  878. $body_html = '<p>' . implode('</p><p>', $paragraphs) . '</p>';
  879. print('<div class="post">');
  880. print('<article>');
  881. print('<div class="post-body">' . $body_html . '</div>');
  882. print('<footer><div class="post-date">Posted ' . localized_date_string($post->created) . '</div></footer>');
  883. print('</article>');
  884. print('</div>');
  885. }
  886. function render_sign_in_form(): void {
  887. ?><form id="signin-form" method="POST">
  888. <div><label for="username">Username:</label> <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" /></div>
  889. <div><label for="password">Password:</label> <input type="password" name="password" id="password" /></div>
  890. <input type="hidden" name="action" value="signin" />
  891. <input type="submit" value="Sign in" />
  892. </form><?php
  893. }
  894. function render_setup_form(): void {
  895. ?>
  896. <div class="important">Journal is not setup. Please create a username and password to secure your journal.</div>
  897. <form id="setup-form" method="POST">
  898. <div><label for="username">Username:</label>
  899. <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" /></div>
  900. <div><label for="password">Password:</label>
  901. <input type="password" id="password" name="password" /></div>
  902. <div><label for="timezone">Time zone:</label>
  903. <select name="timezone" id="timezone">
  904. <?php
  905. foreach (TIME_ZONES as $timezone) {
  906. print('<option value="' . htmlentities($timezone) . '"');
  907. if ($timezone == TIME_ZONE_DEFAULT) {
  908. print(' selected="selected"');
  909. }
  910. print('>' . htmlentities($timezone) . '</option>');
  911. }
  912. ?>
  913. </select></div>
  914. <input type="hidden" name="action" value="createaccount" />
  915. <div><input type="submit" value="Create account" /></div>
  916. </form><?php
  917. }
  918. // -- Main logic ------------------------------------------
  919. check_config();
  920. check_auth();
  921. switch ($_SERVER['REQUEST_METHOD']) {
  922. case 'GET':
  923. render_page_start();
  924. render_error_if_needed();
  925. if (User::$current) {
  926. render_post_form();
  927. render_recent_posts();
  928. } elseif (User::any_exist()) {
  929. render_sign_in_form();
  930. } else {
  931. render_setup_form();
  932. }
  933. render_page_end();
  934. exit();
  935. case 'POST':
  936. switch ($_POST['action']) {
  937. case 'post':
  938. $body = validate($_POST, 'body', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
  939. $author = $_SESSION['user_id'];
  940. $created = time();
  941. Post::create($body, $author, $created);
  942. break;
  943. case 'createaccount':
  944. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME | INPUT_TYPE_NONEMPTY);
  945. $password = validate($_POST, 'password', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
  946. $timezone = validate($_POST, 'timezone', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
  947. User::create($username, $password, $timezone);
  948. break;
  949. case 'signin':
  950. $username = validate($_POST, 'username', INPUT_TYPE_USERNAME | INPUT_TYPE_NONEMPTY);
  951. $password = validate($_POST, 'password', INPUT_TYPE_STRING | INPUT_TYPE_NONEMPTY);
  952. User::sign_in($username, $password);
  953. break;
  954. case 'signout':
  955. User::sign_out();
  956. break;
  957. default:
  958. error_log('Bad action ' . ($_POST['action'] ?: 'null'));
  959. break;
  960. }
  961. break;
  962. }
  963. redirect_home();
  964. ?>