Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A engine/function/token.php +88-0 View file
@@ -0,0 +1,88 @@
1+<?php
2+class Token {
3+
4+ public static function generate(): void {
5+ $_SESSION['token'] = bin2hex(random_bytes(32));
6+ }
7+
8+ public static function create(): void {
9+ if (!self::get()) {
10+ self::generate();
11+ }
12+ echo '<input type="hidden" name="token" value="' . self::get() . '">';
13+ }
14+
15+ public static function get(): string|false {
16+ return $_SESSION['token'] ?? false;
17+ }
18+
19+ public static function isValid(?string $post): bool {
20+ if (!$post || !self::get()) {
21+ return false;
22+ }
23+
24+ $valid = hash_equals($_SESSION['token'], $post);
25+
26+ // 🔐 IMPORTANT: token usage unique
27+ self::_reset();
28+
29+ return $valid;
30+ }
31+
32+ protected static function _reset(): void {
33+ unset($_SESSION['token']);
34+ }
35+}
36+
37+function znote_csrf_field(): string {
38+ if (!Token::get()) {
39+ Token::generate();
40+ }
41+
42+ return '<input type="hidden" name="token" value="' . htmlspecialchars(Token::get(), ENT_QUOTES, 'UTF-8') . '">';
43+}
44+
45+function znote_csrf_validate_post(): bool {
46+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
47+ return true;
48+ }
49+
50+ $posted = $_POST['token'] ?? null;
51+ $session = Token::get();
52+ if (!is_string($posted) || $posted === '' || !is_string($session) || $session === '') {
53+ return false;
54+ }
55+
56+ $valid = hash_equals($session, $posted);
57+ if ($valid) {
58+ Token::generate();
59+ }
60+
61+ return $valid;
62+}
63+
64+function znote_csrf_protect_public_post(): void {
65+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !znote_csrf_validate_post()) {
66+ http_response_code(400);
67+ die('Invalid or expired form token. Please go back, refresh the page and try again.');
68+ }
69+
70+ ob_start(static function (string $html): string {
71+ if (stripos($html, '<form') === false || stripos($html, 'method') === false) {
72+ return $html;
73+ }
74+
75+ return preg_replace_callback(
76+ '~<form\b(?=[^>]*\bmethod\s*=\s*["\']?post["\']?)[^>]*>~i',
77+ static function (array $match): string {
78+ if (stripos($match[0], 'name="token"') !== false || stripos($match[0], "name='token'") !== false) {
79+ return $match[0];
80+ }
81+
82+ return $match[0] . "\n" . znote_csrf_field();
83+ },
84+ $html
85+ ) ?? $html;
86+ });
87+}
88+?>
A engine/function/translate.php +367-0 View file
@@ -0,0 +1,367 @@
1+<?php
2+
3+function translate_catalog(): array {
4+ return array(
5+ 'en' => array('name' => 'English', 'native' => 'English', 'flag' => 'uk'),
6+ 'pt_br' => array('name' => 'Portuguese', 'native' => 'Português', 'flag' => 'pt'),
7+ 'es' => array('name' => 'Spanish', 'native' => 'Español', 'flag' => 'es'),
8+ 'pl' => array('name' => 'Polish', 'native' => 'Polski', 'flag' => 'pl'),
9+ 'de' => array('name' => 'German', 'native' => 'Deutsch', 'flag' => 'de')
10+ );
11+}
12+
13+function translate_sanitize(string $code): string {
14+ $code = strtolower(preg_replace('/[^a-zA-Z_]/', '', $code) ?? '');
15+ return isset(translate_catalog()[$code]) ? $code : '';
16+}
17+
18+function translate_default(): string {
19+ $code = translate_sanitize((string)(config('language') ?? 'en'));
20+ return ($code !== '') ? $code : 'en';
21+}
22+
23+function translate_enabled(): array {
24+ $configured = config('languages_enabled');
25+
26+ if (is_string($configured)) {
27+ $decoded = json_decode($configured, true);
28+ $configured = is_array($decoded) ? $decoded : explode(',', $configured);
29+ }
30+
31+ $enabled = array();
32+
33+ if (is_array($configured)) {
34+ foreach ($configured as $code) {
35+ $clean = translate_sanitize((string)$code);
36+ if ($clean !== '' && !in_array($clean, $enabled, true)) {
37+ $enabled[] = $clean;
38+ }
39+ }
40+ }
41+
42+ if (!$enabled) {
43+ $enabled = array_keys(translate_catalog());
44+ }
45+
46+ $default = translate_default();
47+ if (!in_array($default, $enabled, true)) {
48+ array_unshift($enabled, $default);
49+ }
50+
51+ return $enabled;
52+}
53+
54+function translate_from_browser(): string {
55+ $header = (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '');
56+ if ($header === '') {
57+ return '';
58+ }
59+
60+ $enabled = translate_enabled();
61+ $ranked = array();
62+
63+ foreach (explode(',', $header) as $part) {
64+ $bits = explode(';q=', trim($part));
65+ $tag = strtolower(str_replace('-', '_', trim($bits[0])));
66+ $q = isset($bits[1]) ? (float)$bits[1] : 1.0;
67+
68+ if ($tag === '') {
69+ continue;
70+ }
71+
72+ $ranked[] = array('tag' => $tag, 'q' => $q);
73+ }
74+
75+ usort($ranked, function ($a, $b) { return $b['q'] <=> $a['q']; });
76+
77+ foreach ($ranked as $entry) {
78+ $tag = $entry['tag'];
79+
80+ if (in_array($tag, $enabled, true)) {
81+ return $tag;
82+ }
83+
84+ $base = explode('_', $tag)[0];
85+
86+ foreach ($enabled as $code) {
87+ if ($code === $base || strpos($code, $base . '_') === 0) {
88+ return $code;
89+ }
90+ }
91+ }
92+
93+ return '';
94+}
95+
96+function translate_active(?string $set = null): string {
97+ static $active = null;
98+
99+ if ($set !== null) {
100+ $active = $set;
101+ return $active;
102+ }
103+
104+ if ($active !== null) {
105+ return $active;
106+ }
107+
108+ $enabled = translate_enabled();
109+
110+ $fromUrl = translate_sanitize((string)($_GET['lang'] ?? ''));
111+ if ($fromUrl !== '' && in_array($fromUrl, $enabled, true)) {
112+ translate_remember($fromUrl);
113+ return $active = $fromUrl;
114+ }
115+
116+ $prefix = (string)($GLOBALS['config']['session_prefix'] ?? '');
117+ $fromStore = translate_sanitize((string)($_SESSION[$prefix . 'language'] ?? $_COOKIE[$prefix . 'language'] ?? ''));
118+ if ($fromStore !== '' && in_array($fromStore, $enabled, true)) {
119+ return $active = $fromStore;
120+ }
121+
122+ $fromBrowser = translate_from_browser();
123+ if ($fromBrowser !== '') {
124+ return $active = $fromBrowser;
125+ }
126+
127+ return $active = translate_default();
128+}
129+
130+function translate_remember(string $code): void {
131+ $code = translate_sanitize($code);
132+ if ($code === '' || !in_array($code, translate_enabled(), true)) {
133+ return;
134+ }
135+
136+ $prefix = (string)($GLOBALS['config']['session_prefix'] ?? '');
137+
138+ if (session_status() === PHP_SESSION_ACTIVE) {
139+ $_SESSION[$prefix . 'language'] = $code;
140+ }
141+
142+ if (!headers_sent()) {
143+ setcookie($prefix . 'language', $code, array(
144+ 'expires' => time() + (365 * 86400),
145+ 'path' => '/',
146+ 'samesite' => 'Lax'
147+ ));
148+ }
149+
150+ $_COOKIE[$prefix . 'language'] = $code;
151+}
152+
153+function translate_load(string $code): array {
154+ static $loaded = array();
155+
156+ $code = translate_sanitize($code);
157+ if ($code === '') {
158+ return array();
159+ }
160+
161+ if (isset($loaded[$code])) {
162+ return $loaded[$code];
163+ }
164+
165+ $core = array();
166+
167+ $file = __DIR__ . '/../../locale/' . $code . '.php';
168+ if (is_file($file)) {
169+ $included = include $file;
170+ if (is_array($included)) {
171+ $core = $included;
172+ }
173+ }
174+
175+ return $loaded[$code] = translate_theme_strings($code) + $core + translate_plugin_strings($code);
176+}
177+
178+function translate_theme_strings(string $code): array {
179+ if (!function_exists('theme_chain') || !function_exists('theme_root')) {
180+ return array();
181+ }
182+
183+ $strings = array();
184+
185+ foreach (theme_chain() as $theme) {
186+ $file = theme_root() . '/' . $theme . '/locale/' . $code . '.php';
187+ if (!is_file($file)) {
188+ continue;
189+ }
190+
191+ $loaded = include $file;
192+ if (is_array($loaded)) {
193+ $strings += $loaded;
194+ }
195+ }
196+
197+ return $strings;
198+}
199+
200+function translate_plugin_strings(string $code): array {
201+ if (!function_exists('znote_plugins')) {
202+ return array();
203+ }
204+
205+ $strings = array();
206+
207+ foreach (znote_plugins() as $name => $plugin) {
208+ if (empty($plugin['enabled'])) {
209+ continue;
210+ }
211+
212+ $file = rtrim((string)($plugin['path'] ?? ''), '/\\') . '/locale/' . $code . '.php';
213+ if (!is_file($file)) {
214+ continue;
215+ }
216+
217+ $loaded = include $file;
218+ if (is_array($loaded)) {
219+ $strings += $loaded;
220+ }
221+ }
222+
223+ return $strings;
224+}
225+
226+function t(string $key, array $vars = array()): string {
227+ $active = translate_active();
228+
229+ $strings = translate_load($active);
230+ $text = $strings[$key] ?? null;
231+
232+ if ($text === null && $active !== 'en') {
233+ $fallback = translate_load('en');
234+ $text = $fallback[$key] ?? null;
235+ }
236+
237+ if ($text === null) {
238+ $text = $key;
239+ }
240+
241+ if ($vars) {
242+ $replace = array();
243+ foreach ($vars as $name => $value) {
244+ $replace['{' . $name . '}'] = (string)$value;
245+ }
246+ $text = strtr($text, $replace);
247+ }
248+
249+ return $text;
250+}
251+
252+function te(string $key, array $vars = array()): void {
253+ echo t($key, $vars);
254+}
255+
256+if (!function_exists('h')) {
257+ function h($s): string {
258+ return htmlspecialchars((string)($s ?? ''), ENT_QUOTES, 'UTF-8');
259+ }
260+}
261+
262+function t_default(string $key, string $fallback, array $vars = array()): string {
263+ $text = t($key, $vars);
264+ return ($text === $key) ? $fallback : $text;
265+}
266+
267+function translate_flag(string $flag): string {
268+ if (!preg_match('/^[a-z]{2,3}$/', $flag) || !is_file(__DIR__ . '/../../assets/flags/' . $flag . '.png')) {
269+ return '';
270+ }
271+
272+ return '<img src="assets/flags/' . $flag . '.png" alt="" loading="lazy">';
273+}
274+
275+function translate_url(string $code): string {
276+ $query = $_GET;
277+ $query['lang'] = $code;
278+
279+ $path = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
280+
281+ return htmlspecialchars(($path === false ? '/' : $path) . '?' . http_build_query($query), ENT_QUOTES, 'UTF-8');
282+}
283+
284+function translate_selector(): string {
285+ $enabled = translate_enabled();
286+
287+ if (count($enabled) < 2) {
288+ return '';
289+ }
290+
291+ $catalog = translate_catalog();
292+ $active = translate_active();
293+ $current = $catalog[$active] ?? $catalog['en'];
294+
295+ $items = '';
296+ foreach ($enabled as $code) {
297+ $entry = $catalog[$code] ?? null;
298+ if ($entry === null) {
299+ continue;
300+ }
301+
302+ $items .= '<a href="' . translate_url($code) . '" hreflang="' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '"'
303+ . ($code === $active ? ' class="is-active" aria-current="true"' : '') . '>'
304+ . '<i>' . translate_flag($entry['flag']) . '</i>'
305+ . '<span>' . htmlspecialchars($entry['native'], ENT_QUOTES, 'UTF-8') . '</span></a>';
306+ }
307+
308+ return '<div class="znote-lang" id="znoteLang">'
309+ . '<button type="button" class="znote-lang-current" aria-haspopup="true" aria-expanded="false" aria-label="'
310+ . htmlspecialchars(t('language.choose'), ENT_QUOTES, 'UTF-8') . '">'
311+ . '<i>' . translate_flag($current['flag']) . '</i>'
312+ . '<span>' . htmlspecialchars(strtoupper(explode('_', $active)[0]), ENT_QUOTES, 'UTF-8') . '</span>'
313+ . '<em></em></button>'
314+ . '<div class="znote-lang-menu">' . $items . '</div></div>';
315+}
316+
317+function translate_selector_assets(): string {
318+ return '<style>'
319+ . '.znote-lang{position:fixed;top:12px;right:12px;z-index:9999;font:500 13px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}'
320+ . '.znote-lang *{box-sizing:border-box}'
321+ . '.znote-lang i{display:block;width:20px;height:14px;overflow:hidden;border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.25)}'
322+ . '.znote-lang i img{display:block;width:100%;height:100%;object-fit:cover}'
323+ . '.znote-lang-current{display:flex;align-items:center;gap:7px;padding:6px 9px;border:0;border-radius:6px;cursor:pointer;'
324+ . 'background:rgba(20,20,24,.82);color:#f2f2f4;backdrop-filter:blur(6px);box-shadow:0 2px 10px rgba(0,0,0,.3)}'
325+ . '.znote-lang-current:hover{background:rgba(20,20,24,.95)}'
326+ . '.znote-lang-current em{width:0;height:0;border:4px solid transparent;border-top-color:currentColor;margin-top:2px}'
327+ . '.znote-lang-menu{position:absolute;top:calc(100% + 6px);right:0;min-width:170px;padding:5px;border-radius:8px;'
328+ . 'background:rgba(20,20,24,.96);box-shadow:0 6px 24px rgba(0,0,0,.35);display:none}'
329+ . '.znote-lang.is-open .znote-lang-menu{display:block}'
330+ . '.znote-lang-menu a{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:5px;color:#e8e8ec;text-decoration:none;white-space:nowrap}'
331+ . '.znote-lang-menu a:hover{background:rgba(255,255,255,.1)}'
332+ . '.znote-lang-menu a.is-active{background:rgba(255,255,255,.16)}'
333+ . '@media(max-width:640px){.znote-lang{top:8px;right:8px}.znote-lang-current span{display:none}}'
334+ . '@media print{.znote-lang{display:none}}'
335+ . '</style>'
336+ . '<script>(function(){var r=document.getElementById("znoteLang");if(!r)return;'
337+ . 'var b=r.querySelector(".znote-lang-current");'
338+ . 'b.addEventListener("click",function(e){e.stopPropagation();var o=r.classList.toggle("is-open");b.setAttribute("aria-expanded",o?"true":"false");});'
339+ . 'document.addEventListener("click",function(){r.classList.remove("is-open");b.setAttribute("aria-expanded","false");});'
340+ . 'document.addEventListener("keydown",function(e){if(e.key==="Escape"){r.classList.remove("is-open");b.setAttribute("aria-expanded","false");}});'
341+ . '})();</script>';
342+}
343+
344+function translate_selector_rendered(?bool $set = null): bool {
345+ static $done = false;
346+
347+ if ($set !== null) {
348+ $done = $set;
349+ }
350+
351+ return $done;
352+}
353+
354+function translate_inject(): string {
355+ if (!config('language_selector') || translate_selector_rendered()) {
356+ return '';
357+ }
358+
359+ $markup = translate_selector();
360+ if ($markup === '') {
361+ return '';
362+ }
363+
364+ translate_selector_rendered(true);
365+
366+ return $markup . translate_selector_assets();
367+}
A engine/function/twofa2.php +322-0 View file
@@ -0,0 +1,322 @@
1+<?php
2+
3+function znote2fa_v2_config(): array {
4+ global $config;
5+ $cfg = (array)($config['twoFactorV2'] ?? array());
6+
7+ return array(
8+ 'enabled' => !empty($cfg['enabled']),
9+ 'email_otp_enabled' => !array_key_exists('email_otp_enabled', $cfg) || $cfg['email_otp_enabled'] !== false,
10+ 'force_admins' => !empty($cfg['force_admins']),
11+ 'recovery_codes_count' => max(1, (int)($cfg['recovery_codes_count'] ?? 10)),
12+ 'trusted_device_days' => max(0, (int)($cfg['trusted_device_days'] ?? 30)),
13+ );
14+}
15+
16+function znote2fa_v2_enabled(): bool {
17+ return znote2fa_v2_config()['enabled'];
18+}
19+
20+function znote2fa_row(int $accountId): array {
21+ $row = db()->fetchOne("SELECT * FROM `znote_2fa` WHERE `account_id` = ? LIMIT 1;", [$accountId]);
22+
23+ if (!is_array($row)) {
24+ return array(
25+ 'account_id' => $accountId,
26+ 'totp_secret' => null,
27+ 'totp_enabled' => 0,
28+ 'email_otp_enabled' => 0,
29+ 'recovery_codes' => null,
30+ 'session_version' => 1,
31+ 'updated_at' => 0,
32+ );
33+ }
34+
35+ return $row;
36+}
37+
38+function znote2fa_ensure_row(int $accountId): void {
39+ db()->execute("
40+ INSERT INTO `znote_2fa` (`account_id`, `session_version`, `updated_at`)
41+ VALUES (?, 1, ?)
42+ ON DUPLICATE KEY UPDATE `account_id` = `account_id`;
43+ ", [$accountId, time()]);
44+}
45+
46+function znote2fa_status(int $accountId): array {
47+ $row = znote2fa_row($accountId);
48+ $recoveryCodes = znote2fa_recovery_decode($row['recovery_codes'] ?? null);
49+ $emailOtpEnabled = znote2fa_v2_config()['email_otp_enabled'] && !empty($row['email_otp_enabled']);
50+
51+ return array(
52+ 'totp_enabled' => !empty($row['totp_enabled']),
53+ 'totp_pending' => empty($row['totp_enabled']) && !empty($row['totp_secret']),
54+ 'email_otp_enabled' => $emailOtpEnabled,
55+ 'any_enabled' => !empty($row['totp_enabled']) || $emailOtpEnabled,
56+ 'recovery_remaining' => count($recoveryCodes),
57+ );
58+}
59+
60+function znote2fa_totp_start(int $accountId): string {
61+ $secret = TokenAuth6238::generateRandomClue(20);
62+ znote2fa_ensure_row($accountId);
63+ db()->execute("UPDATE `znote_2fa` SET `totp_secret` = ?, `totp_enabled` = 0, `updated_at` = ? WHERE `account_id` = ?;", [$secret, time(), $accountId]);
64+
65+ return $secret;
66+}
67+
68+function znote2fa_totp_confirm(int $accountId, string $code): bool {
69+ $row = znote2fa_row($accountId);
70+ if (empty($row['totp_secret']) || !TokenAuth6238::verify($row['totp_secret'], $code)) {
71+ return false;
72+ }
73+
74+ return db()->execute("UPDATE `znote_2fa` SET `totp_enabled` = 1, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]) !== false;
75+}
76+
77+function znote2fa_totp_disable(int $accountId): void {
78+ db()->execute("UPDATE `znote_2fa` SET `totp_secret` = NULL, `totp_enabled` = 0, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
79+}
80+
81+function znote2fa_totp_verify(int $accountId, string $code): bool {
82+ $row = znote2fa_row($accountId);
83+ return !empty($row['totp_enabled']) && !empty($row['totp_secret']) && TokenAuth6238::verify($row['totp_secret'], $code);
84+}
85+
86+function znote2fa_email_otp_set(int $accountId, bool $enabled): void {
87+ $enabled = $enabled && znote2fa_v2_config()['email_otp_enabled'];
88+ znote2fa_ensure_row($accountId);
89+ db()->execute("UPDATE `znote_2fa` SET `email_otp_enabled` = ?, `updated_at` = ? WHERE `account_id` = ?;", [$enabled ? 1 : 0, time(), $accountId]);
90+}
91+
92+function znote2fa_email_send_code(int $accountId, string $email, string $accountName = ''): bool {
93+ global $config;
94+
95+ $code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
96+ $hash = hash('sha256', $code);
97+ $expires = time() + 600;
98+
99+ $stored = db()->execute("
100+ INSERT INTO `znote_2fa_email_codes` (`account_id`, `code_hash`, `expires_at`, `attempts`)
101+ VALUES (?, ?, ?, 0)
102+ ON DUPLICATE KEY UPDATE `code_hash` = VALUES(`code_hash`), `expires_at` = VALUES(`expires_at`), `attempts` = 0;
103+ ", [$accountId, $hash, $expires]);
104+
105+ if ($stored === false) {
106+ return false;
107+ }
108+
109+ $mail = new Mail((array)($config['mailserver'] ?? array()));
110+ $title = ($config['site_title'] ?? 'ZnoteX') . ' - Login verification code';
111+ $html = '<p>Your login verification code is:</p><h2 style="letter-spacing:4px;">' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</h2>'
112+ . '<p>It expires in 10 minutes. If you did not try to log in, you can ignore this e-mail.</p>';
113+
114+ return $mail->sendMail($email, $title, $html, $accountName);
115+}
116+
117+function znote2fa_email_verify_code(int $accountId, string $code): bool {
118+ $row = db()->fetchOne("SELECT * FROM `znote_2fa_email_codes` WHERE `account_id` = ? LIMIT 1;", [$accountId]);
119+ if (!is_array($row)) {
120+ return false;
121+ }
122+
123+ if ((int)$row['expires_at'] < time() || (int)$row['attempts'] >= 5) {
124+ db()->execute("DELETE FROM `znote_2fa_email_codes` WHERE `account_id` = ?;", [$accountId]);
125+ return false;
126+ }
127+
128+ if (!hash_equals((string)$row['code_hash'], hash('sha256', $code))) {
129+ db()->execute("UPDATE `znote_2fa_email_codes` SET `attempts` = `attempts` + 1 WHERE `account_id` = ?;", [$accountId]);
130+ return false;
131+ }
132+
133+ db()->execute("DELETE FROM `znote_2fa_email_codes` WHERE `account_id` = ?;", [$accountId]);
134+ return true;
135+}
136+
137+function znote2fa_recovery_decode($stored): array {
138+ if (!is_string($stored) || $stored === '') {
139+ return array();
140+ }
141+
142+ $decoded = json_decode($stored, true);
143+ return is_array($decoded) ? $decoded : array();
144+}
145+
146+function znote2fa_recovery_format(string $code): string {
147+ $code = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $code) ?? '');
148+ return $code;
149+}
150+
151+function znote2fa_recovery_generate(int $accountId, ?int $count = null): array {
152+ $count = $count ?? znote2fa_v2_config()['recovery_codes_count'];
153+ $plain = array();
154+ $hashed = array();
155+
156+ for ($i = 0; $i < $count; $i++) {
157+ $code = strtoupper(bin2hex(random_bytes(5))); // 10 hex chars
158+ $plain[] = substr($code, 0, 5) . '-' . substr($code, 5, 5);
159+ $hashed[] = password_hash($code, PASSWORD_DEFAULT);
160+ }
161+
162+ znote2fa_ensure_row($accountId);
163+ db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = ?, `updated_at` = ? WHERE `account_id` = ?;", [json_encode($hashed), time(), $accountId]);
164+
165+ return $plain;
166+}
167+
168+function znote2fa_recovery_clear(int $accountId): void {
169+ db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = NULL, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
170+}
171+
172+function znote2fa_recovery_consume(int $accountId, string $code): bool {
173+ $formatted = znote2fa_recovery_format($code);
174+ if ($formatted === '') {
175+ return false;
176+ }
177+
178+ $row = znote2fa_row($accountId);
179+ $hashes = znote2fa_recovery_decode($row['recovery_codes'] ?? null);
180+
181+ foreach ($hashes as $index => $hash) {
182+ if (is_string($hash) && password_verify($formatted, $hash)) {
183+ unset($hashes[$index]);
184+ db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = ?, `updated_at` = ? WHERE `account_id` = ?;", [json_encode(array_values($hashes)), time(), $accountId]);
185+ return true;
186+ }
187+ }
188+
189+ return false;
190+}
191+
192+function znote2fa_session_version(int $accountId): int {
193+ return (int)(znote2fa_row($accountId)['session_version'] ?? 1);
194+}
195+
196+function znote2fa_logout_all_devices(int $accountId): void {
197+ znote2fa_ensure_row($accountId);
198+ db()->execute("UPDATE `znote_2fa` SET `session_version` = `session_version` + 1, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
199+ db()->execute("DELETE FROM `znote_2fa_trusted_devices` WHERE `account_id` = ?;", [$accountId]);
200+
201+ if (function_exists('znote_hook')) {
202+ znote_hook('security.logout_all_devices', array('account_id' => $accountId));
203+ }
204+}
205+
206+function znote2fa_trusted_cookie_name(): string {
207+ global $config;
208+ return (string)($config['session_prefix'] ?? 'znote_') . '2fa_trust';
209+}
210+
211+function znote2fa_trusted_cookie_clear(): void {
212+ global $config;
213+
214+ setcookie(znote2fa_trusted_cookie_name(), '', array(
215+ 'expires' => time() - 3600,
216+ 'path' => (string)($config['session']['cookie_path'] ?? '/'),
217+ 'domain' => (string)($config['session']['cookie_domain'] ?? ''),
218+ 'secure' => (bool)znote_session_request_is_https(),
219+ 'httponly' => true,
220+ 'samesite' => (string)($config['session']['cookie_samesite'] ?? 'Lax'),
221+ ));
222+ unset($_COOKIE[znote2fa_trusted_cookie_name()]);
223+}
224+
225+function znote2fa_trusted_device_check(int $accountId): bool {
226+ $cookie = $_COOKIE[znote2fa_trusted_cookie_name()] ?? '';
227+ if (!is_string($cookie) || $cookie === '') {
228+ return false;
229+ }
230+
231+ $hash = hash('sha256', $cookie);
232+ $row = db()->fetchOne("
233+ SELECT `id` FROM `znote_2fa_trusted_devices`
234+ WHERE `account_id` = ? AND `token_hash` = ? AND `expires_at` > ?
235+ LIMIT 1;
236+ ", [$accountId, $hash, time()]);
237+
238+ if (!is_array($row)) {
239+ return false;
240+ }
241+
242+ db()->execute("UPDATE `znote_2fa_trusted_devices` SET `last_used_at` = ? WHERE `id` = ?;", [time(), (int)$row['id']]);
243+ return true;
244+}
245+
246+function znote2fa_trusted_device_issue(int $accountId): void {
247+ $days = znote2fa_v2_config()['trusted_device_days'];
248+ if ($days <= 0) {
249+ return;
250+ }
251+
252+ global $config;
253+ $token = bin2hex(random_bytes(32));
254+ $hash = hash('sha256', $token);
255+ $expires = time() + ($days * 86400);
256+ $label = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown device'), 0, 255);
257+
258+ db()->execute("
259+ INSERT INTO `znote_2fa_trusted_devices` (`account_id`, `token_hash`, `label`, `ip`, `created_at`, `expires_at`, `last_used_at`)
260+ VALUES (?, ?, ?, ?, ?, ?, ?);
261+ ", [$accountId, $hash, $label, (string)($_SERVER['REMOTE_ADDR'] ?? ''), time(), $expires, time()]);
262+
263+ $secure = (bool)znote_session_request_is_https();
264+ setcookie(znote2fa_trusted_cookie_name(), $token, array(
265+ 'expires' => $expires,
266+ 'path' => (string)($config['session']['cookie_path'] ?? '/'),
267+ 'domain' => (string)($config['session']['cookie_domain'] ?? ''),
268+ 'secure' => $secure,
269+ 'httponly' => true,
270+ 'samesite' => (string)($config['session']['cookie_samesite'] ?? 'Lax'),
271+ ));
272+}
273+
274+function znote2fa_trusted_devices_list(int $accountId): array {
275+ $rows = db()->fetchAll("
276+ SELECT `id`, `label`, `ip`, `created_at`, `expires_at`, `last_used_at`
277+ FROM `znote_2fa_trusted_devices`
278+ WHERE `account_id` = ?
279+ ORDER BY `last_used_at` DESC;
280+ ", [$accountId]);
281+
282+ return is_array($rows) ? $rows : array();
283+}
284+
285+function znote2fa_trusted_device_revoke(int $accountId, int $deviceId): void {
286+ db()->execute("DELETE FROM `znote_2fa_trusted_devices` WHERE `id` = ? AND `account_id` = ?;", [$deviceId, $accountId]);
287+}
288+
289+function znote2fa_login_challenge_required(bool $siteEnabled, array $status): bool {
290+ return $siteEnabled && !empty($status['any_enabled']);
291+}
292+
293+function znote2fa_required(int $accountId, bool $isAdmin = false): bool {
294+ if (!znote2fa_v2_enabled()) {
295+ return false;
296+ }
297+
298+ return znote2fa_login_challenge_required(true, znote2fa_status($accountId));
299+}
300+
301+function znote2fa_setup_incomplete(int $accountId, bool $isAdmin): bool {
302+ return znote2fa_v2_enabled() && $isAdmin && znote2fa_v2_config()['force_admins'] && !znote2fa_status($accountId)['any_enabled'];
303+}
304+
305+function znote2fa_verify_login_input(int $accountId, string $input): bool {
306+ $input = trim($input);
307+ if ($input === '') {
308+ return false;
309+ }
310+
311+ if (preg_match('/^\d{6}$/', $input)) {
312+ if (znote2fa_totp_verify($accountId, $input)) {
313+ return true;
314+ }
315+ if (znote2fa_email_verify_code($accountId, $input)) {
316+ return true;
317+ }
318+ return false;
319+ }
320+
321+ return znote2fa_recovery_consume($accountId, $input);
322+}
Top