|
|
@@ -58,7 +58,11 @@ define('BASE_URL', 'https://example.com/path/index.php');
|
|
58
|
58
|
/// For detecting database migrations.
|
|
59
|
59
|
define('SCHEMA_VERSION', '20230107');
|
|
60
|
60
|
define('RECENT_POSTS_PER_PAGE', 50);
|
|
61
|
|
-define('SESSION_COOKIE_TTL_SECONDS', 14 * 24 * 60 * 60); // 14 days
|
|
|
61
|
+define('SECOND', 1);
|
|
|
62
|
+define('MINUTE', 60 * SECOND);
|
|
|
63
|
+define('HOUR', 60 * MINUTE);
|
|
|
64
|
+define('DAY', 24 * HOUR);
|
|
|
65
|
+define('SESSION_COOKIE_TTL_SECONDS', 14 * DAY);
|
|
62
|
66
|
define('SESSION_COOKIE_NAME', 'journal_token');
|
|
63
|
67
|
/// If a new post matches an existing post created less than this many seconds
|
|
64
|
68
|
/// ago, the new duplicate post will be ignored.
|
|
|
@@ -94,6 +98,163 @@ function trace(string $message): void {
|
|
94
|
98
|
// error_log($message, 0);
|
|
95
|
99
|
}
|
|
96
|
100
|
|
|
|
101
|
+// -- Localization ----------------------------------------
|
|
|
102
|
+
|
|
|
103
|
+class L10n {
|
|
|
104
|
+ // General
|
|
|
105
|
+ public const page_title = "Microjournal";
|
|
|
106
|
+ public const mobile_app_icon_title = "μJournal";
|
|
|
107
|
+
|
|
|
108
|
+ // Nav menu
|
|
|
109
|
+ public const hamburger_menu_symbol = "☰";
|
|
|
110
|
+ public const menu_search = "Search";
|
|
|
111
|
+ public const menu_log_out = "Log out";
|
|
|
112
|
+
|
|
|
113
|
+ // Create post form
|
|
|
114
|
+ public const post_form_placeholder = "Your journal post here…";
|
|
|
115
|
+ public const post_form_submit_post = "Post";
|
|
|
116
|
+ public const post_form_submit_update = "Update";
|
|
|
117
|
+ public const post_form_cancel = "Cancel";
|
|
|
118
|
+
|
|
|
119
|
+ // Previous posts
|
|
|
120
|
+ public const posts_previous_page = "Previous";
|
|
|
121
|
+ public const posts_next_page = "Next";
|
|
|
122
|
+ public static function post_created(int $timestamp): string {
|
|
|
123
|
+ $date = self::date_string($timestamp);
|
|
|
124
|
+ $date_full = self::date_string($timestamp, full: true);
|
|
|
125
|
+ $date_iso = self::date_string($timestamp, iso: true);
|
|
|
126
|
+ return "Posted <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>";
|
|
|
127
|
+ }
|
|
|
128
|
+ public static function post_updated(int $timestamp): string {
|
|
|
129
|
+ $date = self::date_string($timestamp);
|
|
|
130
|
+ $date_full = self::date_string($timestamp, full: true);
|
|
|
131
|
+ $date_iso = self::date_string($timestamp, iso: true);
|
|
|
132
|
+ return "(updated <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>)";
|
|
|
133
|
+ }
|
|
|
134
|
+ public const post_menu = "actions";
|
|
|
135
|
+ public const post_menu_edit = "Edit";
|
|
|
136
|
+ public const post_menu_delete = "Delete";
|
|
|
137
|
+
|
|
|
138
|
+ // Search form
|
|
|
139
|
+ public const search_submit = "Search";
|
|
|
140
|
+ public const search_cancel = "Cancel";
|
|
|
141
|
+
|
|
|
142
|
+ // Login form
|
|
|
143
|
+ public const login_username_label = "Username:";
|
|
|
144
|
+ public const login_password_label = "Password:";
|
|
|
145
|
+ public const login_submit = "Log in";
|
|
|
146
|
+
|
|
|
147
|
+ // Create account form
|
|
|
148
|
+ public const create_prompt = "Journal is not setup. Please create a " .
|
|
|
149
|
+ "username and password to secure your data.";
|
|
|
150
|
+ public const create_username_label = "Username:";
|
|
|
151
|
+ public const create_password_label = "Password:";
|
|
|
152
|
+ public const create_time_zone_label = "Time zone:";
|
|
|
153
|
+ public const create_submit = "Create account";
|
|
|
154
|
+
|
|
|
155
|
+ // Error messages
|
|
|
156
|
+ public const error_login_incorrect = "Login incorrect.";
|
|
|
157
|
+ public const error_bad_sql_datatype = "Bad datatype in sqlite statement.";
|
|
|
158
|
+ public const error_not_configured = "Journal not configured. Open " .
|
|
|
159
|
+ "index.php in an editor and configure the variables at the top of " .
|
|
|
160
|
+ "the file.";
|
|
|
161
|
+ public const error_database_not_found = "Database file cannot be found. " .
|
|
|
162
|
+ "Check configuration at the top of index.php.";
|
|
|
163
|
+ public static function error_database_not_writable(
|
|
|
164
|
+ string $user, string $group): string {
|
|
|
165
|
+ return "Database file exists but is not writable by web server " .
|
|
|
166
|
+ "user '{$user}' (user group '{$group}'). Check file permissions.";
|
|
|
167
|
+ }
|
|
|
168
|
+ public const error_no_schema_version = "No schema version in database. " .
|
|
|
169
|
+ "Corrupted?";
|
|
|
170
|
+ public static function error_unexpected_schema_version(
|
|
|
171
|
+ string $schema_version): string {
|
|
|
172
|
+ return "Unexpected schema version {$schema_version}.";
|
|
|
173
|
+ }
|
|
|
174
|
+ public const error_database_web_accessible = "Journal database is " .
|
|
|
175
|
+ "accessible from the web! Either alter your .htaccess permissions " .
|
|
|
176
|
+ "or rename the database with a UUID filename.";
|
|
|
177
|
+ public static function error_parameter_required(string $name): string {
|
|
|
178
|
+ return "Parameter {$name} is required.";
|
|
|
179
|
+ }
|
|
|
180
|
+ public static function error_parameter_empty(string $name): string {
|
|
|
181
|
+ return "Parameter {$name} cannot be empty.";
|
|
|
182
|
+ }
|
|
|
183
|
+ public static function error_parameter_integer(string $name): string {
|
|
|
184
|
+ return "Parameter {$name} must be integer.";
|
|
|
185
|
+ }
|
|
|
186
|
+ public const error_invalid_username = "Invalid username. Can be letters, " .
|
|
|
187
|
+ "numbers, underscores, and dashes, or an email address.";
|
|
|
188
|
+ public const error_sign_in_to_post = "Please sign in to post.";
|
|
|
189
|
+ public const error_sign_in_to_edit = "Please sign in to edit.";
|
|
|
190
|
+
|
|
|
191
|
+ public static function date_string(int $timestamp, bool $full=false, bool $iso=false): string {
|
|
|
192
|
+ static $fmtTime = null;
|
|
|
193
|
+ static $fmtDayOfWeek = null;
|
|
|
194
|
+ static $fmtMonthDay = null;
|
|
|
195
|
+ static $fmtLong = null;
|
|
|
196
|
+ static $fmtFull = null;
|
|
|
197
|
+ static $fmtIso = null;
|
|
|
198
|
+ $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
|
|
|
199
|
+ $timezone = User::$current->timezone;
|
|
|
200
|
+ if (!$fmtTime) {
|
|
|
201
|
+ $fmtTime = IntlDateFormatter::create(
|
|
|
202
|
+ locale: $locale_code,
|
|
|
203
|
+ dateType: IntlDateFormatter::NONE,
|
|
|
204
|
+ timeType: IntlDateFormatter::MEDIUM,
|
|
|
205
|
+ timezone: $timezone);
|
|
|
206
|
+ $fmtDayOfWeek = IntlDateFormatter::create(
|
|
|
207
|
+ locale: $locale_code,
|
|
|
208
|
+ dateType: IntlDateFormatter::FULL,
|
|
|
209
|
+ timeType: IntlDateFormatter::NONE,
|
|
|
210
|
+ timezone: $timezone,
|
|
|
211
|
+ pattern: "cccc");
|
|
|
212
|
+ $fmtMonthDay = IntlDateFormatter::create(
|
|
|
213
|
+ locale: $locale_code,
|
|
|
214
|
+ dateType: IntlDateFormatter::MEDIUM,
|
|
|
215
|
+ timeType: IntlDateFormatter::NONE,
|
|
|
216
|
+ timezone: $timezone,
|
|
|
217
|
+ pattern: "MMMM d");
|
|
|
218
|
+ $fmtLong = IntlDateFormatter::create(
|
|
|
219
|
+ locale: $locale_code,
|
|
|
220
|
+ dateType: IntlDateFormatter::MEDIUM,
|
|
|
221
|
+ timeType: IntlDateFormatter::MEDIUM,
|
|
|
222
|
+ timezone: $timezone);
|
|
|
223
|
+ $fmtFull = IntlDateFormatter::create(
|
|
|
224
|
+ locale: $locale_code,
|
|
|
225
|
+ dateType: IntlDateFormatter::FULL,
|
|
|
226
|
+ timeType: IntlDateFormatter::FULL,
|
|
|
227
|
+ timezone: $timezone);
|
|
|
228
|
+ $fmtIso = IntlDateFormatter::create(
|
|
|
229
|
+ locale: $locale_code,
|
|
|
230
|
+ dateType: IntlDateFormatter::FULL,
|
|
|
231
|
+ timeType: IntlDateFormatter::FULL,
|
|
|
232
|
+ timezone: $timezone,
|
|
|
233
|
+ pattern: "yyyy-MM-dd'T'HH:mm:ssxxx");
|
|
|
234
|
+ }
|
|
|
235
|
+ $now = time();
|
|
|
236
|
+ if ($full) {
|
|
|
237
|
+ return $fmtFull->format($timestamp);
|
|
|
238
|
+ }
|
|
|
239
|
+ if ($iso) {
|
|
|
240
|
+ return $fmtIso->format($timestamp);
|
|
|
241
|
+ }
|
|
|
242
|
+ if ($timestamp > $now) { // future
|
|
|
243
|
+ return $fmtLong->format($timestamp);
|
|
|
244
|
+ }
|
|
|
245
|
+ if ($now - $timestamp < 12 * HOUR) {
|
|
|
246
|
+ return $fmtTime->format($timestamp);
|
|
|
247
|
+ }
|
|
|
248
|
+ if ($now - $timestamp < 4 * DAY) {
|
|
|
249
|
+ return $fmtDayOfWeek->format($timestamp) . ', ' . $time->format($timestamp);
|
|
|
250
|
+ }
|
|
|
251
|
+ if ($now - $timestamp < 180 * DAY) {
|
|
|
252
|
+ return $fmtMonthDay->format($timestamp) . ', ' . $time->format($timestamp);
|
|
|
253
|
+ }
|
|
|
254
|
+ return $fmtLong->format($timestamp);
|
|
|
255
|
+ }
|
|
|
256
|
+}
|
|
|
257
|
+
|
|
97
|
258
|
// -- Data classes ----------------------------------------
|
|
98
|
259
|
|
|
99
|
260
|
/// Represents a journal post.
|
|
|
@@ -134,8 +295,13 @@ class Post {
|
|
134
|
295
|
trace("Same post already created recently. Skipping.");
|
|
135
|
296
|
return;
|
|
136
|
297
|
}
|
|
137
|
|
- $sql = 'INSERT INTO posts (body, author_id, created) VALUES (:body, :author_id, :created);';
|
|
138
|
|
- $args = array(':body' => $body, ':author_id' => $author_id, ':created' => $created);
|
|
|
298
|
+ $sql = 'INSERT INTO posts (body, author_id, created) VALUES ' .
|
|
|
299
|
+ '(:body, :author_id, :created);';
|
|
|
300
|
+ $args = array(
|
|
|
301
|
+ ':body' => $body,
|
|
|
302
|
+ ':author_id' => $author_id,
|
|
|
303
|
+ ':created' => $created,
|
|
|
304
|
+ );
|
|
139
|
305
|
Database::query($sql, $args);
|
|
140
|
306
|
}
|
|
141
|
307
|
|
|
|
@@ -149,7 +315,8 @@ class Post {
|
|
149
|
315
|
string $body,
|
|
150
|
316
|
int $author_id,
|
|
151
|
317
|
int $created): bool {
|
|
152
|
|
- $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND ABS(created - :created) < :seconds LIMIT 1;';
|
|
|
318
|
+ $sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id ' .
|
|
|
319
|
+ 'AND ABS(created - :created) < :seconds LIMIT 1;';
|
|
153
|
320
|
$args = array(
|
|
154
|
321
|
':body' => $body,
|
|
155
|
322
|
':author_id' => $author_id,
|
|
|
@@ -165,8 +332,8 @@ class Post {
|
|
165
|
332
|
/// @param ?int $before_time If provided, only posts older than this Unix
|
|
166
|
333
|
/// timestamp are returned.
|
|
167
|
334
|
/// @return array Three-element tuple - 0: array of Posts,
|
|
168
|
|
- /// 1: relative URL to show previous page, or null if none
|
|
169
|
|
- /// 2: relative URL to show next page, or null if none
|
|
|
335
|
+ /// 1: relative URL to show previous page, or null
|
|
|
336
|
+ /// 2: relative URL to show next page, or null
|
|
170
|
337
|
public static function get_posts(
|
|
171
|
338
|
int $user_id,
|
|
172
|
339
|
int $count = RECENT_POSTS_PER_PAGE,
|
|
|
@@ -203,7 +370,8 @@ class Post {
|
|
203
|
370
|
if ($before_time) {
|
|
204
|
371
|
// We're paged forward. Check if there are newer posts.
|
|
205
|
372
|
$sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
|
|
206
|
|
- 'created >= :before_time ' . $search_where . ' ORDER BY created ASC LIMIT :count;';
|
|
|
373
|
+ 'created >= :before_time ' . $search_where .
|
|
|
374
|
+ ' ORDER BY created ASC LIMIT :count;';
|
|
207
|
375
|
// Reusing same $args
|
|
208
|
376
|
$newer_posts = Database::query_objects('Post', $sql, $args);
|
|
209
|
377
|
if (sizeof($newer_posts) > $count) {
|
|
|
@@ -325,12 +493,12 @@ class User {
|
|
325
|
493
|
$user = self::get_by_username($username);
|
|
326
|
494
|
if ($user === null) {
|
|
327
|
495
|
trace("Login failed. No such user {$username}.");
|
|
328
|
|
- fatal_error("Login incorrect.");
|
|
|
496
|
+ fatal_error(L10n::error_login_incorrect);
|
|
329
|
497
|
return null;
|
|
330
|
498
|
}
|
|
331
|
499
|
if (!password_verify($password, $user->password_hash)) {
|
|
332
|
500
|
trace("Login failed. Bad password for {$username}.");
|
|
333
|
|
- fatal_error("Login incorrect.");
|
|
|
501
|
+ fatal_error(L10n::error_login_incorrect);
|
|
334
|
502
|
return null;
|
|
335
|
503
|
}
|
|
336
|
504
|
trace("Login succeeded for {$username}.");
|
|
|
@@ -342,7 +510,9 @@ class User {
|
|
342
|
510
|
|
|
343
|
511
|
/// Updates self::$current from $_SESSION.
|
|
344
|
512
|
public static function update_current(): void {
|
|
345
|
|
- if (self::any_exist() && array_key_exists(SESSION_KEY_USER_ID, $_SESSION) && is_int($_SESSION[SESSION_KEY_USER_ID])) {
|
|
|
513
|
+ if (self::any_exist() &&
|
|
|
514
|
+ array_key_exists(SESSION_KEY_USER_ID, $_SESSION) &&
|
|
|
515
|
+ is_int($_SESSION[SESSION_KEY_USER_ID])) {
|
|
346
|
516
|
$id = $_SESSION[SESSION_KEY_USER_ID];
|
|
347
|
517
|
self::$current = User::get_by_id($id);
|
|
348
|
518
|
} else {
|
|
|
@@ -414,12 +584,15 @@ class Session {
|
|
414
|
584
|
$this->updated = time();
|
|
415
|
585
|
$secure = str_starts_with(BASE_URL, 'https:');
|
|
416
|
586
|
$expires = intval(time()) + SESSION_COOKIE_TTL_SECONDS;
|
|
417
|
|
- trace('Updating cookie to ' . localized_date_string($expires));
|
|
|
587
|
+ trace('Updating cookie to ' . L10n::date_string($expires));
|
|
418
|
588
|
setcookie(SESSION_COOKIE_NAME, $this->token, $expires, path: '/',
|
|
419
|
589
|
secure: $secure, httponly: true);
|
|
420
|
590
|
if ($update_table) {
|
|
421
|
591
|
$sql = 'UPDATE sessions SET updated=:updated WHERE rowid=:rowid;';
|
|
422
|
|
- $args = array(':updated' => $this->updated, ':rowid' => $this->rowid);
|
|
|
592
|
+ $args = array(
|
|
|
593
|
+ ':updated' => $this->updated,
|
|
|
594
|
+ ':rowid' => $this->rowid,
|
|
|
595
|
+ );
|
|
423
|
596
|
Database::query($sql, $args);
|
|
424
|
597
|
}
|
|
425
|
598
|
}
|
|
|
@@ -447,7 +620,8 @@ class Session {
|
|
447
|
620
|
$args = array(':rowid' => $this->rowid);
|
|
448
|
621
|
Database::query($sql, $args);
|
|
449
|
622
|
$secure = str_starts_with(BASE_URL, 'https:');
|
|
450
|
|
- setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure, httponly: true);
|
|
|
623
|
+ setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure,
|
|
|
624
|
+ httponly: true);
|
|
451
|
625
|
unset($_COOKIE[SESSION_COOKIE_NAME]);
|
|
452
|
626
|
}
|
|
453
|
627
|
}
|
|
|
@@ -492,7 +666,10 @@ class Database {
|
|
492
|
666
|
/// @param $params Map of SQL placeholders to values.
|
|
493
|
667
|
/// @return ?object Object of given class from first result row, or null
|
|
494
|
668
|
/// if no rows matched.
|
|
495
|
|
- public static function query_object(string $classname, string $sql, array $params = array()): ?object {
|
|
|
669
|
+ public static function query_object(
|
|
|
670
|
+ string $classname,
|
|
|
671
|
+ string $sql,
|
|
|
672
|
+ array $params = array()): ?object {
|
|
496
|
673
|
$objs = self::query_objects($classname, $sql, $params);
|
|
497
|
674
|
return (sizeof($objs) > 0) ? $objs[0] : null;
|
|
498
|
675
|
}
|
|
|
@@ -505,7 +682,10 @@ class Database {
|
|
505
|
682
|
/// @param $sql Query to execute.
|
|
506
|
683
|
/// @param $params Map of SQL placeholders to values.
|
|
507
|
684
|
/// @return array Array of records of the given class.
|
|
508
|
|
- public static function query_objects(string $classname, string $sql, array $params = array()): array {
|
|
|
685
|
+ public static function query_objects(
|
|
|
686
|
+ string $classname,
|
|
|
687
|
+ string $sql,
|
|
|
688
|
+ array $params = array()): array {
|
|
509
|
689
|
$rows = self::query($sql, $params);
|
|
510
|
690
|
$objs = array();
|
|
511
|
691
|
if ($rows !== false) {
|
|
|
@@ -524,7 +704,9 @@ class Database {
|
|
524
|
704
|
/// @param $params Map of SQL placeholders to values.
|
|
525
|
705
|
/// @return array Array of arrays. The inner arrays contain both indexed
|
|
526
|
706
|
/// and named column values.
|
|
527
|
|
- public static function query(string $sql, array $params = array()): bool|array {
|
|
|
707
|
+ public static function query(
|
|
|
708
|
+ string $sql,
|
|
|
709
|
+ array $params = array()): bool|array {
|
|
528
|
710
|
$db = new SQLite3(DB_PATH);
|
|
529
|
711
|
trace('SQL: ' . $sql);
|
|
530
|
712
|
$stmt = $db->prepare($sql);
|
|
|
@@ -563,7 +745,7 @@ class Database {
|
|
563
|
745
|
case 'NULL':
|
|
564
|
746
|
return SQLITE3_NULL;
|
|
565
|
747
|
default:
|
|
566
|
|
- fatal_error("Bad datatype in sqlite statement");
|
|
|
748
|
+ fatal_error(L10n::error_bad_sql_datatype);
|
|
567
|
749
|
}
|
|
568
|
750
|
}
|
|
569
|
751
|
|
|
|
@@ -617,7 +799,8 @@ class Config {
|
|
617
|
799
|
private static function set_config_value(string $name, $value): void {
|
|
618
|
800
|
$args = array(':name' => $name, ':value' => $value);
|
|
619
|
801
|
Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
|
|
620
|
|
- Database::query('INSERT OR IGNORE INTO config (name, value) VALUES (:name, :value);', $args);
|
|
|
802
|
+ Database::query('INSERT OR IGNORE INTO config (name, value) ' .
|
|
|
803
|
+ 'VALUES (:name, :value);', $args);
|
|
621
|
804
|
}
|
|
622
|
805
|
}
|
|
623
|
806
|
|
|
|
@@ -627,27 +810,27 @@ class Config {
|
|
627
|
810
|
function check_setup(): void {
|
|
628
|
811
|
// Check user-configurable variables to make sure they aren't default values.
|
|
629
|
812
|
if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
|
|
630
|
|
- fatal_error("Journal not configured. Open index.php in an editor and configure the variables at the top of the file.");
|
|
|
813
|
+ fatal_error(L10n::error_not_configured);
|
|
631
|
814
|
}
|
|
632
|
815
|
|
|
633
|
816
|
if (!Database::exists()) {
|
|
634
|
|
- fatal_error("Database file cannot be found. Check configuration at the top of index.php.");
|
|
|
817
|
+ fatal_error(L10n::error_database_not_found);
|
|
635
|
818
|
}
|
|
636
|
819
|
|
|
637
|
820
|
if (!Database::is_writable()) {
|
|
638
|
821
|
$user = trim(shell_exec('whoami'));
|
|
639
|
822
|
$group = trim(shell_exec('id -gn'));
|
|
640
|
|
- fatal_error("Database file exists but is not writable by web server user '{$user}' (user group '{$group}'). Check file permissions.");
|
|
|
823
|
+ fatal_error(L10n::error_database_not_writable($user, $group));
|
|
641
|
824
|
}
|
|
642
|
825
|
|
|
643
|
826
|
$schema_version = Config::get_schema_version();
|
|
644
|
827
|
if ($schema_version === null) {
|
|
645
|
|
- fatal_error("No schema version in database. Corrupted?");
|
|
|
828
|
+ fatal_error(L10n::error_no_schema_version);
|
|
646
|
829
|
}
|
|
647
|
830
|
|
|
648
|
831
|
if ($schema_version != SCHEMA_VERSION) {
|
|
649
|
832
|
// TODO: If schema changes, migration paths will go here. For now just fail.
|
|
650
|
|
- fatal_error("Unexpected schema version $schema_version.");
|
|
|
833
|
+ fatal_error(L10n::error_unexpected_schema_version($schema_version));
|
|
651
|
834
|
}
|
|
652
|
835
|
|
|
653
|
836
|
if (Config::get_is_configured()) {
|
|
|
@@ -658,7 +841,7 @@ function check_setup(): void {
|
|
658
|
841
|
// If using default database name, make sure it's not accessible from the
|
|
659
|
842
|
// web. It'd be far too easy to access.
|
|
660
|
843
|
if (Database::is_accessible_by_web()) {
|
|
661
|
|
- fatal_error("Journal database is accessible from the web! Either alter your .htaccess permissions or rename the database with a UUID filename.");
|
|
|
844
|
+ fatal_error(L10n::error_database_web_accessible);
|
|
662
|
845
|
}
|
|
663
|
846
|
|
|
664
|
847
|
// Everything looks good!
|
|
|
@@ -705,7 +888,7 @@ define('INPUT_TYPE_USERNAME', 0x4 | INPUT_TYPE_TRIMMED | INPUT_TYPE_NONEMPTY);
|
|
705
|
888
|
function validate(array $source, string $name, int $type, bool $required = true) {
|
|
706
|
889
|
if (!array_key_exists($name, $source)) {
|
|
707
|
890
|
if ($required) {
|
|
708
|
|
- fatal_error("Parameter {$name} is required.");
|
|
|
891
|
+ fatal_error(L10n::error_parameter_required($name));
|
|
709
|
892
|
}
|
|
710
|
893
|
return null;
|
|
711
|
894
|
}
|
|
|
@@ -715,44 +898,34 @@ function validate(array $source, string $name, int $type, bool $required = true)
|
|
715
|
898
|
}
|
|
716
|
899
|
if (strlen($val) == 0) {
|
|
717
|
900
|
if ($type & INPUT_TYPE_NONEMPTY) {
|
|
718
|
|
- fatal_error("Parameter {$name} cannot be empty.");
|
|
|
901
|
+ fatal_error(L10n::error_parameter_empty($name));
|
|
719
|
902
|
}
|
|
720
|
903
|
}
|
|
721
|
904
|
if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
|
|
722
|
905
|
if (is_numeric($val)) return intval($val);
|
|
723
|
|
- fatal_error("Parameter {$name} must be integer.");
|
|
|
906
|
+ fatal_error(L10n::error_parameter_integer($name));
|
|
724
|
907
|
}
|
|
725
|
908
|
if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
|
|
726
|
909
|
return $val;
|
|
727
|
910
|
}
|
|
728
|
911
|
if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
|
|
729
|
912
|
if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
|
|
730
|
|
- fatal_error("Invalid username. Can be letters, numbers, underscores, and dashes, or an email address.");
|
|
|
913
|
+ fatal_error(L10n::error_invalid_username);
|
|
731
|
914
|
}
|
|
732
|
915
|
return null;
|
|
733
|
916
|
}
|
|
734
|
917
|
|
|
735
|
|
-function localized_date_string(int $timestamp): string {
|
|
736
|
|
- $locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
|
|
737
|
|
- $timezone = User::$current->timezone;
|
|
738
|
|
- $formatter = IntlDateFormatter::create(
|
|
739
|
|
- $locale_code,
|
|
740
|
|
- dateType: IntlDateFormatter::MEDIUM,
|
|
741
|
|
- timeType: IntlDateFormatter::MEDIUM,
|
|
742
|
|
- timezone: $timezone,
|
|
743
|
|
- calendar: IntlDateFormatter::GREGORIAN);
|
|
744
|
|
- return $formatter->format($timestamp);
|
|
745
|
|
-}
|
|
746
|
|
-
|
|
747
|
918
|
// -- HTML renderers --------------------------------------
|
|
748
|
919
|
|
|
749
|
920
|
class HTMLPage {
|
|
750
|
921
|
public static function render_page_start(): void {
|
|
|
922
|
+ $str_title = L10n::page_title;
|
|
|
923
|
+ $str_app_icon_title = L10n::mobile_app_icon_title;
|
|
751
|
924
|
print(<<<HTML
|
|
752
|
925
|
<!DOCTYPE html>
|
|
753
|
926
|
<html>
|
|
754
|
927
|
<head>
|
|
755
|
|
- <title>Microjournal</title>
|
|
|
928
|
+ <title>{$str_title}</title>
|
|
756
|
929
|
<meta charset="UTF-8" />
|
|
757
|
930
|
<link rel="stylesheet" type="text/css" href="journal.css" />
|
|
758
|
931
|
<link rel="apple-touch-icon" sizes="58x58" href="apple-touch-icon-58.png" />
|
|
|
@@ -764,30 +937,33 @@ class HTMLPage {
|
|
764
|
937
|
<link rel="apple-touch-icon" sizes="152x152" href="apple-touch-icon-152.png" />
|
|
765
|
938
|
<link rel="apple-touch-icon" sizes="167x167" href="apple-touch-icon-167.png" />
|
|
766
|
939
|
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180.png" />
|
|
767
|
|
- <meta name="apple-mobile-web-app-title" content="μJournal" />
|
|
|
940
|
+ <meta name="apple-mobile-web-app-title" content="{$str_app_icon_title}" />
|
|
768
|
941
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
769
|
942
|
<meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)" />
|
|
770
|
943
|
<meta name="theme-color" content="#444" media="(prefers-color-scheme: dark)" />
|
|
771
|
|
- <meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
|
|
|
944
|
+ <meta name="viewport" content="user-scalable=no, width=device-width, viewport-fit=cover, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" />
|
|
772
|
945
|
<link rel="icon" sizes="16x16" type="image/png" href="favicon-16.png" />
|
|
773
|
946
|
<link rel="icon" sizes="32x32" type="image/png" href="favicon-32.png" />
|
|
774
|
947
|
</head>
|
|
775
|
948
|
<body>
|
|
776
|
949
|
<nav>
|
|
777
|
950
|
<div class="top-nav">
|
|
778
|
|
- <span class="title">Microjournal</span>
|
|
|
951
|
+ <span class="title">{$str_title}</span>
|
|
779
|
952
|
HTML
|
|
780
|
953
|
);
|
|
781
|
954
|
if (User::$current) {
|
|
|
955
|
+ $str_hamburger = L10n::hamburger_menu_symbol;
|
|
|
956
|
+ $str_search = L10n::menu_search;
|
|
|
957
|
+ $str_log_out = L10n::menu_log_out;
|
|
782
|
958
|
print(<<<HTML
|
|
783
|
959
|
<div class="menu-container">
|
|
784
|
960
|
<details class="menu">
|
|
785
|
|
- <summary class="no-indicator menu-button"><span>☰</span></summary>
|
|
|
961
|
+ <summary class="no-indicator menu-button"><span>{$str_hamburger}</span></summary>
|
|
786
|
962
|
|
|
787
|
963
|
<ul>
|
|
788
|
|
- <li><a href="?search">Search</a></li>
|
|
|
964
|
+ <li><a href="?search">{$str_search}</a></li>
|
|
789
|
965
|
<li class="menu-divider"></li>
|
|
790
|
|
- <li class="logout-item destructive"><a href="?logout">Log out</a></li>
|
|
|
966
|
+ <li class="logout-item destructive"><a href="?logout">{$str_log_out}</a></li>
|
|
791
|
967
|
</ul>
|
|
792
|
968
|
</details>
|
|
793
|
969
|
</div>
|
|
|
@@ -825,13 +1001,13 @@ class HTMLPage {
|
|
825
|
1001
|
|
|
826
|
1002
|
public static function render_post_form(): void {
|
|
827
|
1003
|
$action = 'post';
|
|
828
|
|
- $verb = 'Post';
|
|
|
1004
|
+ $str_submit = L10n::post_form_submit_post;
|
|
829
|
1005
|
$body = '';
|
|
830
|
1006
|
if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
|
|
831
|
1007
|
if ($post = Post::get_by_id($edit_id)) {
|
|
832
|
1008
|
$body = $post->body;
|
|
833
|
1009
|
$action = 'edit';
|
|
834
|
|
- $verb = 'Update';
|
|
|
1010
|
+ $str_submit = L10n::post_form_submit_update;
|
|
835
|
1011
|
} else {
|
|
836
|
1012
|
unset($edit_id);
|
|
837
|
1013
|
}
|
|
|
@@ -840,17 +1016,24 @@ class HTMLPage {
|
|
840
|
1016
|
unset($_SESSION[SESSION_KEY_POST_BODY]);
|
|
841
|
1017
|
}
|
|
842
|
1018
|
$body_html = htmlentities($body);
|
|
|
1019
|
+ $str_placeholder = L10n::post_form_placeholder;
|
|
843
|
1020
|
print(<<<HTML
|
|
844
|
1021
|
<form id="post-form" method="POST">
|
|
845
|
|
- <div class="text-container"><textarea name="body" placeholder="Your journal post here…">$body_html</textarea></div>
|
|
|
1022
|
+ <div class="text-container"><textarea name="body" placeholder="$str_placeholder">$body_html</textarea></div>
|
|
846
|
1023
|
<input type="hidden" name="action" value="{$action}" />
|
|
847
|
1024
|
HTML
|
|
848
|
1025
|
);
|
|
849
|
1026
|
if ($edit_id) {
|
|
850
|
1027
|
print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
|
|
851
|
1028
|
}
|
|
|
1029
|
+ print("<div class=\"submit-post form-buttons\">");
|
|
|
1030
|
+ if ($edit_id) {
|
|
|
1031
|
+ $url = BASE_URL;
|
|
|
1032
|
+ $str_cancel = L10n::post_form_cancel;
|
|
|
1033
|
+ print("<a class=\"cancel-edit\" href=\"{$url}\">{$str_cancel}</a> ");
|
|
|
1034
|
+ }
|
|
852
|
1035
|
print(<<<HTML
|
|
853
|
|
- <div class="submit-post"><input type="submit" value="{$verb}" /></div>
|
|
|
1036
|
+ <input type="submit" value="{$str_submit}" /></div>
|
|
854
|
1037
|
</form>
|
|
855
|
1038
|
HTML
|
|
856
|
1039
|
);
|
|
|
@@ -862,13 +1045,15 @@ class HTMLPage {
|
|
862
|
1045
|
[ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
|
|
863
|
1046
|
print("<div class=\"post-container\">\n");
|
|
864
|
1047
|
if ($prev_url !== null) {
|
|
865
|
|
- print("<div class=\"previous\"><a href=\"{$prev_url}\">Previous</a></div>\n");
|
|
|
1048
|
+ $str_prev = L10n::posts_previous_page;
|
|
|
1049
|
+ print("<div class=\"previous\"><a href=\"{$prev_url}\">{$str_prev}</a></div>\n");
|
|
866
|
1050
|
}
|
|
867
|
1051
|
foreach ($posts as $post) {
|
|
868
|
1052
|
self::render_post($post);
|
|
869
|
1053
|
}
|
|
870
|
1054
|
if ($next_url !== null) {
|
|
871
|
|
- print("<div class=\"next\"><a href=\"{$next_url}\">Next</a></div>\n");
|
|
|
1055
|
+ $str_next = L10n::posts_next_page;
|
|
|
1056
|
+ print("<div class=\"next\"><a href=\"{$next_url}\">{$str_next}</a></div>\n");
|
|
872
|
1057
|
}
|
|
873
|
1058
|
print("</div>\n");
|
|
874
|
1059
|
}
|
|
|
@@ -893,7 +1078,7 @@ class HTMLPage {
|
|
893
|
1078
|
public static function render_post(Post $post): void {
|
|
894
|
1079
|
$body_html = MARKDOWN_ENABLED ? Markdown::markdown_to_html($post->body) :
|
|
895
|
1080
|
self::post_body_html($post->body);
|
|
896
|
|
- $date = localized_date_string($post->created);
|
|
|
1081
|
+ $str_posted = L10n::post_created($post->created);
|
|
897
|
1082
|
print(<<<HTML
|
|
898
|
1083
|
<div class="post">
|
|
899
|
1084
|
<article>
|
|
|
@@ -901,21 +1086,25 @@ class HTMLPage {
|
|
901
|
1086
|
<div class="post-footer">
|
|
902
|
1087
|
<footer class="secondary-text">
|
|
903
|
1088
|
<div class="post-date">
|
|
904
|
|
- Posted {$date}
|
|
|
1089
|
+ {$str_posted}
|
|
905
|
1090
|
HTML
|
|
906
|
1091
|
);
|
|
907
|
1092
|
if ($post->updated && $post->updated != $post->created) {
|
|
908
|
|
- print('<br/>(updated ' . localized_date_string($post->updated) . ')');
|
|
|
1093
|
+ $str_updated = L10n::post_updated($post->updated);
|
|
|
1094
|
+ print("<br/>\n$str_updated");
|
|
909
|
1095
|
}
|
|
|
1096
|
+ $str_menu = L10n::post_menu;
|
|
|
1097
|
+ $str_edit = L10n::post_menu_edit;
|
|
|
1098
|
+ $str_delete = L10n::post_menu_delete;
|
|
910
|
1099
|
print(<<<HTML
|
|
911
|
1100
|
</div>
|
|
912
|
1101
|
<details class="post-actions menu">
|
|
913
|
|
- <summary class="no-indicator menu-button"><span>actions</span></summary>
|
|
|
1102
|
+ <summary class="no-indicator menu-button"><span>$str_menu</span></summary>
|
|
914
|
1103
|
|
|
915
|
1104
|
<ul>
|
|
916
|
|
- <li><a href="?edit={$post->post_id}">Edit</a></li>
|
|
|
1105
|
+ <li><a href="?edit={$post->post_id}">{$str_edit}</a></li>
|
|
917
|
1106
|
<li class="menu-divider"></li>
|
|
918
|
|
- <li class="post-action-delete destructive"><a href="?delete={$post->post_id}">Delete</a></li>
|
|
|
1107
|
+ <li class="post-action-delete destructive"><a href="?delete={$post->post_id}">{$str_delete}</a></li>
|
|
919
|
1108
|
</ul>
|
|
920
|
1109
|
</details>
|
|
921
|
1110
|
</footer>
|
|
|
@@ -930,52 +1119,69 @@ class HTMLPage {
|
|
930
|
1119
|
$q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
|
|
931
|
1120
|
$q_html = htmlentities($q);
|
|
932
|
1121
|
$cancel = BASE_URL;
|
|
|
1122
|
+ $str_submit = L10n::search_submit;
|
|
|
1123
|
+ $str_cancel = L10n::search_cancel;
|
|
933
|
1124
|
print(<<<HTML
|
|
934
|
|
- <form id="search-form" method="GET">
|
|
935
|
|
- <div>
|
|
936
|
|
- <label for="search">Search:</label>
|
|
937
|
|
- <input type="text" name="search" id="search" value="{$q_html}" autocapitalize="off" />
|
|
938
|
|
- <input type="submit" value="Search" />
|
|
939
|
|
- <a href="{$cancel}">Cancel</a>
|
|
940
|
|
- </div>
|
|
941
|
|
- </form>
|
|
|
1125
|
+ <div class="form-container">
|
|
|
1126
|
+ <form id="search-form" method="GET">
|
|
|
1127
|
+ <div>
|
|
|
1128
|
+ <input type="search" name="search" id="search" value="{$q_html}" size="40" autocapitalize="off" />
|
|
|
1129
|
+ </div>
|
|
|
1130
|
+ <div class="form-buttons">
|
|
|
1131
|
+ <a class="cancel-search" href="{$cancel}">{$str_cancel}</a>
|
|
|
1132
|
+ <input type="submit" value="{$str_submit}" />
|
|
|
1133
|
+ </div>
|
|
|
1134
|
+ </form>
|
|
|
1135
|
+ </div>
|
|
942
|
1136
|
HTML
|
|
943
|
1137
|
);
|
|
944
|
1138
|
}
|
|
945
|
1139
|
|
|
946
|
1140
|
public static function render_sign_in_form(): void {
|
|
|
1141
|
+ $str_username = L10n::login_username_label;
|
|
|
1142
|
+ $str_password = L10n::login_password_label;
|
|
|
1143
|
+ $str_submit = L10n::login_submit;
|
|
947
|
1144
|
print(<<<HTML
|
|
948
|
|
- <form id="signin-form" method="POST">
|
|
949
|
|
- <div>
|
|
950
|
|
- <label for="username">Username:</label>
|
|
951
|
|
- <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
|
|
952
|
|
- </div>
|
|
953
|
|
- <div>
|
|
954
|
|
- <label for="password">Password:</label>
|
|
955
|
|
- <input type="password" name="password" id="password" />
|
|
956
|
|
- </div>
|
|
957
|
|
- <input type="hidden" name="action" value="signin" />
|
|
958
|
|
- <input type="submit" value="Sign in" />
|
|
959
|
|
- </form>
|
|
|
1145
|
+ <div class="form-container">
|
|
|
1146
|
+ <form id="signin-form" method="POST">
|
|
|
1147
|
+ <div style="text-align: end;">
|
|
|
1148
|
+ <label for="username">{$str_username}</label>
|
|
|
1149
|
+ <input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
|
|
|
1150
|
+ </div>
|
|
|
1151
|
+ <div style="text-align: end;">
|
|
|
1152
|
+ <label for="password">{$str_password}</label>
|
|
|
1153
|
+ <input type="password" name="password" id="password" />
|
|
|
1154
|
+ </div>
|
|
|
1155
|
+ <input type="hidden" name="action" value="signin" />
|
|
|
1156
|
+ <div class="form-buttons">
|
|
|
1157
|
+ <input type="submit" value="{$str_submit}" />
|
|
|
1158
|
+ </div>
|
|
|
1159
|
+ </form>
|
|
|
1160
|
+ </div>
|
|
960
|
1161
|
HTML
|
|
961
|
1162
|
);
|
|
962
|
1163
|
}
|
|
963
|
1164
|
|
|
964
|
1165
|
public static function render_setup_form(): void {
|
|
|
1166
|
+ $str_prompt = L10n::create_prompt;
|
|
|
1167
|
+ $str_username = L10n::create_username_label;
|
|
|
1168
|
+ $str_password = L10n::create_password_label;
|
|
|
1169
|
+ $str_time_zone = L10n::create_time_zone_label;
|
|
965
|
1170
|
print(<<<HTML
|
|
966
|
|
- <div class="important">Journal is not setup. Please create a username and password to secure your data.</div>
|
|
967
|
|
- <form id="setup-form" method="POST">
|
|
968
|
|
- <div>
|
|
969
|
|
- <label for="username">Username:</label>
|
|
970
|
|
- <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
|
|
971
|
|
- </div>
|
|
972
|
|
- <div>
|
|
973
|
|
- <label for="password">Password:</label>
|
|
974
|
|
- <input type="password" id="password" name="password" />
|
|
975
|
|
- </div>
|
|
976
|
|
- <div>
|
|
977
|
|
- <label for="timezone">Time zone:</label>
|
|
978
|
|
- <select name="timezone" id="timezone">
|
|
|
1171
|
+ <div class="important">{$str_prompt}</div>
|
|
|
1172
|
+ <div class="form-container">
|
|
|
1173
|
+ <form id="setup-form" method="POST">
|
|
|
1174
|
+ <div style="text-align: end;">
|
|
|
1175
|
+ <label for="username">{$str_username}</label>
|
|
|
1176
|
+ <input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
|
|
|
1177
|
+ </div>
|
|
|
1178
|
+ <div style="text-align: end;">
|
|
|
1179
|
+ <label for="password">{$str_password}</label>
|
|
|
1180
|
+ <input type="password" id="password" name="password" />
|
|
|
1181
|
+ </div>
|
|
|
1182
|
+ <div>
|
|
|
1183
|
+ <label for="timezone">{$str_time_zone}</label>
|
|
|
1184
|
+ <select name="timezone" id="timezone">
|
|
979
|
1185
|
HTML
|
|
980
|
1186
|
);
|
|
981
|
1187
|
foreach (DateTimeZone::listIdentifiers() as $timezone) {
|
|
|
@@ -985,11 +1191,15 @@ class HTMLPage {
|
|
985
|
1191
|
}
|
|
986
|
1192
|
print(">" . htmlentities($timezone) . "</option>\n");
|
|
987
|
1193
|
}
|
|
|
1194
|
+ $str_submit = L10n::create_submit;
|
|
988
|
1195
|
print(<<<HTML
|
|
989
|
|
- </select></div>
|
|
990
|
|
- <input type="hidden" name="action" value="createaccount" />
|
|
991
|
|
- <div><input type="submit" value="Create account" /></div>
|
|
992
|
|
- </form>
|
|
|
1196
|
+ </select></div>
|
|
|
1197
|
+ <input type="hidden" name="action" value="createaccount" />
|
|
|
1198
|
+ <div class="form-buttons">
|
|
|
1199
|
+ <input type="submit" value="{$str_submit}" />
|
|
|
1200
|
+ </div>
|
|
|
1201
|
+ </form>
|
|
|
1202
|
+ </div>
|
|
993
|
1203
|
HTML
|
|
994
|
1204
|
);
|
|
995
|
1205
|
}
|
|
|
@@ -1249,7 +1459,7 @@ switch ($_SERVER['REQUEST_METHOD']) {
|
|
1249
|
1459
|
if (!User::$current) {
|
|
1250
|
1460
|
// Not logged in. Save body for populating once they sign in.
|
|
1251
|
1461
|
$_SESSION[SESSION_KEY_POST_BODY] = $body;
|
|
1252
|
|
- fatal_error('Please sign in to post.');
|
|
|
1462
|
+ fatal_error(L10n::error_sign_in_to_post);
|
|
1253
|
1463
|
}
|
|
1254
|
1464
|
Post::create($body, $author, $created);
|
|
1255
|
1465
|
break;
|
|
|
@@ -1258,7 +1468,7 @@ switch ($_SERVER['REQUEST_METHOD']) {
|
|
1258
|
1468
|
$edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
|
|
1259
|
1469
|
if (!User::$current) {
|
|
1260
|
1470
|
// Not logged in. Save body for populating once they sign in.
|
|
1261
|
|
- fatal_error('Please sign in to edit.');
|
|
|
1471
|
+ fatal_error(L10n::error_sign_in_to_edit);
|
|
1262
|
1472
|
}
|
|
1263
|
1473
|
Post::get_by_id($edit_id)?->update($body);
|
|
1264
|
1474
|
break;
|