From 2f16ab3fdf89b8ba6b1010510d8b169aad425f38 Mon Sep 17 00:00:00 2001
From: Miguel Ribeiro
Date: Sat, 28 Sep 2024 18:33:09 +0200
Subject: [PATCH] feat: add 2fa support (#525)
---
about.php | 9 +
endpoints/user/disable_totp.php | 123 ++
endpoints/user/enable_totp.php | 139 ++
includes/i18n/de.php | 14 +
includes/i18n/el.php | 14 +
includes/i18n/en.php | 14 +
includes/i18n/es.php | 14 +
includes/i18n/fr.php | 14 +
includes/i18n/it.php | 15 +
includes/i18n/jp.php | 14 +
includes/i18n/ko.php | 14 +
includes/i18n/pl.php | 14 +
includes/i18n/pt.php | 14 +
includes/i18n/pt_br.php | 14 +
includes/i18n/ru.php | 14 +
includes/i18n/sl.php | 14 +
includes/i18n/sr.php | 14 +
includes/i18n/sr_lat.php | 14 +
includes/i18n/tr.php | 14 +
includes/i18n/zh_cn.php | 15 +
includes/i18n/zh_tw.php | 15 +
includes/version.php | 2 +-
libs/OTPHP/Factory.php | 104 ++
libs/OTPHP/FactoryInterface.php | 16 +
libs/OTPHP/HOTP.php | 137 ++
libs/OTPHP/HOTPInterface.php | 36 +
libs/OTPHP/InternalClock.php | 19 +
libs/OTPHP/OTP.php | 150 +++
libs/OTPHP/OTPInterface.php | 132 ++
libs/OTPHP/ParameterTrait.php | 200 +++
libs/OTPHP/TOTP.php | 215 ++++
libs/OTPHP/TOTPInterface.php | 51 +
libs/OTPHP/Url.php | 102 ++
libs/Psr/Clock/ClockInterface.php | 13 +
libs/constant_time_encoding/Base32.php | 541 ++++++++
libs/constant_time_encoding/Base32Hex.php | 111 ++
libs/constant_time_encoding/Base64.php | 319 +++++
.../constant_time_encoding/Base64DotSlash.php | 88 ++
.../Base64DotSlashOrdered.php | 82 ++
libs/constant_time_encoding/Base64UrlSafe.php | 95 ++
libs/constant_time_encoding/Binary.php | 93 ++
.../EncoderInterface.php | 52 +
libs/constant_time_encoding/Encoding.php | 298 +++++
libs/constant_time_encoding/Hex.php | 151 +++
libs/constant_time_encoding/RFC4648.php | 206 +++
login.php | 59 +-
migrations/000027.php | 21 +
scripts/i18n/de.js | 2 +
scripts/i18n/el.js | 2 +
scripts/i18n/en.js | 2 +
scripts/i18n/es.js | 2 +
scripts/i18n/fr.js | 2 +
scripts/i18n/it.js | 2 +
scripts/i18n/jp.js | 2 +
scripts/i18n/ko.js | 2 +
scripts/i18n/pl.js | 2 +
scripts/i18n/pt.js | 2 +
scripts/i18n/pt_br.js | 2 +
scripts/i18n/ru.js | 2 +
scripts/i18n/sl.js | 2 +
scripts/i18n/sr.js | 2 +
scripts/i18n/sr_lat.js | 2 +
scripts/i18n/tr.js | 2 +
scripts/i18n/zh_cn.js | 2 +
scripts/i18n/zh_tw.js | 2 +
scripts/libs/qrcode.min.js | 1 +
scripts/settings.js | 1118 ++++++++++-------
service-worker.js | 1 +
settings.php | 120 +-
styles/dark-theme.css | 14 +-
styles/styles.css | 71 ++
totp.php | 208 +++
72 files changed, 4881 insertions(+), 507 deletions(-)
create mode 100644 endpoints/user/disable_totp.php
create mode 100644 endpoints/user/enable_totp.php
create mode 100644 libs/OTPHP/Factory.php
create mode 100644 libs/OTPHP/FactoryInterface.php
create mode 100644 libs/OTPHP/HOTP.php
create mode 100644 libs/OTPHP/HOTPInterface.php
create mode 100644 libs/OTPHP/InternalClock.php
create mode 100644 libs/OTPHP/OTP.php
create mode 100644 libs/OTPHP/OTPInterface.php
create mode 100644 libs/OTPHP/ParameterTrait.php
create mode 100644 libs/OTPHP/TOTP.php
create mode 100644 libs/OTPHP/TOTPInterface.php
create mode 100644 libs/OTPHP/Url.php
create mode 100644 libs/Psr/Clock/ClockInterface.php
create mode 100644 libs/constant_time_encoding/Base32.php
create mode 100644 libs/constant_time_encoding/Base32Hex.php
create mode 100644 libs/constant_time_encoding/Base64.php
create mode 100644 libs/constant_time_encoding/Base64DotSlash.php
create mode 100644 libs/constant_time_encoding/Base64DotSlashOrdered.php
create mode 100644 libs/constant_time_encoding/Base64UrlSafe.php
create mode 100644 libs/constant_time_encoding/Binary.php
create mode 100644 libs/constant_time_encoding/EncoderInterface.php
create mode 100644 libs/constant_time_encoding/Encoding.php
create mode 100644 libs/constant_time_encoding/Hex.php
create mode 100644 libs/constant_time_encoding/RFC4648.php
create mode 100644 migrations/000027.php
create mode 100644 scripts/libs/qrcode.min.js
create mode 100644 totp.php
diff --git a/about.php b/about.php
index 66a1b96ef..8ba26f31f 100644
--- a/about.php
+++ b/about.php
@@ -69,6 +69,15 @@
+
+ QRCode.js:
+
+ https://github.com/davidshimjs/qrcodejs
+
+
+
+
+
diff --git a/endpoints/user/disable_totp.php b/endpoints/user/disable_totp.php
new file mode 100644
index 000000000..d0936524e
--- /dev/null
+++ b/endpoints/user/disable_totp.php
@@ -0,0 +1,123 @@
+= 80000) {
+ trigger_error(sprintf($message, ...$args), E_USER_DEPRECATED);
+ }
+ }
+}
+
+if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('session_expired', $i18n),
+ "reload" => false
+ ]));
+}
+
+
+$statement = $db->prepare('SELECT totp_enabled FROM user WHERE id = :id');
+$statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+$result = $statement->execute();
+$row = $result->fetchArray(SQLITE3_ASSOC);
+
+if ($row['totp_enabled'] == 0) {
+ die(json_encode([
+ "success" => false,
+ "message" => "2FA is not enabled for this user",
+ "reload" => true
+ ]));
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+ $postData = file_get_contents("php://input");
+ $data = json_decode($postData, true);
+
+ if (isset($data['totpCode']) && $data['totpCode'] != "") {
+ require_once __DIR__ . '/../../libs/OTPHP/FactoryInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/Factory.php';
+ require_once __DIR__ . '/../../libs/OTPHP/ParameterTrait.php';
+ require_once __DIR__ . '/../../libs/OTPHP/OTPInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/OTP.php';
+ require_once __DIR__ . '/../../libs/OTPHP/TOTPInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/TOTP.php';
+ require_once __DIR__ . '/../../libs/Psr/Clock/ClockInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/InternalClock.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/Binary.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/EncoderInterface.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/Base32.php';
+
+ $totp_code = $data['totpCode'];
+
+ $statement = $db->prepare('SELECT totp_secret FROM totp WHERE user_id = :id');
+ $statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+ $result = $statement->execute();
+ $row = $result->fetchArray(SQLITE3_ASSOC);
+ $secret = $row['totp_secret'];
+
+ $statement = $db->prepare('SELECT backup_codes FROM totp WHERE user_id = :id');
+ $statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+ $result = $statement->execute();
+ $row = $result->fetchArray(SQLITE3_ASSOC);
+ $backupCodes = $row['backup_codes'];
+
+ $clock = new OTPHP\InternalClock();
+ $totp = OTPHP\TOTP::createFromSecret($secret, $clock);
+
+ if ($totp->verify($totp_code)) {
+ $statement = $db->prepare('UPDATE user SET totp_enabled = 0 WHERE id = :id');
+ $statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+ $statement->execute();
+
+ $statement = $db->prepare('DELETE FROM totp WHERE user_id = :id');
+ $statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+ $statement->execute();
+
+ die(json_encode([
+ "success" => true,
+ "message" => translate('success', $i18n),
+ "reload" => true
+ ]));
+ } else {
+ // Compare the TOTP code agains the backup codes
+ $backupCodes = json_decode($backupCodes, true);
+ if (($key = array_search($totp_code, $backupCodes)) !== false) {
+ unset($backupCodes[$key]);
+ $statement = $db->prepare('UPDATE totp SET backup_codes = :backup_codes WHERE user_id = :id');
+ $statement->bindValue(':id', $userId, SQLITE3_INTEGER);
+ $statement->bindValue(':backup_codes', json_encode($backupCodes), SQLITE3_TEXT);
+ $statement->execute();
+
+ die(json_encode([
+ "success" => true,
+ "message" => translate('success', $i18n),
+ "reload" => true
+ ]));
+ } else {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('totp_code_incorrect', $i18n),
+ "reload" => false
+ ]));
+ }
+ }
+
+ } else {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('fields_missing', $i18n),
+ "reload" => false
+ ]));
+ }
+} else {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('invalid_request_method', $i18n),
+ "reload" => false
+ ]));
+}
\ No newline at end of file
diff --git a/endpoints/user/enable_totp.php b/endpoints/user/enable_totp.php
new file mode 100644
index 000000000..8e0bbdbc2
--- /dev/null
+++ b/endpoints/user/enable_totp.php
@@ -0,0 +1,139 @@
+= 80000) {
+ trigger_error(sprintf($message, ...$args), E_USER_DEPRECATED);
+ }
+ }
+}
+
+if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('session_expired', $i18n)
+ ]));
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "GET") {
+ function base32_encode($hex)
+ {
+ $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ $bin = '';
+ foreach (str_split($hex) as $char) {
+ $bin .= str_pad(base_convert($char, 16, 2), 4, '0', STR_PAD_LEFT);
+ }
+
+ $chunks = str_split($bin, 5);
+ $base32 = '';
+ foreach ($chunks as $chunk) {
+ $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
+ $index = bindec($chunk);
+ $base32 .= $alphabet[$index];
+ }
+
+ return $base32;
+ }
+
+ $data = $_GET;
+ if (isset($data['generate']) && $data['generate'] == true) {
+ $secret = base32_encode(bin2hex(random_bytes(20)));
+ $qrCodeUrl = "otpauth://totp/Wallos:" . $_SESSION['username'] . "?secret=" . $secret . "&issuer=Wallos";
+ $response = [
+ "success" => true,
+ "secret" => $secret,
+ "qrCodeUrl" => $qrCodeUrl
+ ];
+ echo json_encode($response);
+ }
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+ $postData = file_get_contents("php://input");
+ $data = json_decode($postData, true);
+
+ if (isset($data['totpSecret']) && $data['totpSecret'] != "" && isset($data['totpCode']) && $data['totpCode'] != "") {
+ require_once __DIR__ . '/../../libs/OTPHP/FactoryInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/Factory.php';
+ require_once __DIR__ . '/../../libs/OTPHP/ParameterTrait.php';
+ require_once __DIR__ . '/../../libs/OTPHP/OTPInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/OTP.php';
+ require_once __DIR__ . '/../../libs/OTPHP/TOTPInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/TOTP.php';
+ require_once __DIR__ . '/../../libs/Psr/Clock/ClockInterface.php';
+ require_once __DIR__ . '/../../libs/OTPHP/InternalClock.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/Binary.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/EncoderInterface.php';
+ require_once __DIR__ . '/../../libs/constant_time_encoding/Base32.php';
+
+ $secret = $data['totpSecret'];
+ $totp_code = $data['totpCode'];
+
+ // Check if user already has TOTP enabled
+ $stmt = $db->prepare("SELECT totp_enabled FROM user WHERE id = :user_id");
+ $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
+ $result = $stmt->execute();
+ $row = $result->fetchArray(SQLITE3_ASSOC);
+ if ($row['totp_enabled'] == 1) {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('2fa_already_enabled', $i18n)
+ ]));
+ }
+
+ $clock = new OTPHP\InternalClock();
+ $totp = OTPHP\TOTP::createFromSecret($secret, $clock);
+
+ if ($totp->verify($totp_code)) {
+ // Generate 10 backup codes
+ $backupCodes = [];
+ for ($i = 0; $i < 10; $i++) {
+ $backupCode = bin2hex(random_bytes(10));
+ $backupCodes[] = $backupCode;
+ }
+
+ // Remove old TOTP data
+ $stmt = $db->prepare("DELETE FROM totp WHERE user_id = :user_id");
+ $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
+ $stmt->execute();
+
+ $stmt = $db->prepare("INSERT INTO totp (user_id, totp_secret, backup_codes, last_totp_used) VALUES (:user_id, :totp_secret, :backup_codes, :last_totp_used)");
+ $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
+ $stmt->bindValue(':totp_secret', $secret, SQLITE3_TEXT);
+ $stmt->bindValue(':backup_codes', json_encode($backupCodes), SQLITE3_TEXT);
+ $stmt->bindValue(':last_totp_used', time(), SQLITE3_INTEGER);
+ $stmt->execute();
+
+ // Update user totp_enabled
+
+ $stmt = $db->prepare("UPDATE user SET totp_enabled = 1 WHERE id = :user_id");
+ $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
+ $stmt->execute();
+
+ die(json_encode([
+ "success" => true,
+ "backupCodes" => $backupCodes,
+ "message" => translate('success', $i18n)
+ ]));
+ } else {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('totp_code_incorrect', $i18n)
+ ]));
+ }
+
+ } else {
+ die(json_encode([
+ "success" => false,
+ "message" => translate('totp_code_incorrect', $i18n)
+ ]));
+ }
+
+
+
+
+}
\ No newline at end of file
diff --git a/includes/i18n/de.php b/includes/i18n/de.php
index 7c550746c..af3ddef72 100644
--- a/includes/i18n/de.php
+++ b/includes/i18n/de.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Avatar hochladen",
'file_type_error' => "Dateityp nicht unterstützt",
'user_details' => "Benutzerdetails",
+ 'two_factor_authentication' => "Zwei-Faktor-Authentifizierung",
+ 'two_factor_info' => "Die Zwei-Faktor-Authentifizierung fügt Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.
Sie benötigen eine Authentifizierungs-App wie Google Authenticator, Authy oder Ente Auth, um den QR-Code zu scannen.",
+ "two_factor_enabled_info" => "Ihr Konto ist mit der Zwei-Faktor-Authentifizierung gesichert. Sie können sie deaktivieren, indem Sie auf die Schaltfläche oben klicken.",
+ "enable_two_factor_authentication" => "Zwei-Faktor-Authentifizierung aktivieren",
+ "2fa_already_enabled" => "Zwei-Faktor-Authentifizierung ist bereits aktiviert",
+ "totp_code_incorrect" => "TOTP-Code ist falsch",
+ "backup_codes" => "Backup-Codes",
+ "download_backup_codes" => "Backup-Codes herunterladen",
+ "copy_to_clipboard" => "In die Zwischenablage kopieren",
+ "totp_backup_codes_info" => "Speichern Sie diese Codes an einem sicheren Ort. Sie können sie verwenden, wenn Sie keinen Zugriff auf Ihre Authentifizierungs-App haben.",
+ "disable_two_factor_authentication" => "Zwei-Faktor-Authentifizierung deaktivieren",
+ "totp_code" => "TOTP-Code",
"monthly_budget" => "Monatliches Budget",
"budget_info" => "Das monatliche Budget wird für die Berechnung der Statistiken verwendet.",
"household" => "Haushalt",
@@ -344,6 +356,8 @@
"month-10" => "Oktober",
"month-11" => "November",
"month-12" => "Dezember",
+ // TOTP Page
+ "insert_totp_code" => "Bitte geben Sie den TOTP-Code ein",
];
diff --git a/includes/i18n/el.php b/includes/i18n/el.php
index 8c6dd87ef..95afd1ec6 100644
--- a/includes/i18n/el.php
+++ b/includes/i18n/el.php
@@ -120,6 +120,18 @@
'upload_avatar' => "μεταφόρτωση άβαταρ",
'file_type_error' => "Το αρχείο πρέπει να είναι τύπου jpg, jpeg, png, webp ή gif",
'user_details' => "Λεπτομέρειες χρήστη",
+ 'two_factor_authentication' => "Διπλής πιστοποίησης",
+ "two_factor_info" => "Ο έλεγχος ταυτότητας δύο παραγόντων προσθέτει ένα επιπλέον επίπεδο ασφάλειας στο λογαριασμό σας.
Θα χρειαστείτε μια εφαρμογή ελέγχου ταυτότητας όπως το Google Authenticator, το Authy ή το Ente Auth για να σαρώσετε τον κωδικό QR.",
+ "two_factor_enabled_info" => "Ο λογαριασμός σας είναι ασφαλής με τον έλεγχο ταυτότητας δύο παραγόντων. Μπορείτε να τον απενεργοποιήσετε κάνοντας κλικ στο κουμπί παραπάνω.",
+ "enable_two_factor_authentication" => "Ενεργοποίηση διπλής πιστοποίησης",
+ "2fa_already_enabled" => "Ο έλεγχος ταυτότητας δύο παραγόντων είναι ήδη ενεργοποιημένος",
+ "totp_code_incorrect" => "Ο κωδικός TOTP είναι εσφαλμένος",
+ "backup_codes" => "Κωδικοί ανάκτησης",
+ "download_backup_codes" => "Κατέβασε τους κωδικούς ανάκτησης",
+ "copy_to_clipboard" => "Αντιγραφή στο πρόχειρο",
+ "totp_backup_codes_info" => "Αποθηκεύστε αυτούς τους κωδικούς ανάκτησης σε ένα ασφαλές μέρος. Θα χρειαστείτε έναν από αυτούς τους κωδικούς ανάκτησης για να αποκτήσετε πρόσβαση στο λογαριασμό σας σε περίπτωση που χάσετε τη συσκευή σας.",
+ "disable_two_factor_authentication" => "Απενεργοποίηση διπλής πιστοποίησης",
+ "totp_code" => "Κωδικός TOTP",
"monthly_budget" => "Μηνιαίος προϋπολογισμός",
"budget_info" => "Ο μηνιαίος προϋπολογισμός χρησιμοποιείται για τον υπολογισμό των στατιστικών",
"household" => "Νοικοκυριό",
@@ -344,6 +356,8 @@
"month-10" => "Οκτώβριος",
"month-11" => "Νοέμβριος",
"month-12" => "Δεκέμβριος",
+ // TOTP Page
+ "insert_totp_code" => "Εισάγετε τον κωδικό TOTP",
];
diff --git a/includes/i18n/en.php b/includes/i18n/en.php
index 653e2a54e..c873fe50e 100644
--- a/includes/i18n/en.php
+++ b/includes/i18n/en.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Upload Avatar",
'file_type_error' => "The file type supplied is not supported.",
'user_details' => "User Details",
+ 'two_factor_authentication' => "Two Factor Authentication",
+ 'two_factor_info' => "Two Factor Authentication adds an extra layer of security to your account.
You will need an authenticator app like Google Authenticator, Authy or Ente Auth to scan the QR code.",
+ "two_factor_enabled_info" => "Your account is secure with Two Factor Authentication. You can disable it by clicking the button above.",
+ "enable_two_factor_authentication" => "Enable Two Factor Authentication",
+ "2fa_already_enabled" => "Two Factor Authentication is already enabled",
+ "totp_code_incorrect" => "TOTP code is incorrect",
+ "backup_codes" => "Backup Codes",
+ "download_backup_codes" => "Download Backup Codes",
+ "copy_to_clipboard" => "Copy to clipboard",
+ "totp_backup_codes_info" => "These codes can be used to login if you lose access to your authenticator app.",
+ "disable_two_factor_authentication" => "Disable Two Factor Authentication",
+ "totp_code" => "TOTP Code",
"monthly_budget" => "Monthly Budget",
"budget_info" => "Monthly budget is used to calculate statistics",
"household" => "Household",
@@ -345,6 +357,8 @@
"month-10" => "October",
"month-11" => "November",
"month-12" => "December",
+ // TOTP Page
+ "insert_totp_code" => "Insert TOTP code",
];
diff --git a/includes/i18n/es.php b/includes/i18n/es.php
index 71901063b..947d5bee4 100644
--- a/includes/i18n/es.php
+++ b/includes/i18n/es.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Subir avatar",
'file_type_error' => "El archivo debe ser una imagen en formato PNG, JPG, WEBP o SVG",
'user_details' => "Detalles del Usuario",
+ 'two_factor_authentication' => "Autenticación de Dos Factores",
+ 'two_factor_info' => "La autenticación de dos factores añade una capa adicional de seguridad a tu cuenta.
Necesitarás una aplicación de autenticación como Google Authenticator, Authy o Ente Auth para escanear el código QR.",
+ 'two_factor_enabled_info' => "Tu cuenta está segura con la autenticación de dos factores. Puedes desactivarla haciendo clic en el botón de arriba.",
+ "enable_two_factor_authentication" => "Habilitar Autenticación de Dos Factores",
+ "2fa_already_enabled" => "La autenticación de dos factores ya está habilitada",
+ "totp_code_incorrect" => "El código TOTP es incorrecto",
+ "backup_codes" => "Códigos de Respaldo",
+ "download_backup_codes" => "Descargar Códigos de Respaldo",
+ "copy_to_clipboard" => "Copiar al Portapapeles",
+ "totp_backup_codes_info" => "Guarda estos códigos en un lugar seguro. Puedes usarlos si pierdes acceso a tu aplicación de autenticación.",
+ "disable_two_factor_authentication" => "Desactivar Autenticación de Dos Factores",
+ "totp_code" => "Código TOTP",
"monthly_budget" => "Presupuesto Mensual",
"budget_info" => "El presupuesto mensual se utiliza para calcular las estadísticas. Si no deseas utilizar esta función, déjalo en 0.",
"household" => "Hogar",
@@ -344,6 +356,8 @@
"month-10" => "Octubre",
"month-11" => "Noviembre",
"month-12" => "Diciembre",
+ // TOTP Page
+ "insert_totp_code" => "Introduce el código TOTP",
];
diff --git a/includes/i18n/fr.php b/includes/i18n/fr.php
index 4e378dea9..afb52a8ce 100644
--- a/includes/i18n/fr.php
+++ b/includes/i18n/fr.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Télécharger un Avatar",
'file_type_error' => "Le type de fichier n'est pas pris en charge",
'user_details' => "Détails de l'utilisateur",
+ 'two_factor_authentication' => "Authentification à deux facteurs",
+ 'two_factor_info' => "L'authentification à deux facteurs ajoute une couche supplémentaire de sécurité à votre compte.
Vous aurez besoin d'une application d'authentification comme Google Authenticator, Authy ou Ente Auth pour scanner le code QR.",
+ 'two_factor_enabled_info' => "Votre compte est sécurisé grâce à l'authentification à deux facteurs. Vous pouvez la désactiver en cliquant sur le bouton ci-dessus.",
+ "enable_two_factor_authentication" => "Activer l'authentification à deux facteurs",
+ "2fa_already_enabled" => "L'authentification à deux facteurs est déjà activée",
+ "totp_code_incorrect" => "Le code TOTP est incorrect",
+ "backup_codes" => "Codes de sauvegarde",
+ "download_backup_codes" => "Télécharger les codes de sauvegarde",
+ "copy_to_clipboard" => "Copier dans le presse-papiers",
+ "totp_backup_codes_info" => "Conservez ces codes en lieu sûr. Vous ne pourrez pas les récupérer plus tard.",
+ "disable_two_factor_authentication" => "Désactiver l'authentification à deux facteurs",
+ "totp_code" => "Code TOTP",
"monthly_budget" => "Budget mensuel",
"budget_info" => "Le budget mensuel est utilisé pour calculer les statistiques. Laissez vide pour désactiver.",
"household" => "Ménage",
@@ -344,6 +356,8 @@
"month-10" => "Octobre",
"month-11" => "Novembre",
"month-12" => "Décembre",
+ // TOTP Page
+ "insert_totp_code" => "Veuillez insérer le code TOTP",
];
diff --git a/includes/i18n/it.php b/includes/i18n/it.php
index 561ac9020..e3eee79af 100644
--- a/includes/i18n/it.php
+++ b/includes/i18n/it.php
@@ -128,6 +128,18 @@
'upload_avatar' => 'Carica avatar',
'file_type_error' => 'Il tipo di file fornito non è supportato.',
'user_details' => 'Dettagli utente',
+ 'two_factor_authentication' => 'Autenticazione a due fattori',
+ 'two_factor_info' => "L'Autenticazione a due fattori aggiunge un ulteriore livello di sicurezza al vostro account.
Per scansionare il codice QR è necessaria un'app di autenticazione come Google Authenticator, Authy o Ente Auth.",
+ 'two_factor_enabled_info' => "Il vostro account è sicuro con l'Autenticazione a due fattori. È possibile disattivarla facendo clic sul pulsante in alto.",
+ "enable_two_factor_authentication" => "Abilita l'autenticazione a due fattori",
+ "2fa_already_enabled" => "L'autenticazione a due fattori è già abilitata",
+ "totp_code_incorrect" => "Il codice TOTP è incorretto",
+ "backup_codes" => "Codici di backup",
+ "download_backup_codes" => "Scarica i codici di backup",
+ "copy_to_clipboard" => "Copia negli appunti",
+ "totp_backup_codes_info" => "I codici di backup possono essere utilizzati per accedere al tuo account se non hai accesso al tuo dispositivo di autenticazione a due fattori.",
+ "disable_two_factor_authentication" => "Disabilita l'autenticazione a due fattori",
+ "totp_code" => "Codice TOTP",
"monthly_budget" => "Budget mensile",
"budget_info" => "Il budget mensile viene utilizzato per calcolare le statistiche. Se non si desidera utilizzare questa funzionalità, impostare il budget su 0.",
'household' => 'Nucleo familiare',
@@ -364,6 +376,9 @@
"month-10" => "Ottobre",
"month-11" => "Novembre",
"month-12" => "Dicembre",
+
+ // TOTP Page
+ "insert_totp_code" => "Inserisci il codice TOTP",
];
?>
\ No newline at end of file
diff --git a/includes/i18n/jp.php b/includes/i18n/jp.php
index b32b466b9..4abdce71b 100644
--- a/includes/i18n/jp.php
+++ b/includes/i18n/jp.php
@@ -120,6 +120,18 @@
'upload_avatar' => "アバターをアップロードする",
'file_type_error' => "ファイルタイプが許可されていません",
'user_details' => "ユーザー詳細",
+ 'two_factor_authentication' => "2要素認証",
+ 'two_factor_info' => "二要素認証は、アカウントに追加のセキュリティレイヤーを追加します。QR コードをスキャンするには、Google Authenticator、Authy、Ente Auth などの認証アプリが必要です。",
+ 'two_factor_enabled_info' => "お客様のアカウントは二要素認証で保護されています。上のボタンをクリックして無効にすることができます。",
+ "enable_two_factor_authentication" => "二要素認証を有効にする",
+ "2fa_already_enabled" => "2要素認証は既に有効です",
+ "totp_code_incorrect" => "TOTPコードが正しくありません",
+ "backup_codes" => "バックアップコード",
+ "download_backup_codes" => "バックアップコードをダウンロード",
+ "copy_to_clipboard" => "クリップボードにコピー",
+ "totp_backup_codes_info" => "これらのコードは、2要素認証アプリが利用できない場合に使用します。コードは一度しか表示されません。",
+ "disable_two_factor_authentication" => "二要素認証を無効にする",
+ "totp_code" => "TOTPコード",
"monthly_budget" => "月間予算",
"budget_info" => "予算を設定すると、統計ページで予算と実際の支出を比較できます。",
"household" => "世帯",
@@ -337,6 +349,8 @@
"month-10" => "10月",
"month-11" => "11月",
"month-12" => "12月",
+ // TOTP Page
+ "insert_totp_code" => "TOTPコードを入力してください",
];
diff --git a/includes/i18n/ko.php b/includes/i18n/ko.php
index d47c16488..b3e5909da 100644
--- a/includes/i18n/ko.php
+++ b/includes/i18n/ko.php
@@ -120,6 +120,18 @@
'upload_avatar' => "아바타 업로드",
'file_type_error' => "제공된 파일이 지원하지 않는 타입입니다.",
'user_details' => "유저 상세",
+ 'two_factor_authentication' => "이중 인증",
+ 'two_factor_info' => "2단계 인증은 계정에 보안을 한층 더 강화합니다. QR 코드를 스캔하려면 Google Authenticator, Authy 또는 Ente Auth와 같은 인증 앱이 필요합니다.",
+ 'two_factor_enabled_info' => "계정은 2단계 인증으로 안전하게 보호됩니다. 위의 버튼을 클릭하여 비활성화할 수 있습니다.",
+ "enable_two_factor_authentication" => "2단계 인증 활성화",
+ "2fa_already_enabled" => "2단계 인증이 이미 활성화되어 있습니다.",
+ "totp_code_incorrect" => "TOTP 코드가 올바르지 않습니다.",
+ "backup_codes" => "백업 코드",
+ "download_backup_codes" => "백업 코드 다운로드",
+ "copy_to_clipboard" => "클립보드로 복사",
+ "totp_backup_codes_info" => "이 코드는 계정에 대한 백업 코드입니다. 이 코드를 안전한 곳에 보관하세요. 이 코드는 한 번만 사용할 수 있습니다.",
+ "disable_two_factor_authentication" => "2단계 인증 비활성화",
+ "totp_code" => "TOTP 코드",
"monthly_budget" => "월간 예산",
"budget_info" => "예산을 설정하면 통계 페이지에서 예산과 실제 지출을 비교할 수 있습니다.",
"household" => "가구",
@@ -345,6 +357,8 @@
"month-10" => "10월",
"month-11" => "11월",
"month-12" => "12월",
+ // TOTP Page
+ "insert_totp_code" => "2단계 인증 코드를 입력하세요",
];
diff --git a/includes/i18n/pl.php b/includes/i18n/pl.php
index bc1122734..7619c7752 100644
--- a/includes/i18n/pl.php
+++ b/includes/i18n/pl.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Prześlij awatar",
'file_type_error' => "Podany typ pliku nie jest obsługiwany.",
'user_details' => "Szczegóły użytkownika",
+ 'two_factor_authentication' => "Uwierzytelnianie dwuskładnikowe",
+ 'two_factor_info' => "Uwierzytelnianie dwuskładnikowe dodaje dodatkową warstwę zabezpieczeń do konta.
Do zeskanowania kodu QR potrzebna będzie aplikacja uwierzytelniająca, taka jak Google Authenticator, Authy lub Ente Auth.",
+ 'two_factor_enabled_info' => "Twoje konto jest bezpieczne dzięki uwierzytelnianiu dwuetapowemu. Możesz ją wyłączyć, klikając przycisk powyżej.",
+ "enable_two_factor_authentication" => "Włącz uwierzytelnianie dwuskładnikowe",
+ "2fa_already_enabled" => "Uwierzytelnianie dwuskładnikowe jest już włączone",
+ "totp_code_incorrect" => "Kod TOTP jest nieprawidłowy",
+ "backup_codes" => "Kody zapasowe",
+ "download_backup_codes" => "Pobierz kody zapasowe",
+ "copy_to_clipboard" => "Skopiuj do schowka",
+ "totp_backup_codes_info" => "Kody zapasowe są jednorazowe i można je użyć do zalogowania się, jeśli nie masz dostępu do aplikacji uwierzytelniającej.",
+ "disable_two_factor_authentication" => "Wyłącz uwierzytelnianie dwuskładnikowe",
+ "totp_code" => "Kod TOTP",
"monthly_budget" => "Miesięczny budżet",
"budget_info" => "Jeśli ustawisz budżet, zobaczysz pasek postępu na stronie głównej.",
"household" => "Gospodarstwo domowe",
@@ -344,6 +356,8 @@
"month-10" => "Październik",
"month-11" => "Listopad",
"month-12" => "Grudzień",
+ // TOTP Page
+ "insert_totp_code" => "Wprowadź kod TOTP",
];
diff --git a/includes/i18n/pt.php b/includes/i18n/pt.php
index 50e41434b..7608597ee 100644
--- a/includes/i18n/pt.php
+++ b/includes/i18n/pt.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Enviar avatar",
'file_type_error' => "Tipo de ficheiro não permitido",
'user_details' => "Detalhes do utilizador",
+ 'two_factor_authentication' => "Autenticação de dois fatores",
+ 'two_factor_info' => "A autenticação de dois factores acrescenta uma camada extra de segurança à sua conta.
Necessitará de uma aplicação de autenticação como o Google Authenticator, Authy ou Ente Auth para ler o código QR.",
+ 'two_factor_enabled_info' => "A sua conta está segura com a autenticação de dois factores. Pode desactivá-la clicando no botão acima.",
+ "enable_two_factor_authentication" => "Activar autenticação de dois factores",
+ "2fa_already_enabled" => "A autenticação de dois factores já está activada",
+ "totp_code_incorrect" => "Código TOTP incorrecto",
+ "backup_codes" => "Códigos de Backup",
+ "download_backup_codes" => "Descarregar códigos de backup",
+ "copy_to_clipboard" => "Copiar para a área de transferência",
+ "totp_backup_codes_info" => "Guarde estes códigos num local seguro. Pode usá-los para aceder à sua conta se perder o acesso ao seu dispositivo de autenticação.",
+ "disable_two_factor_authentication" => "Desactivar autenticação de dois factores",
+ "totp_code" => "Código TOTP",
"monthly_budget" => "Orçamento Mensal",
"budget_info" => "Ao definir um orçamento pode comparar com os gastos reais na página de estatísticas.",
"household" => "Agregado",
@@ -344,6 +356,8 @@
"month-10" => "Outubro",
"month-11" => "Novembro",
"month-12" => "Dezembro",
+ // TOTP Page
+ "insert_totp_code" => "Insira o código TOTP",
];
diff --git a/includes/i18n/pt_br.php b/includes/i18n/pt_br.php
index ed4f21e37..211d332a5 100644
--- a/includes/i18n/pt_br.php
+++ b/includes/i18n/pt_br.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Carregar avatar",
'file_type_error' => "Tipo de arquivo não permitido",
'user_details' => "Informações do Usuário",
+ 'two_factor_authentication' => "Autenticação de dois fatores",
+ 'two_factor_info' => "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta.
Você precisará de um aplicativo autenticador, como o Google Authenticator, Authy ou Ente Auth, para ler o código QR.",
+ 'two_factor_enabled_info' => "Sua conta está segura com a autenticação de dois fatores. Você pode desativá-la clicando no botão acima.",
+ "enable_two_factor_authentication" => "Ativar autenticação de dois fatores",
+ "2fa_already_enabled" => "A autenticação de dois fatores já está ativada",
+ "totp_code_incorrect" => "Código TOTP incorreto",
+ "backup_codes" => "Códigos de backup",
+ "download_backup_codes" => "Baixar códigos de backup",
+ "copy_to_clipboard" => "Copiar para a área de transferência",
+ "totp_backup_codes_info" => "Guarde esses códigos em um lugar seguro. Eles podem ser usados para acessar sua conta se você perder o acesso ao aplicativo de autenticação.",
+ "disable_two_factor_authentication" => "Desativar autenticação de dois fatores",
+ "totp_code" => "Código TOTP",
"monthly_budget" => "Orçamento mensal",
"budget_info" => "O orçamento mensal é usado para calcular estatísticas",
"household" => "Membros",
@@ -344,6 +356,8 @@
"month-10" => "Outubro",
"month-11" => "Novembro",
"month-12" => "Dezembro",
+ // TOTP Page
+ "insert_totp_code" => "Insira o código TOTP",
];
diff --git a/includes/i18n/ru.php b/includes/i18n/ru.php
index 4ec39da0a..57e3522e5 100644
--- a/includes/i18n/ru.php
+++ b/includes/i18n/ru.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Загрузить аватар",
'file_type_error' => "Указанный тип файла не поддерживается.",
'user_details' => "Данные пользователя",
+ 'two_factor_authentication' => "Двухфакторная аутентификация",
+ 'two_factor_info' => "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи.
Для сканирования QR-кода вам понадобится приложение-аутентификатор, например Google Authenticator, Authy или Ente Auth.",
+ 'two_factor_enabled_info' => "Ваш аккаунт защищен с помощью двухфакторной аутентификации. Вы можете отключить ее, нажав на кнопку выше.",
+ "enable_two_factor_authentication" => "Включить двухфакторную аутентификацию",
+ "2fa_already_enabled" => "Двухфакторная аутентификация уже включена",
+ "totp_code_incorrect" => "Код TOTP неверен",
+ "backup_codes" => "Резервные коды",
+ "download_backup_codes" => "Скачать резервные коды",
+ "copy_to_clipboard" => "Скопировать в буфер обмена",
+ "totp_backup_codes_info" => "Сохраните эти коды в безопасном месте. Они могут быть использованы для входа в систему, если вы потеряете доступ к приложению аутентификации.",
+ "disable_two_factor_authentication" => "Отключить двухфакторную аутентификацию",
+ "totp_code" => "Код TOTP",
"monthly_budget" => "Ежемесячный бюджет",
"budget_info" => "Если вы укажете бюджет, Wallos будет отображать вашу текущую стоимость подписок в сравнении с вашим бюджетом.",
"household" => "Семья",
@@ -344,6 +356,8 @@
"month-10" => "Октябрь",
"month-11" => "Ноябрь",
"month-12" => "Декабрь",
+ // TOTP Page
+ "insert_totp_code" => "Введите код TOTP",
];
diff --git a/includes/i18n/sl.php b/includes/i18n/sl.php
index 395fac72b..1fbeda7da 100644
--- a/includes/i18n/sl.php
+++ b/includes/i18n/sl.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Naloži avatar",
'file_type_error' => "Vrsta datoteke ni podprta.",
'user_details' => "Podrobnosti o uporabniku",
+ 'two_factor_authentication' => "Dvojna preverba pristnosti",
+ 'two_factor_info' => "Two Factor Authentication adds an extra layer of security to your account.
Za optično branje kode QR potrebujete aplikacijo za preverjanje pristnosti, kot so Google Authenticator, Authy ali Ente Auth.",
+ 'two_factor_enabled_info' => "Vaš račun je varen z dvostopenjskim preverjanjem pristnosti. Onemogočite jo lahko tako, da kliknete zgornji gumb.",
+ "enable_two_factor_authentication" => "Omogoči dvostopenjsko preverjanje pristnosti",
+ "2fa_already_enabled" => "Dvostopenjsko preverjanje pristnosti je že omogočeno",
+ "totp_code_incorrect" => "Koda TOTP je napačna",
+ "backup_codes" => "Rezervne kode",
+ "download_backup_codes" => "Prenesi rezervne kode",
+ "copy_to_clipboard" => "Kopiraj v odložišče",
+ "totp_backup_codes_info" => "Shranite te rezervne kode na varno mesto. Uporabite jih lahko, če izgubite dostop do svoje aplikacije za preverjanje pristnosti.",
+ "disable_two_factor_authentication" => "Onemogoči dvostopenjsko preverjanje pristnosti",
+ "totp_code" => "TOTP koda",
"monthly_budget" => "Mesečni proračun",
"budget_info" => "Mesečni proračun se uporablja za izračun statistike",
"household" => "Gospodinjstvo",
@@ -337,6 +349,8 @@
"month-10" => "Oktober",
"month-11" => "November",
"month-12" => "December",
+ // TOTP Page
+ "insert_totp_code" => "Vnesite kodo TOTP",
];
diff --git a/includes/i18n/sr.php b/includes/i18n/sr.php
index 75b124da9..713086988 100644
--- a/includes/i18n/sr.php
+++ b/includes/i18n/sr.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Постави аватар",
'file_type_error' => "Датотека није у подржаном формату.",
'user_details' => "Кориснички детаљи",
+ 'two_factor_authentication' => "Двофакторска аутентикација",
+ 'two_factor_info' => "Двофакторска аутентификација додаје додатни ниво сигурности вашем налогу. <бр>Биће вам потребна апликација за аутентификацију као што је Гоогле Аутхентицатор, Аутхи или Енте Аутх да бисте скенирали КР код.",
+ 'two_factor_enabled_info' => "Ваш налог је сигуран са двофакторском аутентификацијом. Можете га онемогућити кликом на дугме изнад.",
+ "enable_two_factor_authentication" => "Омогући двофакторску аутентикацију",
+ "2fa_already_enabled" => "Двофакторска аутентикација је већ омогућена",
+ "totp_code_incorrect" => "ТОТП код није исправан",
+ "backup_codes" => "Резервни кодови",
+ "download_backup_codes" => "Преузми резервне кодове",
+ "copy_to_clipboard" => "Копирај у клипборд",
+ "totp_backup_codes_info" => "Сачувајте ове кодове на безбедно место. Користићете их када изгубите приступ апликацији за аутентификацију.",
+ "disable_two_factor_authentication" => "Онемогући двофакторску аутентикацију",
+ "totp_code" => "ТОТП код",
"monthly_budget" => "Месечни буџет",
"budget_info" => "Унесите месечни буџет да бисте видели препоручену максималну цену претплате на почетној страници.",
"household" => "Домаћинство",
@@ -344,6 +356,8 @@
"month-10" => "Октобар",
"month-11" => "Новембар",
"month-12" => "Децембар",
+ // TOTP Page
+ "insert_totp_code" => "Унесите ТОТП код",
];
diff --git a/includes/i18n/sr_lat.php b/includes/i18n/sr_lat.php
index 5b78af93b..6a5c89ae2 100644
--- a/includes/i18n/sr_lat.php
+++ b/includes/i18n/sr_lat.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Učitaj avatar",
'file_type_error' => "Tip datoteke koji ste priložili nije podržan.",
'user_details' => "Detalji korisnika",
+ 'two_factor_authentication' => "Dvostruka autentifikacija",
+ 'two_factor_info' => "Dvofaktorska autentifikacija dodaje dodatni nivo sigurnosti vašem nalogu.
Biće vam potrebna aplikacija za autentifikaciju kao što je Google Authenticator, Authi ili Ente Auth da biste skenirali KR kod.",
+ 'two_factor_enabled_info' => "Vaš nalog je siguran sa dvofaktorskom autentifikacijom. Možete ga onemogućiti klikom na dugme iznad.",
+ "enable_two_factor_authentication" => "Omogući dvofaktorsku autentifikaciju",
+ "2fa_already_enabled" => "Dvofaktorska autentifikacija je već omogućena",
+ "totp_code_incorrect" => "Kod za dvofaktorsku autentifikaciju nije tačan",
+ "backup_codes" => "Rezervni kodovi",
+ "download_backup_codes" => "Preuzmi rezervne kodove",
+ "copy_to_clipboard" => "Kopiraj u clipboard",
+ "totp_backup_codes_info" => "Ovo su vaši rezervni kodovi za dvofaktorsku autentifikaciju. Sačuvajte ih na sigurnom mestu.",
+ "disable_two_factor_authentication" => "Onemogući dvofaktorsku autentifikaciju",
+ "totp_code" => "Kod za dvofaktorsku autentifikaciju",
"monthly_budget" => "Mesečni budžet",
"budget_info" => "Ovo je vaš mesečni budžet za sve pretplate. Ovo je samo informativno i ne ograničava vas.",
"household" => "Domaćinstvo",
@@ -344,6 +356,8 @@
"month-10" => "Oktobar",
"month-11" => "Novembar",
"month-12" => "Decembar",
+ // TOTP Page
+ "insert_totp_code" => "Unesite TOTP kod",
];
diff --git a/includes/i18n/tr.php b/includes/i18n/tr.php
index 9184ec033..cf6b13dfa 100644
--- a/includes/i18n/tr.php
+++ b/includes/i18n/tr.php
@@ -120,6 +120,18 @@
'upload_avatar' => "Avatarı yükle",
'file_type_error' => "Dosya türü izin verilmiyor",
'user_details' => "Kullanıcı Detayları",
+ 'two_factor_authentication' => "İki Faktörlü Kimlik Doğrulama",
+ 'two_factor_info' => "İki Faktörlü Kimlik Doğrulama, hesabınıza ekstra bir güvenlik katmanı ekler.
Karekodu taramak için Google Authenticator, Authy veya Ente Auth gibi bir kimlik doğrulayıcı uygulamasına ihtiyacınız olacaktır.",
+ 'two_factor_enabled_info' => "Hesabınız İki Faktörlü Kimlik Doğrulama ile güvendedir. Yukarıdaki düğmeye tıklayarak devre dışı bırakabilirsiniz.",
+ "enable_two_factor_authentication" => "İki Faktörlü Kimlik Doğrulamayı Etkinleştir",
+ "2fa_already_enabled" => "İki Faktörlü Kimlik Doğrulama zaten etkinleştirildi",
+ "totp_code_incorrect" => "TOTP kodu yanlış",
+ "backup_codes" => "Yedek Kodlar",
+ "download_backup_codes" => "Yedek Kodları İndir",
+ "copy_to_clipboard" => "Panoya Kopyala",
+ "totp_backup_codes_info" => "Yedek kodları güvenli bir yerde saklayın. Her biri yalnızca bir kez kullanılabilir.",
+ "disable_two_factor_authentication" => "İki Faktörlü Kimlik Doğrulamayı Devre Dışı Bırak",
+ "totp_code" => "TOTP Kodu",
"monthly_budget" => "Aylık Bütçe",
"budget_info" => "Bir bütçe belirlemek, istatistik sayfasında bütçe ve gerçek harcamaları karşılaştırmanıza olanak tanır.",
"household" => "Hane",
@@ -344,6 +356,8 @@
"month-10" => "Ekim",
"month-11" => "Kasım",
"month-12" => "Aralık",
+ // TOTP Page
+ "insert_totp_code" => "Lütfen TOTP kodunuzu girin",
];
diff --git a/includes/i18n/zh_cn.php b/includes/i18n/zh_cn.php
index ac01dcb09..8bcf7eba8 100644
--- a/includes/i18n/zh_cn.php
+++ b/includes/i18n/zh_cn.php
@@ -128,6 +128,18 @@
'upload_avatar' => "上传头像",
'file_type_error' => "文件类型不允许",
'user_details' => "用户详情",
+ 'two_factor_authentication' => "双因素认证",
+ 'two_factor_info' => "双因素身份验证为您的账户增加了一层额外的安全保护。您需要使用 Google Authenticator、Authy 或 Ente Auth 等认证程序来扫描二维码。",
+ 'two_factor_enabled_info' => "双因素身份验证确保您的账户安全。您可以单击上面的按钮禁用它。",
+ "enable_two_factor_authentication" => "启用双因素身份验证",
+ "2fa_already_enabled" => "双因素身份验证已启用",
+ "totp_code_incorrect" => "TOTP 代码不正确",
+ "backup_codes" => "备份代码",
+ "download_backup_codes" => "下载备份代码",
+ "copy_to_clipboard" => "复制到剪贴板",
+ "totp_backup_codes_info" => "请务必保存这些备份代码。如果您丢失了双因素身份验证设备,您将需要这些备份代码来登录。",
+ "disable_two_factor_authentication" => "禁用双因素身份验证",
+ "totp_code" => "TOTP 代码",
"monthly_budget" => "每月预算",
"budget_info" => "设置预算后,您可以在统计页面上比较预算和实际支出。",
"household" => "家庭",
@@ -365,6 +377,9 @@
"month-11" => "十一月",
"month-12" => "十二月",
+ // TOTP Page
+ "insert_totp_code" => "请输入 TOTP 代码",
+
];
?>
\ No newline at end of file
diff --git a/includes/i18n/zh_tw.php b/includes/i18n/zh_tw.php
index 275bea30b..c94ab24ea 100644
--- a/includes/i18n/zh_tw.php
+++ b/includes/i18n/zh_tw.php
@@ -120,6 +120,18 @@
'upload_avatar' => "上传头像",
'file_type_error' => "文件类型不允许",
'user_details' => "使用者詳細資訊",
+ 'two_factor_authentication' => "雙因素驗證",
+ 'two_factor_info' => "雙因素驗證為您的帳戶增加了一層額外的安全性。您需要使用 Google Authenticator、Authy 或 Ente Auth 等驗證器應用程式來掃描 QR 代碼。",
+ 'two_factor_enabled_info' => "您的帳戶有雙重認證,十分安全。您可以按一下上面的按鈕停用它。",
+ "enable_two_factor_authentication" => "啟用雙因素驗證",
+ "2fa_already_enabled" => "雙因素驗證已經啟用",
+ "totp_code_incorrect" => "TOTP 驗證碼不正確",
+ "backup_codes" => "備份代碼",
+ "download_backup_codes" => "下載備份代碼",
+ "copy_to_clipboard" => "複製到剪貼板",
+ "totp_backup_codes_info" => "請妥善保管這些代碼。當您無法使用雙因素驗證應用程式時,您可以使用這些代碼來登入。",
+ "disable_two_factor_authentication" => "停用雙因素驗證",
+ "totp_code" => "TOTP 驗證碼",
"monthly_budget" => "每月預算",
"budget_info" => "設定預算後,您可以在統計頁面上比較預算和實際支出。",
"household" => "家庭",
@@ -345,6 +357,9 @@
"month-11" => "十一月",
"month-12" => "十二月",
+ // TOTP Page
+ "insert_totp_code" => "請輸入 TOTP 驗證碼",
+
];
diff --git a/includes/version.php b/includes/version.php
index 8baed2d45..8e607f72b 100644
--- a/includes/version.php
+++ b/includes/version.php
@@ -1,3 +1,3 @@
\ No newline at end of file
diff --git a/libs/OTPHP/Factory.php b/libs/OTPHP/Factory.php
new file mode 100644
index 000000000..4bf41a84a
--- /dev/null
+++ b/libs/OTPHP/Factory.php
@@ -0,0 +1,104 @@
+getScheme() === 'otpauth' || throw new InvalidArgumentException('Invalid scheme.');
+ } catch (Throwable $throwable) {
+ throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable);
+ }
+ if ($clock === null) {
+ trigger_deprecation(
+ 'spomky-labs/otphp',
+ '11.3.0',
+ 'The parameter "$clock" will become mandatory in 12.0.0. Please set a valid PSR Clock implementation instead of "null".'
+ );
+ $clock = new InternalClock();
+ }
+
+ $otp = self::createOTP($parsed_url, $clock);
+
+ self::populateOTP($otp, $parsed_url);
+
+ return $otp;
+ }
+
+ private static function populateParameters(OTPInterface $otp, Url $data): void
+ {
+ foreach ($data->getQuery() as $key => $value) {
+ $otp->setParameter($key, $value);
+ }
+ }
+
+ private static function populateOTP(OTPInterface $otp, Url $data): void
+ {
+ self::populateParameters($otp, $data);
+ $result = explode(':', rawurldecode(mb_substr($data->getPath(), 1)));
+
+ if (count($result) < 2) {
+ $otp->setIssuerIncludedAsParameter(false);
+
+ return;
+ }
+
+ if ($otp->getIssuer() !== null) {
+ $result[0] === $otp->getIssuer() || throw new InvalidArgumentException(
+ 'Invalid OTP: invalid issuer in parameter'
+ );
+ $otp->setIssuerIncludedAsParameter(true);
+ }
+
+ assert($result[0] !== '');
+
+ $otp->setIssuer($result[0]);
+ }
+
+ private static function createOTP(Url $parsed_url, ClockInterface $clock): OTPInterface
+ {
+ switch ($parsed_url->getHost()) {
+ case 'totp':
+ $totp = TOTP::createFromSecret($parsed_url->getSecret(), $clock);
+ $totp->setLabel(self::getLabel($parsed_url->getPath()));
+
+ return $totp;
+ case 'hotp':
+ $hotp = HOTP::createFromSecret($parsed_url->getSecret());
+ $hotp->setLabel(self::getLabel($parsed_url->getPath()));
+
+ return $hotp;
+ default:
+ throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost()));
+ }
+ }
+
+ /**
+ * @param non-empty-string $data
+ * @return non-empty-string
+ */
+ private static function getLabel(string $data): string
+ {
+ $result = explode(':', rawurldecode(mb_substr($data, 1)));
+ $label = count($result) === 2 ? $result[1] : $result[0];
+ assert($label !== '');
+
+ return $label;
+ }
+}
diff --git a/libs/OTPHP/FactoryInterface.php b/libs/OTPHP/FactoryInterface.php
new file mode 100644
index 000000000..dd14e45f9
--- /dev/null
+++ b/libs/OTPHP/FactoryInterface.php
@@ -0,0 +1,16 @@
+setCounter($counter);
+ $htop->setDigest($digest);
+ $htop->setDigits($digits);
+
+ return $htop;
+ }
+
+ public static function createFromSecret(string $secret): self
+ {
+ $htop = new self($secret);
+ $htop->setCounter(self::DEFAULT_COUNTER);
+ $htop->setDigest(self::DEFAULT_DIGEST);
+ $htop->setDigits(self::DEFAULT_DIGITS);
+
+ return $htop;
+ }
+
+ public static function generate(): self
+ {
+ return self::createFromSecret(self::generateSecret());
+ }
+
+ /**
+ * @return 0|positive-int
+ */
+ public function getCounter(): int
+ {
+ $value = $this->getParameter('counter');
+ (is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "counter" parameter.');
+
+ return $value;
+ }
+
+ public function getProvisioningUri(): string
+ {
+ return $this->generateURI('hotp', [
+ 'counter' => $this->getCounter(),
+ ]);
+ }
+
+ /**
+ * If the counter is not provided, the OTP is verified at the actual counter.
+ *
+ * @param null|0|positive-int $counter
+ */
+ public function verify(string $otp, null|int $counter = null, null|int $window = null): bool
+ {
+ $counter >= 0 || throw new InvalidArgumentException('The counter must be at least 0.');
+
+ if ($counter === null) {
+ $counter = $this->getCounter();
+ } elseif ($counter < $this->getCounter()) {
+ return false;
+ }
+
+ return $this->verifyOtpWithWindow($otp, $counter, $window);
+ }
+
+ public function setCounter(int $counter): void
+ {
+ $this->setParameter('counter', $counter);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getParameterMap(): array
+ {
+ return [...parent::getParameterMap(), ...[
+ 'counter' => static function (mixed $value): int {
+ $value = (int) $value;
+ $value >= 0 || throw new InvalidArgumentException('Counter must be at least 0.');
+
+ return $value;
+ },
+ ]];
+ }
+
+ private function updateCounter(int $counter): void
+ {
+ $this->setCounter($counter);
+ }
+
+ /**
+ * @param null|0|positive-int $window
+ */
+ private function getWindow(null|int $window): int
+ {
+ return abs($window ?? self::DEFAULT_WINDOW);
+ }
+
+ /**
+ * @param non-empty-string $otp
+ * @param 0|positive-int $counter
+ * @param null|0|positive-int $window
+ */
+ private function verifyOtpWithWindow(string $otp, int $counter, null|int $window): bool
+ {
+ $window = $this->getWindow($window);
+
+ for ($i = $counter; $i <= $counter + $window; ++$i) {
+ if ($this->compareOTP($this->at($i), $otp)) {
+ $this->updateCounter($i + 1);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/libs/OTPHP/HOTPInterface.php b/libs/OTPHP/HOTPInterface.php
new file mode 100644
index 000000000..915569a03
--- /dev/null
+++ b/libs/OTPHP/HOTPInterface.php
@@ -0,0 +1,36 @@
+setSecret($secret);
+ }
+
+ public function getQrCodeUri(string $uri, string $placeholder): string
+ {
+ $provisioning_uri = urlencode($this->getProvisioningUri());
+
+ return str_replace($placeholder, $provisioning_uri, $uri);
+ }
+
+ /**
+ * @param 0|positive-int $input
+ */
+ public function at(int $input): string
+ {
+ return $this->generateOTP($input);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ final protected static function generateSecret(): string
+ {
+ return Base32::encodeUpper(random_bytes(self::DEFAULT_SECRET_SIZE));
+ }
+
+ /**
+ * The OTP at the specified input.
+ *
+ * @param 0|positive-int $input
+ *
+ * @return non-empty-string
+ */
+ protected function generateOTP(int $input): string
+ {
+ $hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret(), true);
+ $unpacked = unpack('C*', $hash);
+ $unpacked !== false || throw new InvalidArgumentException('Invalid data.');
+ $hmac = array_values($unpacked);
+
+ $offset = ($hmac[count($hmac) - 1] & 0xF);
+ $code = ($hmac[$offset] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF);
+ $otp = $code % (10 ** $this->getDigits());
+
+ return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT);
+ }
+
+ /**
+ * @param array $options
+ */
+ protected function filterOptions(array &$options): void
+ {
+ foreach ([
+ 'algorithm' => 'sha1',
+ 'period' => 30,
+ 'digits' => 6,
+ ] as $key => $default) {
+ if (isset($options[$key]) && $default === $options[$key]) {
+ unset($options[$key]);
+ }
+ }
+
+ ksort($options);
+ }
+
+ /**
+ * @param non-empty-string $type
+ * @param array $options
+ *
+ * @return non-empty-string
+ */
+ protected function generateURI(string $type, array $options): string
+ {
+ $label = $this->getLabel();
+ is_string($label) || throw new InvalidArgumentException('The label is not set.');
+ $this->hasColon($label) === false || throw new InvalidArgumentException('Label must not contain a colon.');
+ $options = [...$options, ...$this->getParameters()];
+ $this->filterOptions($options);
+ $params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options, '', '&'));
+
+ return sprintf(
+ 'otpauth://%s/%s?%s',
+ $type,
+ rawurlencode(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label),
+ $params
+ );
+ }
+
+ /**
+ * @param non-empty-string $safe
+ * @param non-empty-string $user
+ */
+ protected function compareOTP(string $safe, string $user): bool
+ {
+ return hash_equals($safe, $user);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ private function getDecodedSecret(): string
+ {
+ try {
+ $decoded = Base32::decodeUpper($this->getSecret());
+ } catch (Exception) {
+ throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
+ }
+ assert($decoded !== '');
+
+ return $decoded;
+ }
+
+ private function intToByteString(int $int): string
+ {
+ $result = [];
+ while ($int !== 0) {
+ $result[] = chr($int & 0xFF);
+ $int >>= 8;
+ }
+
+ return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT);
+ }
+}
diff --git a/libs/OTPHP/OTPInterface.php b/libs/OTPHP/OTPInterface.php
new file mode 100644
index 000000000..39ce4acd0
--- /dev/null
+++ b/libs/OTPHP/OTPInterface.php
@@ -0,0 +1,132 @@
+
+ */
+ public function getParameters(): array;
+
+ /**
+ * @param non-empty-string $parameter
+ */
+ public function setParameter(string $parameter, mixed $value): void;
+
+ /**
+ * Get the provisioning URI.
+ *
+ * @return non-empty-string
+ */
+ public function getProvisioningUri(): string;
+
+ /**
+ * Get the provisioning URI.
+ *
+ * @param non-empty-string $uri The Uri of the QRCode generator with all parameters. This Uri MUST contain a placeholder that will be replaced by the method.
+ * @param non-empty-string $placeholder the placeholder to be replaced in the QR Code generator URI
+ */
+ public function getQrCodeUri(string $uri, string $placeholder): string;
+}
diff --git a/libs/OTPHP/ParameterTrait.php b/libs/OTPHP/ParameterTrait.php
new file mode 100644
index 000000000..dc92861c4
--- /dev/null
+++ b/libs/OTPHP/ParameterTrait.php
@@ -0,0 +1,200 @@
+
+ */
+ private array $parameters = [];
+
+ /**
+ * @var non-empty-string|null
+ */
+ private null|string $issuer = null;
+
+ /**
+ * @var non-empty-string|null
+ */
+ private null|string $label = null;
+
+ private bool $issuer_included_as_parameter = true;
+
+ /**
+ * @return array
+ */
+ public function getParameters(): array
+ {
+ $parameters = $this->parameters;
+
+ if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) {
+ $parameters['issuer'] = $this->getIssuer();
+ }
+
+ return $parameters;
+ }
+
+ public function getSecret(): string
+ {
+ $value = $this->getParameter('secret');
+ (is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "secret" parameter.');
+
+ return $value;
+ }
+
+ public function getLabel(): null|string
+ {
+ return $this->label;
+ }
+
+ public function setLabel(string $label): void
+ {
+ $this->setParameter('label', $label);
+ }
+
+ public function getIssuer(): null|string
+ {
+ return $this->issuer;
+ }
+
+ public function setIssuer(string $issuer): void
+ {
+ $this->setParameter('issuer', $issuer);
+ }
+
+ public function isIssuerIncludedAsParameter(): bool
+ {
+ return $this->issuer_included_as_parameter;
+ }
+
+ public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void
+ {
+ $this->issuer_included_as_parameter = $issuer_included_as_parameter;
+ }
+
+ public function getDigits(): int
+ {
+ $value = $this->getParameter('digits');
+ (is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "digits" parameter.');
+
+ return $value;
+ }
+
+ public function getDigest(): string
+ {
+ $value = $this->getParameter('algorithm');
+ (is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "algorithm" parameter.');
+
+ return $value;
+ }
+
+ public function hasParameter(string $parameter): bool
+ {
+ return array_key_exists($parameter, $this->parameters);
+ }
+
+ public function getParameter(string $parameter): mixed
+ {
+ if ($this->hasParameter($parameter)) {
+ return $this->getParameters()[$parameter];
+ }
+
+ throw new InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter));
+ }
+
+ public function setParameter(string $parameter, mixed $value): void
+ {
+ $map = $this->getParameterMap();
+
+ if (array_key_exists($parameter, $map) === true) {
+ $callback = $map[$parameter];
+ $value = $callback($value);
+ }
+
+ if (property_exists($this, $parameter)) {
+ $this->{$parameter} = $value;
+ } else {
+ $this->parameters[$parameter] = $value;
+ }
+ }
+
+ public function setSecret(string $secret): void
+ {
+ $this->setParameter('secret', $secret);
+ }
+
+ public function setDigits(int $digits): void
+ {
+ $this->setParameter('digits', $digits);
+ }
+
+ public function setDigest(string $digest): void
+ {
+ $this->setParameter('algorithm', $digest);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getParameterMap(): array
+ {
+ return [
+ 'label' => function (string $value): string {
+ assert($value !== '');
+ $this->hasColon($value) === false || throw new InvalidArgumentException(
+ 'Label must not contain a colon.'
+ );
+
+ return $value;
+ },
+ 'secret' => static fn (string $value): string => mb_strtoupper(trim($value, '=')),
+ 'algorithm' => static function (string $value): string {
+ $value = mb_strtolower($value);
+ in_array($value, hash_algos(), true) || throw new InvalidArgumentException(sprintf(
+ 'The "%s" digest is not supported.',
+ $value
+ ));
+
+ return $value;
+ },
+ 'digits' => static function ($value): int {
+ $value > 0 || throw new InvalidArgumentException('Digits must be at least 1.');
+
+ return (int) $value;
+ },
+ 'issuer' => function (string $value): string {
+ assert($value !== '');
+ $this->hasColon($value) === false || throw new InvalidArgumentException(
+ 'Issuer must not contain a colon.'
+ );
+
+ return $value;
+ },
+ ];
+ }
+
+ /**
+ * @param non-empty-string $value
+ */
+ private function hasColon(string $value): bool
+ {
+ $colons = [':', '%3A', '%3a'];
+ foreach ($colons as $colon) {
+ if (str_contains($value, $colon)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/libs/OTPHP/TOTP.php b/libs/OTPHP/TOTP.php
new file mode 100644
index 000000000..035e04f95
--- /dev/null
+++ b/libs/OTPHP/TOTP.php
@@ -0,0 +1,215 @@
+clock = $clock;
+ }
+
+ public static function create(
+ null|string $secret = null,
+ int $period = self::DEFAULT_PERIOD,
+ string $digest = self::DEFAULT_DIGEST,
+ int $digits = self::DEFAULT_DIGITS,
+ int $epoch = self::DEFAULT_EPOCH,
+ ?ClockInterface $clock = null
+ ): self {
+ $totp = $secret !== null
+ ? self::createFromSecret($secret, $clock)
+ : self::generate($clock)
+ ;
+ $totp->setPeriod($period);
+ $totp->setDigest($digest);
+ $totp->setDigits($digits);
+ $totp->setEpoch($epoch);
+
+ return $totp;
+ }
+
+ public static function createFromSecret(string $secret, ?ClockInterface $clock = null): self
+ {
+ $totp = new self($secret, $clock);
+ $totp->setPeriod(self::DEFAULT_PERIOD);
+ $totp->setDigest(self::DEFAULT_DIGEST);
+ $totp->setDigits(self::DEFAULT_DIGITS);
+ $totp->setEpoch(self::DEFAULT_EPOCH);
+
+ return $totp;
+ }
+
+ public static function generate(?ClockInterface $clock = null): self
+ {
+ return self::createFromSecret(self::generateSecret(), $clock);
+ }
+
+ public function getPeriod(): int
+ {
+ $value = $this->getParameter('period');
+ (is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "period" parameter.');
+
+ return $value;
+ }
+
+ public function getEpoch(): int
+ {
+ $value = $this->getParameter('epoch');
+ (is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "epoch" parameter.');
+
+ return $value;
+ }
+
+ public function expiresIn(): int
+ {
+ $period = $this->getPeriod();
+
+ return $period - ($this->clock->now()->getTimestamp() % $this->getPeriod());
+ }
+
+ /**
+ * The OTP at the specified input.
+ *
+ * @param 0|positive-int $input
+ */
+ public function at(int $input): string
+ {
+ return $this->generateOTP($this->timecode($input));
+ }
+
+ public function now(): string
+ {
+ $timestamp = $this->clock->now()
+ ->getTimestamp();
+ assert($timestamp >= 0, 'The timestamp must return a positive integer.');
+
+ return $this->at($timestamp);
+ }
+
+ /**
+ * If no timestamp is provided, the OTP is verified at the actual timestamp. When used, the leeway parameter will
+ * allow time drift. The passed value is in seconds.
+ *
+ * @param 0|positive-int $timestamp
+ * @param null|0|positive-int $leeway
+ */
+ public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
+ {
+ $timestamp ??= $this->clock->now()
+ ->getTimestamp();
+ $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.');
+
+ if ($leeway === null) {
+ return $this->compareOTP($this->at($timestamp), $otp);
+ }
+
+ $leeway = abs($leeway);
+ $leeway < $this->getPeriod() || throw new InvalidArgumentException(
+ 'The leeway must be lower than the TOTP period'
+ );
+ $timestampMinusLeeway = $timestamp - $leeway;
+ $timestampMinusLeeway >= 0 || throw new InvalidArgumentException(
+ 'The timestamp must be greater than or equal to the leeway.'
+ );
+
+ return $this->compareOTP($this->at($timestampMinusLeeway), $otp)
+ || $this->compareOTP($this->at($timestamp), $otp)
+ || $this->compareOTP($this->at($timestamp + $leeway), $otp);
+ }
+
+ public function getProvisioningUri(): string
+ {
+ $params = [];
+ if ($this->getPeriod() !== 30) {
+ $params['period'] = $this->getPeriod();
+ }
+
+ if ($this->getEpoch() !== 0) {
+ $params['epoch'] = $this->getEpoch();
+ }
+
+ return $this->generateURI('totp', $params);
+ }
+
+ public function setPeriod(int $period): void
+ {
+ $this->setParameter('period', $period);
+ }
+
+ public function setEpoch(int $epoch): void
+ {
+ $this->setParameter('epoch', $epoch);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getParameterMap(): array
+ {
+ return [
+ ...parent::getParameterMap(),
+ 'period' => static function ($value): int {
+ (int) $value > 0 || throw new InvalidArgumentException('Period must be at least 1.');
+
+ return (int) $value;
+ },
+ 'epoch' => static function ($value): int {
+ (int) $value >= 0 || throw new InvalidArgumentException(
+ 'Epoch must be greater than or equal to 0.'
+ );
+
+ return (int) $value;
+ },
+ ];
+ }
+
+ /**
+ * @param array $options
+ */
+ protected function filterOptions(array &$options): void
+ {
+ parent::filterOptions($options);
+
+ if (isset($options['epoch']) && $options['epoch'] === 0) {
+ unset($options['epoch']);
+ }
+
+ ksort($options);
+ }
+
+ /**
+ * @param 0|positive-int $timestamp
+ *
+ * @return 0|positive-int
+ */
+ private function timecode(int $timestamp): int
+ {
+ $timecode = (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
+ assert($timecode >= 0);
+
+ return $timecode;
+ }
+}
diff --git a/libs/OTPHP/TOTPInterface.php b/libs/OTPHP/TOTPInterface.php
new file mode 100644
index 000000000..a79fedcce
--- /dev/null
+++ b/libs/OTPHP/TOTPInterface.php
@@ -0,0 +1,51 @@
+ $query
+ */
+ public function __construct(
+ private readonly string $scheme,
+ private readonly string $host,
+ private readonly string $path,
+ private readonly string $secret,
+ private readonly array $query
+ ) {
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getScheme(): string
+ {
+ return $this->scheme;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getHost(): string
+ {
+ return $this->host;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getSecret(): string
+ {
+ return $this->secret;
+ }
+
+ /**
+ * @return array
+ */
+ public function getQuery(): array
+ {
+ return $this->query;
+ }
+
+ /**
+ * @param non-empty-string $uri
+ */
+ public static function fromString(string $uri): self
+ {
+ $parsed_url = parse_url($uri);
+ $parsed_url !== false || throw new InvalidArgumentException('Invalid URI.');
+ foreach (['scheme', 'host', 'path', 'query'] as $key) {
+ array_key_exists($key, $parsed_url) || throw new InvalidArgumentException(
+ 'Not a valid OTP provisioning URI'
+ );
+ }
+ $scheme = $parsed_url['scheme'] ?? null;
+ $host = $parsed_url['host'] ?? null;
+ $path = $parsed_url['path'] ?? null;
+ $query = $parsed_url['query'] ?? null;
+ $scheme === 'otpauth' || throw new InvalidArgumentException('Not a valid OTP provisioning URI');
+ is_string($host) || throw new InvalidArgumentException('Invalid URI.');
+ is_string($path) || throw new InvalidArgumentException('Invalid URI.');
+ is_string($query) || throw new InvalidArgumentException('Invalid URI.');
+ $parsedQuery = [];
+ parse_str($query, $parsedQuery);
+ array_key_exists('secret', $parsedQuery) || throw new InvalidArgumentException(
+ 'Not a valid OTP provisioning URI'
+ );
+ $secret = $parsedQuery['secret'];
+ unset($parsedQuery['secret']);
+
+ return new self($scheme, $host, $path, $secret, $parsedQuery);
+ }
+}
diff --git a/libs/Psr/Clock/ClockInterface.php b/libs/Psr/Clock/ClockInterface.php
new file mode 100644
index 000000000..963d2e322
--- /dev/null
+++ b/libs/Psr/Clock/ClockInterface.php
@@ -0,0 +1,13 @@
+ 96 && $src < 123) $ret += $src - 97 + 1; // -64
+ $ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 96);
+
+ // if ($src > 0x31 && $src < 0x38) $ret += $src - 24 + 1; // -23
+ $ret += (((0x31 - $src) & ($src - 0x38)) >> 8) & ($src - 23);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 5-bit integers
+ * into 8-bit integers.
+ *
+ * Uppercase variant.
+ *
+ * @param int $src
+ * @return int
+ */
+ protected static function decode5BitsUpper(int $src): int
+ {
+ $ret = -1;
+
+ // if ($src > 64 && $src < 91) $ret += $src - 65 + 1; // -64
+ $ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 64);
+
+ // if ($src > 0x31 && $src < 0x38) $ret += $src - 24 + 1; // -23
+ $ret += (((0x31 - $src) & ($src - 0x38)) >> 8) & ($src - 23);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 5-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode5Bits(int $src): string
+ {
+ $diff = 0x61;
+
+ // if ($src > 25) $ret -= 72;
+ $diff -= ((25 - $src) >> 8) & 73;
+
+ return \pack('C', $src + $diff);
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 5-bit integers.
+ *
+ * Uppercase variant.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode5BitsUpper(int $src): string
+ {
+ $diff = 0x41;
+
+ // if ($src > 25) $ret -= 40;
+ $diff -= ((25 - $src) >> 8) & 41;
+
+ return \pack('C', $src + $diff);
+ }
+
+ /**
+ * @param string $encodedString
+ * @param bool $upper
+ * @return string
+ */
+ public static function decodeNoPadding(
+ #[\SensitiveParameter]
+ string $encodedString,
+ bool $upper = false
+ ): string {
+ $srcLen = Binary::safeStrlen($encodedString);
+ if ($srcLen === 0) {
+ return '';
+ }
+ if (($srcLen & 7) === 0) {
+ for ($j = 0; $j < 7 && $j < $srcLen; ++$j) {
+ if ($encodedString[$srcLen - $j - 1] === '=') {
+ throw new InvalidArgumentException(
+ "decodeNoPadding() doesn't tolerate padding"
+ );
+ }
+ }
+ }
+ return static::doDecode(
+ $encodedString,
+ $upper,
+ true
+ );
+ }
+
+ /**
+ * Base32 decoding
+ *
+ * @param string $src
+ * @param bool $upper
+ * @param bool $strictPadding
+ * @return string
+ *
+ * @throws TypeError
+ */
+ protected static function doDecode(
+ #[\SensitiveParameter]
+ string $src,
+ bool $upper = false,
+ bool $strictPadding = false
+ ): string {
+ // We do this to reduce code duplication:
+ $method = $upper
+ ? 'decode5BitsUpper'
+ : 'decode5Bits';
+
+ // Remove padding
+ $srcLen = Binary::safeStrlen($src);
+ if ($srcLen === 0) {
+ return '';
+ }
+ if ($strictPadding) {
+ if (($srcLen & 7) === 0) {
+ for ($j = 0; $j < 7; ++$j) {
+ if ($src[$srcLen - 1] === '=') {
+ $srcLen--;
+ } else {
+ break;
+ }
+ }
+ }
+ if (($srcLen & 7) === 1) {
+ throw new RangeException(
+ 'Incorrect padding'
+ );
+ }
+ } else {
+ $src = \rtrim($src, '=');
+ $srcLen = Binary::safeStrlen($src);
+ }
+
+ $err = 0;
+ $dest = '';
+ // Main loop (no padding):
+ for ($i = 0; $i + 8 <= $srcLen; $i += 8) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, 8));
+ /** @var int $c0 */
+ $c0 = static::$method($chunk[1]);
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+ /** @var int $c3 */
+ $c3 = static::$method($chunk[4]);
+ /** @var int $c4 */
+ $c4 = static::$method($chunk[5]);
+ /** @var int $c5 */
+ $c5 = static::$method($chunk[6]);
+ /** @var int $c6 */
+ $c6 = static::$method($chunk[7]);
+ /** @var int $c7 */
+ $c7 = static::$method($chunk[8]);
+
+ $dest .= \pack(
+ 'CCCCC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) | ($c3 >> 4)) & 0xff,
+ (($c3 << 4) | ($c4 >> 1) ) & 0xff,
+ (($c4 << 7) | ($c5 << 2) | ($c6 >> 3)) & 0xff,
+ (($c6 << 5) | ($c7 ) ) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3 | $c4 | $c5 | $c6 | $c7) >> 8;
+ }
+ // The last chunk, which may have padding:
+ if ($i < $srcLen) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, $srcLen - $i));
+ /** @var int $c0 */
+ $c0 = static::$method($chunk[1]);
+
+ if ($i + 6 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+ /** @var int $c3 */
+ $c3 = static::$method($chunk[4]);
+ /** @var int $c4 */
+ $c4 = static::$method($chunk[5]);
+ /** @var int $c5 */
+ $c5 = static::$method($chunk[6]);
+ /** @var int $c6 */
+ $c6 = static::$method($chunk[7]);
+
+ $dest .= \pack(
+ 'CCCC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) | ($c3 >> 4)) & 0xff,
+ (($c3 << 4) | ($c4 >> 1) ) & 0xff,
+ (($c4 << 7) | ($c5 << 2) | ($c6 >> 3)) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3 | $c4 | $c5 | $c6) >> 8;
+ if ($strictPadding) {
+ $err |= ($c6 << 5) & 0xff;
+ }
+ } elseif ($i + 5 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+ /** @var int $c3 */
+ $c3 = static::$method($chunk[4]);
+ /** @var int $c4 */
+ $c4 = static::$method($chunk[5]);
+ /** @var int $c5 */
+ $c5 = static::$method($chunk[6]);
+
+ $dest .= \pack(
+ 'CCCC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) | ($c3 >> 4)) & 0xff,
+ (($c3 << 4) | ($c4 >> 1) ) & 0xff,
+ (($c4 << 7) | ($c5 << 2) ) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3 | $c4 | $c5) >> 8;
+ } elseif ($i + 4 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+ /** @var int $c3 */
+ $c3 = static::$method($chunk[4]);
+ /** @var int $c4 */
+ $c4 = static::$method($chunk[5]);
+
+ $dest .= \pack(
+ 'CCC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) | ($c3 >> 4)) & 0xff,
+ (($c3 << 4) | ($c4 >> 1) ) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3 | $c4) >> 8;
+ if ($strictPadding) {
+ $err |= ($c4 << 7) & 0xff;
+ }
+ } elseif ($i + 3 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+ /** @var int $c3 */
+ $c3 = static::$method($chunk[4]);
+
+ $dest .= \pack(
+ 'CC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) | ($c3 >> 4)) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3) >> 8;
+ if ($strictPadding) {
+ $err |= ($c3 << 4) & 0xff;
+ }
+ } elseif ($i + 2 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+ /** @var int $c2 */
+ $c2 = static::$method($chunk[3]);
+
+ $dest .= \pack(
+ 'CC',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff,
+ (($c1 << 6) | ($c2 << 1) ) & 0xff
+ );
+ $err |= ($c0 | $c1 | $c2) >> 8;
+ if ($strictPadding) {
+ $err |= ($c2 << 6) & 0xff;
+ }
+ } elseif ($i + 1 < $srcLen) {
+ /** @var int $c1 */
+ $c1 = static::$method($chunk[2]);
+
+ $dest .= \pack(
+ 'C',
+ (($c0 << 3) | ($c1 >> 2) ) & 0xff
+ );
+ $err |= ($c0 | $c1) >> 8;
+ if ($strictPadding) {
+ $err |= ($c1 << 6) & 0xff;
+ }
+ } else {
+ $dest .= \pack(
+ 'C',
+ (($c0 << 3) ) & 0xff
+ );
+ $err |= ($c0) >> 8;
+ }
+ }
+ $check = ($err === 0);
+ if (!$check) {
+ throw new RangeException(
+ 'Base32::doDecode() only expects characters in the correct base32 alphabet'
+ );
+ }
+ return $dest;
+ }
+
+ /**
+ * Base32 Encoding
+ *
+ * @param string $src
+ * @param bool $upper
+ * @param bool $pad
+ * @return string
+ * @throws TypeError
+ */
+ protected static function doEncode(
+ #[\SensitiveParameter]
+ string $src,
+ bool $upper = false,
+ bool $pad = true
+ ): string {
+ // We do this to reduce code duplication:
+ $method = $upper
+ ? 'encode5BitsUpper'
+ : 'encode5Bits';
+
+ $dest = '';
+ $srcLen = Binary::safeStrlen($src);
+
+ // Main loop (no padding):
+ for ($i = 0; $i + 5 <= $srcLen; $i += 5) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, 5));
+ $b0 = $chunk[1];
+ $b1 = $chunk[2];
+ $b2 = $chunk[3];
+ $b3 = $chunk[4];
+ $b4 = $chunk[5];
+ $dest .=
+ static::$method( ($b0 >> 3) & 31) .
+ static::$method((($b0 << 2) | ($b1 >> 6)) & 31) .
+ static::$method((($b1 >> 1) ) & 31) .
+ static::$method((($b1 << 4) | ($b2 >> 4)) & 31) .
+ static::$method((($b2 << 1) | ($b3 >> 7)) & 31) .
+ static::$method((($b3 >> 2) ) & 31) .
+ static::$method((($b3 << 3) | ($b4 >> 5)) & 31) .
+ static::$method( $b4 & 31);
+ }
+ // The last chunk, which may have padding:
+ if ($i < $srcLen) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, $srcLen - $i));
+ $b0 = $chunk[1];
+ if ($i + 3 < $srcLen) {
+ $b1 = $chunk[2];
+ $b2 = $chunk[3];
+ $b3 = $chunk[4];
+ $dest .=
+ static::$method( ($b0 >> 3) & 31) .
+ static::$method((($b0 << 2) | ($b1 >> 6)) & 31) .
+ static::$method((($b1 >> 1) ) & 31) .
+ static::$method((($b1 << 4) | ($b2 >> 4)) & 31) .
+ static::$method((($b2 << 1) | ($b3 >> 7)) & 31) .
+ static::$method((($b3 >> 2) ) & 31) .
+ static::$method((($b3 << 3) ) & 31);
+ if ($pad) {
+ $dest .= '=';
+ }
+ } elseif ($i + 2 < $srcLen) {
+ $b1 = $chunk[2];
+ $b2 = $chunk[3];
+ $dest .=
+ static::$method( ($b0 >> 3) & 31) .
+ static::$method((($b0 << 2) | ($b1 >> 6)) & 31) .
+ static::$method((($b1 >> 1) ) & 31) .
+ static::$method((($b1 << 4) | ($b2 >> 4)) & 31) .
+ static::$method((($b2 << 1) ) & 31);
+ if ($pad) {
+ $dest .= '===';
+ }
+ } elseif ($i + 1 < $srcLen) {
+ $b1 = $chunk[2];
+ $dest .=
+ static::$method( ($b0 >> 3) & 31) .
+ static::$method((($b0 << 2) | ($b1 >> 6)) & 31) .
+ static::$method((($b1 >> 1) ) & 31) .
+ static::$method((($b1 << 4) ) & 31);
+ if ($pad) {
+ $dest .= '====';
+ }
+ } else {
+ $dest .=
+ static::$method( ($b0 >> 3) & 31) .
+ static::$method( ($b0 << 2) & 31);
+ if ($pad) {
+ $dest .= '======';
+ }
+ }
+ }
+ return $dest;
+ }
+}
diff --git a/libs/constant_time_encoding/Base32Hex.php b/libs/constant_time_encoding/Base32Hex.php
new file mode 100644
index 000000000..b868dd048
--- /dev/null
+++ b/libs/constant_time_encoding/Base32Hex.php
@@ -0,0 +1,111 @@
+ 0x30 && $src < 0x3a) ret += $src - 0x2e + 1; // -47
+ $ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src - 47);
+
+ // if ($src > 0x60 && $src < 0x77) ret += $src - 0x61 + 10 + 1; // -86
+ $ret += (((0x60 - $src) & ($src - 0x77)) >> 8) & ($src - 86);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 5-bit integers
+ * into 8-bit integers.
+ *
+ * @param int $src
+ * @return int
+ */
+ protected static function decode5BitsUpper(int $src): int
+ {
+ $ret = -1;
+
+ // if ($src > 0x30 && $src < 0x3a) ret += $src - 0x2e + 1; // -47
+ $ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src - 47);
+
+ // if ($src > 0x40 && $src < 0x57) ret += $src - 0x41 + 10 + 1; // -54
+ $ret += (((0x40 - $src) & ($src - 0x57)) >> 8) & ($src - 54);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 5-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode5Bits(int $src): string
+ {
+ $src += 0x30;
+
+ // if ($src > 0x39) $src += 0x61 - 0x3a; // 39
+ $src += ((0x39 - $src) >> 8) & 39;
+
+ return \pack('C', $src);
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 5-bit integers.
+ *
+ * Uppercase variant.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode5BitsUpper(int $src): string
+ {
+ $src += 0x30;
+
+ // if ($src > 0x39) $src += 0x41 - 0x3a; // 7
+ $src += ((0x39 - $src) >> 8) & 7;
+
+ return \pack('C', $src);
+ }
+}
\ No newline at end of file
diff --git a/libs/constant_time_encoding/Base64.php b/libs/constant_time_encoding/Base64.php
new file mode 100644
index 000000000..2e3ecc859
--- /dev/null
+++ b/libs/constant_time_encoding/Base64.php
@@ -0,0 +1,319 @@
+ $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, 3));
+ $b0 = $chunk[1];
+ $b1 = $chunk[2];
+ $b2 = $chunk[3];
+
+ $dest .=
+ static::encode6Bits( $b0 >> 2 ) .
+ static::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
+ static::encode6Bits((($b1 << 2) | ($b2 >> 6)) & 63) .
+ static::encode6Bits( $b2 & 63);
+ }
+ // The last chunk, which may have padding:
+ if ($i < $srcLen) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($src, $i, $srcLen - $i));
+ $b0 = $chunk[1];
+ if ($i + 1 < $srcLen) {
+ $b1 = $chunk[2];
+ $dest .=
+ static::encode6Bits($b0 >> 2) .
+ static::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
+ static::encode6Bits(($b1 << 2) & 63);
+ if ($pad) {
+ $dest .= '=';
+ }
+ } else {
+ $dest .=
+ static::encode6Bits( $b0 >> 2) .
+ static::encode6Bits(($b0 << 4) & 63);
+ if ($pad) {
+ $dest .= '==';
+ }
+ }
+ }
+ return $dest;
+ }
+
+ /**
+ * decode from base64 into binary
+ *
+ * Base64 character set "./[A-Z][a-z][0-9]"
+ *
+ * @param string $encodedString
+ * @param bool $strictPadding
+ * @return string
+ *
+ * @throws RangeException
+ * @throws TypeError
+ */
+ public static function decode(
+ #[\SensitiveParameter]
+ string $encodedString,
+ bool $strictPadding = false
+ ): string {
+ // Remove padding
+ $srcLen = Binary::safeStrlen($encodedString);
+ if ($srcLen === 0) {
+ return '';
+ }
+
+ if ($strictPadding) {
+ if (($srcLen & 3) === 0) {
+ if ($encodedString[$srcLen - 1] === '=') {
+ $srcLen--;
+ if ($encodedString[$srcLen - 1] === '=') {
+ $srcLen--;
+ }
+ }
+ }
+ if (($srcLen & 3) === 1) {
+ throw new RangeException(
+ 'Incorrect padding'
+ );
+ }
+ if ($encodedString[$srcLen - 1] === '=') {
+ throw new RangeException(
+ 'Incorrect padding'
+ );
+ }
+ } else {
+ $encodedString = \rtrim($encodedString, '=');
+ $srcLen = Binary::safeStrlen($encodedString);
+ }
+
+ $err = 0;
+ $dest = '';
+ // Main loop (no padding):
+ for ($i = 0; $i + 4 <= $srcLen; $i += 4) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($encodedString, $i, 4));
+ $c0 = static::decode6Bits($chunk[1]);
+ $c1 = static::decode6Bits($chunk[2]);
+ $c2 = static::decode6Bits($chunk[3]);
+ $c3 = static::decode6Bits($chunk[4]);
+
+ $dest .= \pack(
+ 'CCC',
+ ((($c0 << 2) | ($c1 >> 4)) & 0xff),
+ ((($c1 << 4) | ($c2 >> 2)) & 0xff),
+ ((($c2 << 6) | $c3 ) & 0xff)
+ );
+ $err |= ($c0 | $c1 | $c2 | $c3) >> 8;
+ }
+ // The last chunk, which may have padding:
+ if ($i < $srcLen) {
+ /** @var array $chunk */
+ $chunk = \unpack('C*', Binary::safeSubstr($encodedString, $i, $srcLen - $i));
+ $c0 = static::decode6Bits($chunk[1]);
+
+ if ($i + 2 < $srcLen) {
+ $c1 = static::decode6Bits($chunk[2]);
+ $c2 = static::decode6Bits($chunk[3]);
+ $dest .= \pack(
+ 'CC',
+ ((($c0 << 2) | ($c1 >> 4)) & 0xff),
+ ((($c1 << 4) | ($c2 >> 2)) & 0xff)
+ );
+ $err |= ($c0 | $c1 | $c2) >> 8;
+ if ($strictPadding) {
+ $err |= ($c2 << 6) & 0xff;
+ }
+ } elseif ($i + 1 < $srcLen) {
+ $c1 = static::decode6Bits($chunk[2]);
+ $dest .= \pack(
+ 'C',
+ ((($c0 << 2) | ($c1 >> 4)) & 0xff)
+ );
+ $err |= ($c0 | $c1) >> 8;
+ if ($strictPadding) {
+ $err |= ($c1 << 4) & 0xff;
+ }
+ } elseif ($strictPadding) {
+ $err |= 1;
+ }
+ }
+ $check = ($err === 0);
+ if (!$check) {
+ throw new RangeException(
+ 'Base64::decode() only expects characters in the correct base64 alphabet'
+ );
+ }
+ return $dest;
+ }
+
+ /**
+ * @param string $encodedString
+ * @return string
+ */
+ public static function decodeNoPadding(
+ #[\SensitiveParameter]
+ string $encodedString
+ ): string {
+ $srcLen = Binary::safeStrlen($encodedString);
+ if ($srcLen === 0) {
+ return '';
+ }
+ if (($srcLen & 3) === 0) {
+ // If $strLen is not zero, and it is divisible by 4, then it's at least 4.
+ if ($encodedString[$srcLen - 1] === '=' || $encodedString[$srcLen - 2] === '=') {
+ throw new InvalidArgumentException(
+ "decodeNoPadding() doesn't tolerate padding"
+ );
+ }
+ }
+ return static::decode(
+ $encodedString,
+ true
+ );
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 6-bit integers
+ * into 8-bit integers.
+ *
+ * Base64 character set:
+ * [A-Z] [a-z] [0-9] + /
+ * 0x41-0x5a, 0x61-0x7a, 0x30-0x39, 0x2b, 0x2f
+ *
+ * @param int $src
+ * @return int
+ */
+ protected static function decode6Bits(int $src): int
+ {
+ $ret = -1;
+
+ // if ($src > 0x40 && $src < 0x5b) $ret += $src - 0x41 + 1; // -64
+ $ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 64);
+
+ // if ($src > 0x60 && $src < 0x7b) $ret += $src - 0x61 + 26 + 1; // -70
+ $ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 70);
+
+ // if ($src > 0x2f && $src < 0x3a) $ret += $src - 0x30 + 52 + 1; // 5
+ $ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src + 5);
+
+ // if ($src == 0x2b) $ret += 62 + 1;
+ $ret += (((0x2a - $src) & ($src - 0x2c)) >> 8) & 63;
+
+ // if ($src == 0x2f) ret += 63 + 1;
+ $ret += (((0x2e - $src) & ($src - 0x30)) >> 8) & 64;
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 6-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode6Bits(int $src): string
+ {
+ $diff = 0x41;
+
+ // if ($src > 25) $diff += 0x61 - 0x41 - 26; // 6
+ $diff += ((25 - $src) >> 8) & 6;
+
+ // if ($src > 51) $diff += 0x30 - 0x61 - 26; // -75
+ $diff -= ((51 - $src) >> 8) & 75;
+
+ // if ($src > 61) $diff += 0x2b - 0x30 - 10; // -15
+ $diff -= ((61 - $src) >> 8) & 15;
+
+ // if ($src > 62) $diff += 0x2f - 0x2b - 1; // 3
+ $diff += ((62 - $src) >> 8) & 3;
+
+ return \pack('C', $src + $diff);
+ }
+}
diff --git a/libs/constant_time_encoding/Base64DotSlash.php b/libs/constant_time_encoding/Base64DotSlash.php
new file mode 100644
index 000000000..5e98a8f79
--- /dev/null
+++ b/libs/constant_time_encoding/Base64DotSlash.php
@@ -0,0 +1,88 @@
+ 0x2d && $src < 0x30) ret += $src - 0x2e + 1; // -45
+ $ret += (((0x2d - $src) & ($src - 0x30)) >> 8) & ($src - 45);
+
+ // if ($src > 0x40 && $src < 0x5b) ret += $src - 0x41 + 2 + 1; // -62
+ $ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 62);
+
+ // if ($src > 0x60 && $src < 0x7b) ret += $src - 0x61 + 28 + 1; // -68
+ $ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 68);
+
+ // if ($src > 0x2f && $src < 0x3a) ret += $src - 0x30 + 54 + 1; // 7
+ $ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src + 7);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 6-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode6Bits(int $src): string
+ {
+ $src += 0x2e;
+
+ // if ($src > 0x2f) $src += 0x41 - 0x30; // 17
+ $src += ((0x2f - $src) >> 8) & 17;
+
+ // if ($src > 0x5a) $src += 0x61 - 0x5b; // 6
+ $src += ((0x5a - $src) >> 8) & 6;
+
+ // if ($src > 0x7a) $src += 0x30 - 0x7b; // -75
+ $src -= ((0x7a - $src) >> 8) & 75;
+
+ return \pack('C', $src);
+ }
+}
diff --git a/libs/constant_time_encoding/Base64DotSlashOrdered.php b/libs/constant_time_encoding/Base64DotSlashOrdered.php
new file mode 100644
index 000000000..9780b14bb
--- /dev/null
+++ b/libs/constant_time_encoding/Base64DotSlashOrdered.php
@@ -0,0 +1,82 @@
+ 0x2d && $src < 0x3a) ret += $src - 0x2e + 1; // -45
+ $ret += (((0x2d - $src) & ($src - 0x3a)) >> 8) & ($src - 45);
+
+ // if ($src > 0x40 && $src < 0x5b) ret += $src - 0x41 + 12 + 1; // -52
+ $ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 52);
+
+ // if ($src > 0x60 && $src < 0x7b) ret += $src - 0x61 + 38 + 1; // -58
+ $ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 58);
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 6-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode6Bits(int $src): string
+ {
+ $src += 0x2e;
+
+ // if ($src > 0x39) $src += 0x41 - 0x3a; // 7
+ $src += ((0x39 - $src) >> 8) & 7;
+
+ // if ($src > 0x5a) $src += 0x61 - 0x5b; // 6
+ $src += ((0x5a - $src) >> 8) & 6;
+
+ return \pack('C', $src);
+ }
+}
diff --git a/libs/constant_time_encoding/Base64UrlSafe.php b/libs/constant_time_encoding/Base64UrlSafe.php
new file mode 100644
index 000000000..8192c63d5
--- /dev/null
+++ b/libs/constant_time_encoding/Base64UrlSafe.php
@@ -0,0 +1,95 @@
+ 0x40 && $src < 0x5b) $ret += $src - 0x41 + 1; // -64
+ $ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 64);
+
+ // if ($src > 0x60 && $src < 0x7b) $ret += $src - 0x61 + 26 + 1; // -70
+ $ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 70);
+
+ // if ($src > 0x2f && $src < 0x3a) $ret += $src - 0x30 + 52 + 1; // 5
+ $ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src + 5);
+
+ // if ($src == 0x2c) $ret += 62 + 1;
+ $ret += (((0x2c - $src) & ($src - 0x2e)) >> 8) & 63;
+
+ // if ($src == 0x5f) ret += 63 + 1;
+ $ret += (((0x5e - $src) & ($src - 0x60)) >> 8) & 64;
+
+ return $ret;
+ }
+
+ /**
+ * Uses bitwise operators instead of table-lookups to turn 8-bit integers
+ * into 6-bit integers.
+ *
+ * @param int $src
+ * @return string
+ */
+ protected static function encode6Bits(int $src): string
+ {
+ $diff = 0x41;
+
+ // if ($src > 25) $diff += 0x61 - 0x41 - 26; // 6
+ $diff += ((25 - $src) >> 8) & 6;
+
+ // if ($src > 51) $diff += 0x30 - 0x61 - 26; // -75
+ $diff -= ((51 - $src) >> 8) & 75;
+
+ // if ($src > 61) $diff += 0x2d - 0x30 - 10; // -13
+ $diff -= ((61 - $src) >> 8) & 13;
+
+ // if ($src > 62) $diff += 0x5f - 0x2b - 1; // 3
+ $diff += ((62 - $src) >> 8) & 49;
+
+ return \pack('C', $src + $diff);
+ }
+}
diff --git a/libs/constant_time_encoding/Binary.php b/libs/constant_time_encoding/Binary.php
new file mode 100644
index 000000000..a958f2f7c
--- /dev/null
+++ b/libs/constant_time_encoding/Binary.php
@@ -0,0 +1,93 @@
+ $chunk */
+ $chunk = \unpack('C', $binString[$i]);
+ $c = $chunk[1] & 0xf;
+ $b = $chunk[1] >> 4;
+
+ $hex .= \pack(
+ 'CC',
+ (87 + $b + ((($b - 10) >> 8) & ~38)),
+ (87 + $c + ((($c - 10) >> 8) & ~38))
+ );
+ }
+ return $hex;
+ }
+
+ /**
+ * Convert a binary string into a hexadecimal string without cache-timing
+ * leaks, returning uppercase letters (as per RFC 4648)
+ *
+ * @param string $binString (raw binary)
+ * @return string
+ * @throws TypeError
+ */
+ public static function encodeUpper(
+ #[\SensitiveParameter]
+ string $binString
+ ): string {
+ $hex = '';
+ $len = Binary::safeStrlen($binString);
+
+ for ($i = 0; $i < $len; ++$i) {
+ /** @var array $chunk */
+ $chunk = \unpack('C', $binString[$i]);
+ $c = $chunk[1] & 0xf;
+ $b = $chunk[1] >> 4;
+
+ $hex .= \pack(
+ 'CC',
+ (55 + $b + ((($b - 10) >> 8) & ~6)),
+ (55 + $c + ((($c - 10) >> 8) & ~6))
+ );
+ }
+ return $hex;
+ }
+
+ /**
+ * Convert a hexadecimal string into a binary string without cache-timing
+ * leaks
+ *
+ * @param string $encodedString
+ * @param bool $strictPadding
+ * @return string (raw binary)
+ * @throws RangeException
+ */
+ public static function decode(
+ #[\SensitiveParameter]
+ string $encodedString,
+ bool $strictPadding = false
+ ): string {
+ $hex_pos = 0;
+ $bin = '';
+ $c_acc = 0;
+ $hex_len = Binary::safeStrlen($encodedString);
+ $state = 0;
+ if (($hex_len & 1) !== 0) {
+ if ($strictPadding) {
+ throw new RangeException(
+ 'Expected an even number of hexadecimal characters'
+ );
+ } else {
+ $encodedString = '0' . $encodedString;
+ ++$hex_len;
+ }
+ }
+
+ /** @var array $chunk */
+ $chunk = \unpack('C*', $encodedString);
+ while ($hex_pos < $hex_len) {
+ ++$hex_pos;
+ $c = $chunk[$hex_pos];
+ $c_num = $c ^ 48;
+ $c_num0 = ($c_num - 10) >> 8;
+ $c_alpha = ($c & ~32) - 55;
+ $c_alpha0 = (($c_alpha - 10) ^ ($c_alpha - 16)) >> 8;
+
+ if (($c_num0 | $c_alpha0) === 0) {
+ throw new RangeException(
+ 'Expected hexadecimal character'
+ );
+ }
+ $c_val = ($c_num0 & $c_num) | ($c_alpha & $c_alpha0);
+ if ($state === 0) {
+ $c_acc = $c_val * 16;
+ } else {
+ $bin .= \pack('C', $c_acc | $c_val);
+ }
+ $state ^= 1;
+ }
+ return $bin;
+ }
+}
diff --git a/libs/constant_time_encoding/RFC4648.php b/libs/constant_time_encoding/RFC4648.php
new file mode 100644
index 000000000..7cd2e9909
--- /dev/null
+++ b/libs/constant_time_encoding/RFC4648.php
@@ -0,0 +1,206 @@
+ "Zm9v"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base64Encode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base64::encode($str);
+ }
+
+ /**
+ * RFC 4648 Base64 decoding
+ *
+ * "Zm9v" -> "foo"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base64Decode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base64::decode($str, true);
+ }
+
+ /**
+ * RFC 4648 Base64 (URL Safe) encoding
+ *
+ * "foo" -> "Zm9v"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base64UrlSafeEncode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base64UrlSafe::encode($str);
+ }
+
+ /**
+ * RFC 4648 Base64 (URL Safe) decoding
+ *
+ * "Zm9v" -> "foo"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base64UrlSafeDecode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base64UrlSafe::decode($str, true);
+ }
+
+ /**
+ * RFC 4648 Base32 encoding
+ *
+ * "foo" -> "MZXW6==="
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base32Encode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base32::encodeUpper($str);
+ }
+
+ /**
+ * RFC 4648 Base32 encoding
+ *
+ * "MZXW6===" -> "foo"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base32Decode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base32::decodeUpper($str, true);
+ }
+
+ /**
+ * RFC 4648 Base32-Hex encoding
+ *
+ * "foo" -> "CPNMU==="
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base32HexEncode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base32::encodeUpper($str);
+ }
+
+ /**
+ * RFC 4648 Base32-Hex decoding
+ *
+ * "CPNMU===" -> "foo"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base32HexDecode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Base32::decodeUpper($str, true);
+ }
+
+ /**
+ * RFC 4648 Base16 decoding
+ *
+ * "foo" -> "666F6F"
+ *
+ * @param string $str
+ * @return string
+ *
+ * @throws TypeError
+ */
+ public static function base16Encode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Hex::encodeUpper($str);
+ }
+
+ /**
+ * RFC 4648 Base16 decoding
+ *
+ * "666F6F" -> "foo"
+ *
+ * @param string $str
+ * @return string
+ */
+ public static function base16Decode(
+ #[\SensitiveParameter]
+ string $str
+ ): string {
+ return Hex::decode($str, true);
+ }
+}
diff --git a/login.php b/login.php
index 36cedd051..0f9973094 100644
--- a/login.php
+++ b/login.php
@@ -86,6 +86,14 @@
}
}
+if (isset($_SESSION['totp_user_id'])) {
+ unset($_SESSION['totp_user_id']);
+}
+
+if (isset($_SESSION['token'])) {
+ unset($_SESSION['token']);
+}
+
$theme = "light";
$updateThemeSettings = false;
@@ -126,12 +134,42 @@
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
- $verificationRow = $result->fetchArray(SQLITE3_ASSOC);
+ $verificationMissing = $result->fetchArray(SQLITE3_ASSOC);
+
+ // Check if the user has 2fa enabled
+ $query = "SELECT totp_enabled FROM user WHERE id = :userId";
+ $stmt = $db->prepare($query);
+ $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
+ $result = $stmt->execute();
+ $totpEnabled = $result->fetchArray(SQLITE3_ASSOC);
- if ($verificationRow) {
+ if ($verificationMissing) {
$userEmailWaitingVerification = true;
$loginFailed = true;
} else {
+ if ($rememberMe) {
+ $token = bin2hex(random_bytes(32));
+ $addLoginTokens = "INSERT INTO login_tokens (user_id, token) VALUES (:userId, :token)";
+ $addLoginTokensStmt = $db->prepare($addLoginTokens);
+ $addLoginTokensStmt->bindParam(':userId', $userId, SQLITE3_INTEGER);
+ $addLoginTokensStmt->bindParam(':token', $token, SQLITE3_TEXT);
+ $addLoginTokensStmt->execute();
+ $_SESSION['token'] = $token;
+ $cookieValue = $username . "|" . $token . "|" . $main_currency;
+ setcookie('wallos_login', $cookieValue, [
+ 'expires' => $cookieExpire,
+ 'samesite' => 'Strict'
+ ]);
+ }
+
+ // Send to totp page if 2fa is enabled
+ if ($totpEnabled['totp_enabled'] == 1) {
+ $_SESSION['totp_user_id'] = $userId;
+ $db->close();
+ header("Location: totp.php");
+ exit();
+ }
+
$_SESSION['username'] = $username;
$_SESSION['loggedin'] = true;
$_SESSION['main_currency'] = $main_currency;
@@ -148,8 +186,9 @@
]);
}
- $query = "SELECT color_theme FROM settings";
+ $query = "SELECT color_theme FROM settings WHERE user_id = :userId";
$stmt = $db->prepare($query);
+ $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$settings = $result->fetchArray(SQLITE3_ASSOC);
setcookie('colorTheme', $settings['color_theme'], [
@@ -157,20 +196,6 @@
'samesite' => 'Strict'
]);
- if ($rememberMe) {
- $token = bin2hex(random_bytes(32));
- $addLoginTokens = "INSERT INTO login_tokens (user_id, token) VALUES (:userId, :token)";
- $addLoginTokensStmt = $db->prepare($addLoginTokens);
- $addLoginTokensStmt->bindParam(':userId', $userId, SQLITE3_INTEGER);
- $addLoginTokensStmt->bindParam(':token', $token, SQLITE3_TEXT);
- $addLoginTokensStmt->execute();
- $_SESSION['token'] = $token;
- $cookieValue = $username . "|" . $token . "|" . $main_currency;
- setcookie('wallos_login', $cookieValue, [
- 'expires' => $cookieExpire,
- 'samesite' => 'Strict'
- ]);
- }
$db->close();
header("Location: .");
exit();
diff --git a/migrations/000027.php b/migrations/000027.php
new file mode 100644
index 000000000..d09b35972
--- /dev/null
+++ b/migrations/000027.php
@@ -0,0 +1,21 @@
+query("SELECT * FROM pragma_table_info('user') where name='totp_enabled'");
+
+$columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false;
+
+if ($columnRequired) {
+ $db->exec('ALTER TABLE user ADD COLUMN totp_enabled BOOLEAN DEFAULT 0');
+}
+
+$db->exec('CREATE TABLE IF NOT EXISTS totp (
+ user_id INTEGER NOT NULL,
+ totp_secret TEXT NOT NULL,
+ backup_codes TEXT NOT NULL,
+ last_totp_used INTEGER DEFAULT 0,
+ FOREIGN KEY(user_id) REFERENCES user(id)
+)');
\ No newline at end of file
diff --git a/scripts/i18n/de.js b/scripts/i18n/de.js
index 9a5349afa..ae19c09ee 100644
--- a/scripts/i18n/de.js
+++ b/scripts/i18n/de.js
@@ -37,6 +37,8 @@ let i18n = {
error_sending_notification: "Fehler beim Senden der Benachrichtigung",
delete_account_confirmation: "Möchten Sie Ihr Konto wirklich löschen?",
this_will_delete_all_data: "Dadurch werden alle Daten gelöscht und können nicht wiederhergestellt werden. Fortfahren?",
+ success: "Erfolg",
+ copied_to_clipboard: "In die Zwischenablage kopiert",
// Calendar
price: "Preis",
category: "Kategorie",
diff --git a/scripts/i18n/el.js b/scripts/i18n/el.js
index f6aad9515..2b5111ab0 100644
--- a/scripts/i18n/el.js
+++ b/scripts/i18n/el.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Σφάλμα αποστολής ειδοποίησης",
delete_account_confirmation: "Είστε σίγουρος ότι θέλετε να διαγράψετε το λογαριασμό σας;",
this_will_delete_all_data: "Αυτό θα διαγράψει όλα τα δεδομένα σας και δεν μπορεί να ανακτηθεί. Να συνεχίσω;",
+ success: "Επιτυχία",
+ copied_to_clipboard: "Αντιγράφηκε στο πρόχειρο",
// Calendar
price: "Τιμή",
category: "Κατηγορία",
diff --git a/scripts/i18n/en.js b/scripts/i18n/en.js
index 8a4bf4ef7..0d1ce848e 100644
--- a/scripts/i18n/en.js
+++ b/scripts/i18n/en.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Error sending notification",
delete_account_confirmation: "Are you sure you want to delete your account?",
this_will_delete_all_data: "This will delete all your data and can't be undone. Continue?",
+ success: "Success",
+ copied_to_clipboard: "Copied to clipboard",
// Calendar
price: "Price",
category: "Category",
diff --git a/scripts/i18n/es.js b/scripts/i18n/es.js
index ed018891f..91bda3f03 100644
--- a/scripts/i18n/es.js
+++ b/scripts/i18n/es.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Error al enviar la notificación",
delete_account_confirmation: "¿Estás seguro de que quieres eliminar tu cuenta?",
this_will_delete_all_data: "Esto eliminará todos tus datos y no se podrán recuperar. ¿Continuar?",
+ success: "Éxito",
+ copied_to_clipboard: "Copiado al portapapeles",
// Calendar
price: "Precio",
category: "Categoría",
diff --git a/scripts/i18n/fr.js b/scripts/i18n/fr.js
index 16e115ee9..28f4469f2 100644
--- a/scripts/i18n/fr.js
+++ b/scripts/i18n/fr.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Erreur lors de l'envoi de la notification",
delete_account_confirmation: "Êtes-vous sûr de vouloir supprimer votre compte ?",
this_will_delete_all_data: "Cela supprimera toutes vos données et ne pourra pas être annulé. Continuer ?",
+ success: "Succès",
+ copied_to_clipboard: "Copié dans le presse-papiers",
// Calendar
price: "Prix",
category: "Catégorie",
diff --git a/scripts/i18n/it.js b/scripts/i18n/it.js
index a05c3bc64..a197ece05 100644
--- a/scripts/i18n/it.js
+++ b/scripts/i18n/it.js
@@ -36,6 +36,8 @@ let i18n = {
error_sending_notification: "Errore nell'invio della notifica",
delete_account_confirmation: "Sei sicuro di voler eliminare il tuo account?",
this_will_delete_all_data: "Questo eliminerà tutti i tuoi dati e non potrà essere annullato. Continuare?",
+ success: "Successo",
+ copied_to_clipboard: "Copiato negli appunti",
// Calendar
price: "Prezzo",
category: "Categoria",
diff --git a/scripts/i18n/jp.js b/scripts/i18n/jp.js
index f2bbd8a2f..45a8e9806 100644
--- a/scripts/i18n/jp.js
+++ b/scripts/i18n/jp.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "通知の送信エラー",
delete_account_confirmation: "アカウントを削除してもよろしいですか?",
this_will_delete_all_data: "これによりすべてのデータが削除され、元に戻すことはできません。続行しますか?",
+ success: "成功",
+ copied_to_clipboard: "クリップボードにコピーされました",
// Calendar
price: "価格",
category: "カテゴリ",
diff --git a/scripts/i18n/ko.js b/scripts/i18n/ko.js
index 45c861175..2961e6eb1 100644
--- a/scripts/i18n/ko.js
+++ b/scripts/i18n/ko.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "알림 전송 에러",
delete_account_confirmation: "정말 계정을 삭제하시겠습니까?",
this_will_delete_all_data: "이로 인해 모든 데이터가 삭제되며 복구할 수 없습니다. 계속하시겠습니까?",
+ success: "성공",
+ copied_to_clipboard: "클립보드에 복사되었습니다",
// Calendar
price: "가격",
category: "카테고리",
diff --git a/scripts/i18n/pl.js b/scripts/i18n/pl.js
index 2569782e2..46be9648a 100644
--- a/scripts/i18n/pl.js
+++ b/scripts/i18n/pl.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Błąd wysyłania powiadomienia",
delete_account_confirmation: "Czy na pewno chcesz usunąć swoje konto?",
this_will_delete_all_data: "Spowoduje to usunięcie wszystkich danych i nie będzie można tego cofnąć. Kontynuować?",
+ success: "Sukces",
+ copied_to_clipboard: "Skopiowano do schowka",
// Calendar
price: "Cena",
category: "Kategoria",
diff --git a/scripts/i18n/pt.js b/scripts/i18n/pt.js
index cd6f91902..f337737ce 100644
--- a/scripts/i18n/pt.js
+++ b/scripts/i18n/pt.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: 'Erro ao enviar notificação',
delete_account_confirmation: "Tem a certeza de que deseja eliminar a sua conta?",
this_will_delete_all_data: "Isto irá eliminar todos os seus dados e não poderão ser recuperados. Continuar?",
+ success: "Sucesso",
+ copied_to_clipboard: "Copiado para a área de transferência",
// Calendar
price: "Preço",
category: "Categoria",
diff --git a/scripts/i18n/pt_br.js b/scripts/i18n/pt_br.js
index cb1f3e36d..7efc8a364 100644
--- a/scripts/i18n/pt_br.js
+++ b/scripts/i18n/pt_br.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Erro ao enviar notificação",
delete_account_confirmation: "Você tem certeza que deseja excluir sua conta?",
this_will_delete_all_data: "Isso excluirá todos os seus dados e não poderão ser recuperados. Continuar?",
+ success: "Sucesso",
+ copied_to_clipboard: "Copiado para a área de transferência",
// Calendar
price: "Preço",
category: "Categoria",
diff --git a/scripts/i18n/ru.js b/scripts/i18n/ru.js
index 4b048414e..03c38628f 100644
--- a/scripts/i18n/ru.js
+++ b/scripts/i18n/ru.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Ошибка отправки уведомления",
delete_account_confirmation: "Вы уверены, что хотите удалить свою учетную запись?",
this_will_delete_all_data: "Это удалит все ваши данные и не может быть отменено. Продолжить?",
+ success: "Успешно",
+ copied_to_clipboard: "Скопировано в буфер обмена",
// Calendar
price: "Цена",
category: "Категория",
diff --git a/scripts/i18n/sl.js b/scripts/i18n/sl.js
index ed61d6e47..2ba461452 100644
--- a/scripts/i18n/sl.js
+++ b/scripts/i18n/sl.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Napaka pri pošiljanju obvestila",
delete_account_confirmation: "Ali ste prepričani, da želite izbrisati svoj račun?",
this_will_delete_all_data: "To bo izbrisalo vse vaše podatke in jih ni mogoče obnoviti. Nadaljujem?",
+ success: "Uspeh",
+ copied_to_clipboard: "Kopirano v odložišče",
// Calendar
price: "Cena",
category: "Kategorija",
diff --git a/scripts/i18n/sr.js b/scripts/i18n/sr.js
index 0e7094220..bde46e23f 100644
--- a/scripts/i18n/sr.js
+++ b/scripts/i18n/sr.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Грешка при слању обавештења",
delete_account_confirmation: "Да ли сте сигурни да желите да избришете свој налог?",
this_will_delete_all_data: "Ово ће избрисати све ваше податке и не може се поништити. Настави?",
+ success: "Успех",
+ copied_to_clipboard: "Копирано у привремену меморију",
// Calendar
price: "Цена",
category: "Категорија",
diff --git a/scripts/i18n/sr_lat.js b/scripts/i18n/sr_lat.js
index 5eb1761d4..fdc3c32cb 100644
--- a/scripts/i18n/sr_lat.js
+++ b/scripts/i18n/sr_lat.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Greška pri slanju obaveštenja",
delete_account_confirmation: "Da li ste sigurni da želite da izbrišete svoj nalog?",
this_will_delete_all_data: "Ovo će izbrisati sve vaše podatke i ne može se poništiti. Da li nastaviti?",
+ success: "Uspeh",
+ copied_to_clipboard: "Kopirano u privremenu memoriju",
// Calendar
price: "Cena",
category: "Kategorija",
diff --git a/scripts/i18n/tr.js b/scripts/i18n/tr.js
index 579339b45..245cea3e0 100644
--- a/scripts/i18n/tr.js
+++ b/scripts/i18n/tr.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: "Bildirim gönderilirken hata oluştu",
delete_account_confirmation: "Hesabınızı silmek istediğinizden emin misiniz?",
this_will_delete_all_data: "Bu tüm verilerinizi silecek ve geri alınamaz. Devam etmek istiyor musunuz?",
+ success: "Başarılı",
+ copied_to_clipboard: "Panoya kopyalandı",
// Calendar
price: "Price",
category: "Category",
diff --git a/scripts/i18n/zh_cn.js b/scripts/i18n/zh_cn.js
index 1e06f346d..f9460ef5b 100644
--- a/scripts/i18n/zh_cn.js
+++ b/scripts/i18n/zh_cn.js
@@ -35,6 +35,8 @@ let i18n = {
'error_sending_notification': '发送通知时出错',
'delete_account_confirmation': "您确定要删除您的帐户吗?",
'this_will_delete_all_data': "这将删除所有您的数据,且无法撤销。是否继续?",
+ 'success': "成功",
+ 'copied_to_clipboard': "已复制到剪贴板",
// Calendar
price: "价格",
category: "类别",
diff --git a/scripts/i18n/zh_tw.js b/scripts/i18n/zh_tw.js
index 43c5155e3..2b9f5a12e 100644
--- a/scripts/i18n/zh_tw.js
+++ b/scripts/i18n/zh_tw.js
@@ -35,6 +35,8 @@ let i18n = {
error_sending_notification: '發送通知時發生錯誤',
delete_account_confirmation: "您確定要刪除您的帳戶嗎?",
this_will_delete_all_data: "這將刪除所有資料,且無法復原。繼續?",
+ success: "成功",
+ copied_to_clipboard: "已複製到剪貼簿",
// Calendar
price: "價格",
category: "類別",
diff --git a/scripts/libs/qrcode.min.js b/scripts/libs/qrcode.min.js
new file mode 100644
index 000000000..993e88f39
--- /dev/null
+++ b/scripts/libs/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push(' | ');g.push("
")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
\ No newline at end of file
diff --git a/scripts/settings.js b/scripts/settings.js
index 85c0db2cf..fd60b1491 100644
--- a/scripts/settings.js
+++ b/scripts/settings.js
@@ -33,35 +33,35 @@ function closeAvatarSelect() {
}
document.querySelectorAll('.avatar-option').forEach((avatar) => {
- avatar.addEventListener("click", () => {
- changeAvatar(avatar.src);
- document.getElementById('avatarUser').value = avatar.getAttribute('data-src');
- closeAvatarSelect();
- })
+ avatar.addEventListener("click", () => {
+ changeAvatar(avatar.src);
+ document.getElementById('avatarUser').value = avatar.getAttribute('data-src');
+ closeAvatarSelect();
+ })
});
function changeAvatar(src) {
- document.getElementById("avatarImg").src = src;
+ document.getElementById("avatarImg").src = src;
}
function successfulUpload(field, msg) {
- var reader = new FileReader();
+ var reader = new FileReader();
- if (field.files.length === 0) {
- return;
- }
-
- if (! ['image/jpeg', 'image/png', 'image/gif', 'image/jtif', 'image/webp'].includes(field.files[0]['type'])) {
- showErrorMessage(msg);
- return;
- }
+ if (field.files.length === 0) {
+ return;
+ }
- reader.onload = function() {
- changeAvatar(reader.result);
- };
+ if (!['image/jpeg', 'image/png', 'image/gif', 'image/jtif', 'image/webp'].includes(field.files[0]['type'])) {
+ showErrorMessage(msg);
+ return;
+ }
- reader.readAsDataURL(field.files[0]);
- closeAvatarSelect();
+ reader.onload = function () {
+ changeAvatar(reader.result);
+ };
+
+ reader.readAsDataURL(field.files[0]);
+ closeAvatarSelect();
}
function deleteAvatar(path) {
@@ -72,21 +72,21 @@ function deleteAvatar(path) {
},
body: JSON.stringify({ avatar: path }),
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- var avatarContainer = document.querySelector(`.avatar-container[data-src="${path}"]`);
- if (avatarContainer) {
- avatarContainer.remove();
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ var avatarContainer = document.querySelector(`.avatar-container[data-src="${path}"]`);
+ if (avatarContainer) {
+ avatarContainer.remove();
+ }
+ showSuccessMessage();
+ } else {
+ showErrorMessage();
}
- showSuccessMessage();
- } else {
- showErrorMessage();
- }
- })
- .catch((error) => {
- console.error('Error:', error);
- });
+ })
+ .catch((error) => {
+ console.error('Error:', error);
+ });
}
function saveBudget() {
@@ -102,19 +102,19 @@ function saveBudget() {
},
body: JSON.stringify({ budget: budget })
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- showSuccessMessage(data.message);
- } else {
- showErrorMessage(data.message);
- }
- button.disabled = false;
- })
- .catch(error => {
- showErrorMessage(translate('unknown_error'));
- button.disabled = false;
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showSuccessMessage(data.message);
+ } else {
+ showErrorMessage(data.message);
+ }
+ button.disabled = false;
+ })
+ .catch(error => {
+ showErrorMessage(translate('unknown_error'));
+ button.disabled = false;
+ });
}
@@ -122,68 +122,68 @@ function addMemberButton(memberId) {
document.getElementById("addMember").disabled = true;
const url = 'endpoints/household/household.php?action=add';
fetch(url)
- .then(response => {
- if (!response.ok) {
- throw new Error(translate('network_response_error'));
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(translate('network_response_error'));
+ showErrorMessage(translate('failed_add_member'));
+ }
+ return response.json();
+ })
+ .then(responseData => {
+ if (responseData.success) {
+ const newMemberId = responseData.householdId;;
+ let container = document.getElementById("householdMembers");
+ let div = document.createElement("div");
+ div.className = "form-group-inline";
+ div.dataset.memberid = newMemberId;
+
+ let input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = translate('member');
+ input.name = "member";
+ input.value = translate('member');
+
+ let emailInput = document.createElement("input");
+ emailInput.type = "text";
+ emailInput.placeholder = translate('email');
+ emailInput.name = "email";
+ emailInput.value = "";
+
+ let editLink = document.createElement("button");
+ editLink.className = "image-button medium"
+ editLink.name = "save";
+ editLink.onclick = function () {
+ editMember(newMemberId);
+ };
+
+ editLink.innerHTML = editSvgContent;
+ editLink.title = translate('save_member');
+
+ let deleteLink = document.createElement("button");
+ deleteLink.className = "image-button medium"
+ deleteLink.name = "delete";
+ deleteLink.onclick = function () {
+ removeMember(newMemberId);
+ };
+
+ deleteLink.innerHTML = deleteSvgContent;
+ deleteLink.title = translate('delete_member');
+
+ div.appendChild(input);
+ div.appendChild(emailInput);
+ div.appendChild(editLink);
+ div.appendChild(deleteLink);
+
+ container.appendChild(div);
+ } else {
+ showErrorMessage(responseData.errorMessage);
+ }
+ document.getElementById("addMember").disabled = false;
+ })
+ .catch(error => {
showErrorMessage(translate('failed_add_member'));
- }
- return response.json();
- })
- .then(responseData => {
- if(responseData.success) {
- const newMemberId = responseData.householdId;;
- let container = document.getElementById("householdMembers");
- let div = document.createElement("div");
- div.className = "form-group-inline";
- div.dataset.memberid = newMemberId;
-
- let input = document.createElement("input");
- input.type = "text";
- input.placeholder = translate('member');
- input.name = "member";
- input.value = translate('member');
-
- let emailInput = document.createElement("input");
- emailInput.type = "text";
- emailInput.placeholder = translate('email');
- emailInput.name = "email";
- emailInput.value = "";
-
- let editLink = document.createElement("button");
- editLink.className = "image-button medium"
- editLink.name = "save";
- editLink.onclick = function() {
- editMember(newMemberId);
- };
-
- editLink.innerHTML = editSvgContent;
- editLink.title = translate('save_member');
-
- let deleteLink = document.createElement("button");
- deleteLink.className = "image-button medium"
- deleteLink.name = "delete";
- deleteLink.onclick = function() {
- removeMember(newMemberId);
- };
-
- deleteLink.innerHTML = deleteSvgContent;
- deleteLink.title = translate('delete_member');
-
- div.appendChild(input);
- div.appendChild(emailInput);
- div.appendChild(editLink);
- div.appendChild(deleteLink);
-
- container.appendChild(div);
- } else {
- showErrorMessage(responseData.errorMessage);
- }
- document.getElementById("addMember").disabled = false;
- })
- .catch(error => {
- showErrorMessage(translate('failed_add_member'));
- document.getElementById("addMember").disabled = false;
- });
+ document.getElementById("addMember").disabled = false;
+ });
}
@@ -197,15 +197,15 @@ function removeMember(memberId) {
return response.json();
})
.then(responseData => {
- if (responseData.success) {
- let divToRemove = document.querySelector(`[data-memberid="${memberId}"]`);
- if (divToRemove) {
- divToRemove.parentNode.removeChild(divToRemove);
+ if (responseData.success) {
+ let divToRemove = document.querySelector(`[data-memberid="${memberId}"]`);
+ if (divToRemove) {
+ divToRemove.parentNode.removeChild(divToRemove);
+ }
+ showSuccessMessage(responseData.message);
+ } else {
+ showErrorMessage(responseData.errorMessage || translate('failed_remove_member'));
}
- showSuccessMessage(responseData.message);
- } else {
- showErrorMessage(responseData.errorMessage || translate('failed_remove_member'));
- }
})
.catch(error => {
showErrorMessage(translate('failed_remove_member'));
@@ -248,65 +248,65 @@ function addCategoryButton(categoryId) {
document.getElementById("addCategory").disabled = true;
const url = 'endpoints/categories/category.php?action=add';
fetch(url)
- .then(response => {
- if (!response.ok) {
+ .then(response => {
+ if (!response.ok) {
+ showErrorMessage(translate('failed_add_category'));
+ throw new Error(translate('network_response_error'));
+ }
+ return response.json();
+ })
+ .then(responseData => {
+ if (responseData.success) {
+ const newCategoryId = responseData.categoryId;;
+ let container = document.getElementById("categories");
+ let row = document.createElement("div");
+ row.className = "form-group-inline";
+ row.dataset.categoryid = newCategoryId;
+
+ let dragIcon = document.createElement("div");
+ dragIcon.className = "drag-icon";
+
+ let input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = translate('category');
+ input.name = "category";
+ input.value = translate('category');
+
+ let editLink = document.createElement("button");
+ editLink.className = "image-button medium"
+ editLink.name = "save";
+ editLink.onclick = function () {
+ editCategory(newCategoryId);
+ };
+
+ editLink.innerHTML = editSvgContent;
+ editLink.title = translate('save_member');
+
+ let deleteLink = document.createElement("button");
+ deleteLink.className = "image-button medium"
+ deleteLink.name = "delete";
+ deleteLink.onclick = function () {
+ removeCategory(newCategoryId);
+ };
+
+ deleteLink.innerHTML = deleteSvgContent;
+ deleteLink.title = translate('delete_member');
+
+ row.appendChild(dragIcon);
+ row.appendChild(input);
+ row.appendChild(editLink);
+ row.appendChild(deleteLink);
+
+ container.appendChild(row);
+ } else {
+ showErrorMessage(responseData.errorMessage);
+ }
+ document.getElementById("addCategory").disabled = false;
+ })
+ .catch(error => {
showErrorMessage(translate('failed_add_category'));
- throw new Error(translate('network_response_error'));
- }
- return response.json();
- })
- .then(responseData => {
- if(responseData.success) {
- const newCategoryId = responseData.categoryId;;
- let container = document.getElementById("categories");
- let row = document.createElement("div");
- row.className = "form-group-inline";
- row.dataset.categoryid = newCategoryId;
-
- let dragIcon = document.createElement("div");
- dragIcon.className = "drag-icon";
-
- let input = document.createElement("input");
- input.type = "text";
- input.placeholder = translate('category');
- input.name = "category";
- input.value = translate('category');
-
- let editLink = document.createElement("button");
- editLink.className = "image-button medium"
- editLink.name = "save";
- editLink.onclick = function() {
- editCategory(newCategoryId);
- };
-
- editLink.innerHTML = editSvgContent;
- editLink.title = translate('save_member');
-
- let deleteLink = document.createElement("button");
- deleteLink.className = "image-button medium"
- deleteLink.name = "delete";
- deleteLink.onclick = function() {
- removeCategory(newCategoryId);
- };
-
- deleteLink.innerHTML = deleteSvgContent;
- deleteLink.title = translate('delete_member');
-
- row.appendChild(dragIcon);
- row.appendChild(input);
- row.appendChild(editLink);
- row.appendChild(deleteLink);
-
- container.appendChild(row);
- } else {
- showErrorMessage(responseData.errorMessage);
- }
- document.getElementById("addCategory").disabled = false;
- })
- .catch(error => {
- showErrorMessage(translate('failed_add_category'));
- document.getElementById("addCategory").disabled = false;
- });
+ document.getElementById("addCategory").disabled = false;
+ });
}
@@ -320,15 +320,15 @@ function removeCategory(categoryId) {
return response.json();
})
.then(responseData => {
- if (responseData.success) {
- let divToRemove = document.querySelector(`[data-categoryid="${categoryId}"]`);
- if (divToRemove) {
- divToRemove.parentNode.removeChild(divToRemove);
+ if (responseData.success) {
+ let divToRemove = document.querySelector(`[data-categoryid="${categoryId}"]`);
+ if (divToRemove) {
+ divToRemove.parentNode.removeChild(divToRemove);
+ }
+ showSuccessMessage(responseData.message);
+ } else {
+ showErrorMessage(responseData.errorMessage || translate('failed_remove_category'));
}
- showSuccessMessage(responseData.message);
- } else {
- showErrorMessage(responseData.errorMessage || translate('failed_remove_category'));
- }
})
.catch(error => {
showErrorMessage(translate('failed_remove_category'));
@@ -338,7 +338,7 @@ function removeCategory(categoryId) {
function editCategory(categoryId) {
var saveButton = document.querySelector(`div[data-categoryid="${categoryId}"] button[name="save"]`);
var inputElement = document.querySelector(`div[data-categoryid="${categoryId}"] input[name="category"]`);
-
+
saveButton.classList.add("disabled");
saveButton.disabled = true;
if (inputElement) {
@@ -370,102 +370,102 @@ function addCurrencyButton(currencyId) {
document.getElementById("addCurrency").disabled = true;
const url = 'endpoints/currency/add.php';
fetch(url)
- .then(response => {
- if (!response.ok) {
- throw new Error(translate('network_response_error'));
- showErrorMessage(response.text());
- }
- return response.text();
- })
- .then(responseText => {
- if(responseText !== "Error") {
- const newCurrencyId = responseText;
- let container = document.getElementById("currencies");
- let div = document.createElement("div");
- div.className = "form-group-inline";
- div.dataset.currencyid = newCurrencyId;
-
- let inputSymbol = document.createElement("input");
- inputSymbol.type = "text";
- inputSymbol.placeholder = "$";
- inputSymbol.name = "symbol";
- inputSymbol.value = "$";
- inputSymbol.classList.add("short");
-
- let inputName = document.createElement("input");
- inputName.type = "text";
- inputName.placeholder = translate('currency');
- inputName.name = "currency";
- inputName.value = translate('currency');
-
- let inputCode = document.createElement("input");
- inputCode.type = "text";
- inputCode.placeholder = translate('currency_code');
- inputCode.name = "code";
- inputCode.value = "CODE";
-
- let editLink = document.createElement("button");
- editLink.className = "image-button medium"
- editLink.name = "save";
- editLink.onclick = function() {
- editCurrency(newCurrencyId);
- };
-
- editLink.innerHTML = editSvgContent;
- editLink.title = translate('save_member');
-
- let deleteLink = document.createElement("button");
- deleteLink.className = "image-button medium"
- deleteLink.name = "delete";
- deleteLink.onclick = function() {
- removeCurrency(newCurrencyId);
- };
-
- deleteLink.innerHTML = deleteSvgContent;
- deleteLink.title = translate('delete_member');
-
- div.appendChild(inputSymbol);
- div.appendChild(inputName);
- div.appendChild(inputCode);
- div.appendChild(editLink);
- div.appendChild(deleteLink);
-
- container.appendChild(div);
- } else {
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(translate('network_response_error'));
+ showErrorMessage(response.text());
+ }
+ return response.text();
+ })
+ .then(responseText => {
+ if (responseText !== "Error") {
+ const newCurrencyId = responseText;
+ let container = document.getElementById("currencies");
+ let div = document.createElement("div");
+ div.className = "form-group-inline";
+ div.dataset.currencyid = newCurrencyId;
+
+ let inputSymbol = document.createElement("input");
+ inputSymbol.type = "text";
+ inputSymbol.placeholder = "$";
+ inputSymbol.name = "symbol";
+ inputSymbol.value = "$";
+ inputSymbol.classList.add("short");
+
+ let inputName = document.createElement("input");
+ inputName.type = "text";
+ inputName.placeholder = translate('currency');
+ inputName.name = "currency";
+ inputName.value = translate('currency');
+
+ let inputCode = document.createElement("input");
+ inputCode.type = "text";
+ inputCode.placeholder = translate('currency_code');
+ inputCode.name = "code";
+ inputCode.value = "CODE";
+
+ let editLink = document.createElement("button");
+ editLink.className = "image-button medium"
+ editLink.name = "save";
+ editLink.onclick = function () {
+ editCurrency(newCurrencyId);
+ };
+
+ editLink.innerHTML = editSvgContent;
+ editLink.title = translate('save_member');
+
+ let deleteLink = document.createElement("button");
+ deleteLink.className = "image-button medium"
+ deleteLink.name = "delete";
+ deleteLink.onclick = function () {
+ removeCurrency(newCurrencyId);
+ };
+
+ deleteLink.innerHTML = deleteSvgContent;
+ deleteLink.title = translate('delete_member');
+
+ div.appendChild(inputSymbol);
+ div.appendChild(inputName);
+ div.appendChild(inputCode);
+ div.appendChild(editLink);
+ div.appendChild(deleteLink);
+
+ container.appendChild(div);
+ } else {
+ // TODO: Show error
+ }
+ document.getElementById("addCurrency").disabled = false;
+ })
+ .catch(error => {
// TODO: Show error
- }
- document.getElementById("addCurrency").disabled = false;
- })
- .catch(error => {
- // TODO: Show error
- document.getElementById("addCurrency").disabled = false;
- });
+ document.getElementById("addCurrency").disabled = false;
+ });
}
function removeCurrency(currencyId) {
let url = `endpoints/currency/remove.php?currencyId=${currencyId}`;
fetch(url)
- .then(response => {
- if (!response.ok) {
- throw new Error(translate('network_response_error'));
- }
- return response.json();
- })
- .then(data => {
- if (data.success) {
- showSuccessMessage(data.message);
- let divToRemove = document.querySelector(`[data-currencyid="${currencyId}"]`);
- if (divToRemove) {
- divToRemove.parentNode.removeChild(divToRemove);
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(translate('network_response_error'));
}
- } else {
- showErrorMessage(data.message || translate('failed_remove_currency'));
- }
- })
- .catch(error => {
- showErrorMessage(error.message || translate('failed_remove_currency'));
- });
+ return response.json();
+ })
+ .then(data => {
+ if (data.success) {
+ showSuccessMessage(data.message);
+ let divToRemove = document.querySelector(`[data-currencyid="${currencyId}"]`);
+ if (divToRemove) {
+ divToRemove.parentNode.removeChild(divToRemove);
+ }
+ } else {
+ showErrorMessage(data.message || translate('failed_remove_currency'));
+ }
+ })
+ .catch(error => {
+ showErrorMessage(error.message || translate('failed_remove_currency'));
+ });
}
function editCurrency(currencyId) {
@@ -508,41 +508,41 @@ function editCurrency(currencyId) {
}
function togglePayment(paymentId) {
- const element = document.querySelector(`div[data-paymentid="${paymentId}"]`);
+ const element = document.querySelector(`div[data-paymentid="${paymentId}"]`);
- if (element.dataset.inUse === 'yes') {
- return showErrorMessage(translate('cant_disable_payment_in_use'));
- }
+ if (element.dataset.inUse === 'yes') {
+ return showErrorMessage(translate('cant_disable_payment_in_use'));
+ }
- const newEnabledState = element.dataset.enabled === '1' ? '0' : '1';
- const paymentMethodName = element.querySelector('.payment-name').innerText;
+ const newEnabledState = element.dataset.enabled === '1' ? '0' : '1';
+ const paymentMethodName = element.querySelector('.payment-name').innerText;
- const url = `endpoints/payments/payment.php?action=toggle&paymentId=${paymentId}&enabled=${newEnabledState}`;
+ const url = `endpoints/payments/payment.php?action=toggle&paymentId=${paymentId}&enabled=${newEnabledState}`;
- fetch(url).then(response => {
- if (!response.ok) {
- throw new Error(translate('network_response_error'));
- }
- return response.json();
- }).then(data => {
- if (data.success) {
- element.dataset.enabled = newEnabledState;
- showSuccessMessage(`${paymentMethodName} ${data.message}`);
- } else {
- showErrorMessage(data.message || translate('failed_save_payment_method'));
- }
- }).catch(error => {
- showErrorMessage(error.message || translate('failed_save_payment_method'));
- });
+ fetch(url).then(response => {
+ if (!response.ok) {
+ throw new Error(translate('network_response_error'));
+ }
+ return response.json();
+ }).then(data => {
+ if (data.success) {
+ element.dataset.enabled = newEnabledState;
+ showSuccessMessage(`${paymentMethodName} ${data.message}`);
+ } else {
+ showErrorMessage(data.message || translate('failed_save_payment_method'));
+ }
+ }).catch(error => {
+ showErrorMessage(error.message || translate('failed_save_payment_method'));
+ });
}
-document.body.addEventListener('click', function(e) {
+document.body.addEventListener('click', function (e) {
let targetElement = e.target;
do {
if (targetElement.classList && targetElement.classList.contains('payments-payment')) {
let targetChild = e.target;
do {
- if (targetChild.classList && (targetChild.classList.contains('payment-name') || targetChild.classList.contains('drag-icon') )) {
+ if (targetChild.classList && (targetChild.classList.contains('payment-name') || targetChild.classList.contains('drag-icon'))) {
return;
}
targetChild = targetChild.parentNode;
@@ -556,7 +556,7 @@ document.body.addEventListener('click', function(e) {
} while (targetElement);
});
-document.body.addEventListener('blur', function(e) {
+document.body.addEventListener('blur', function (e) {
let targetElement = e.target;
if (targetElement.classList && targetElement.classList.contains('payment-name')) {
const paymentId = targetElement.closest('.payments-payment').dataset.paymentid;
@@ -589,14 +589,14 @@ function renamePayment(paymentId, newName) {
});
}
-document.body.addEventListener('keypress', function(e) {
- let targetElement = e.target;
- if (targetElement.classList && targetElement.classList.contains('payment-name')) {
- if (e.key === 'Enter') {
- e.preventDefault();
- targetElement.blur();
- }
+document.body.addEventListener('keypress', function (e) {
+ let targetElement = e.target;
+ if (targetElement.classList && targetElement.classList.contains('payment-name')) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ targetElement.blur();
}
+ }
});
function handleFileSelect(event) {
@@ -607,14 +607,14 @@ function handleFileSelect(event) {
iconUrl.value = "";
if (fileInput.files && fileInput.files[0]) {
- const reader = new FileReader();
+ const reader = new FileReader();
- reader.onload = function (e) {
- iconImg.src = e.target.result;
- iconImg.style.display = 'block';
- };
+ reader.onload = function (e) {
+ iconImg.src = e.target.result;
+ iconImg.style.display = 'block';
+ };
- reader.readAsDataURL(fileInput.files[0]);
+ reader.readAsDataURL(fileInput.files[0]);
}
}
@@ -639,17 +639,17 @@ function searchPaymentIcon() {
iconSearchPopup.classList.add("is-open");
const imageSearchUrl = `endpoints/payments/search.php?search=${searchTerm}`;
fetch(imageSearchUrl)
- .then(response => response.json())
- .then(data => {
- if (data.imageUrls) {
- displayImageResults(data.imageUrls);
- } else if (data.error) {
- console.error(data.error);
- }
- })
- .catch(error => {
- console.error(translate('error_fetching_image_results'), error);
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.imageUrls) {
+ displayImageResults(data.imageUrls);
+ } else if (data.error) {
+ console.error(data.error);
+ }
+ })
+ .catch(error => {
+ console.error(translate('error_fetching_image_results'), error);
+ });
} else {
nameInput.focus();
}
@@ -660,15 +660,15 @@ function displayImageResults(imageSources) {
iconResults.innerHTML = "";
imageSources.forEach(src => {
- const img = document.createElement("img");
- img.src = src;
- img.onclick = function() {
- selectWebIcon(src);
- };
- img.onerror = function() {
- this.parentNode.removeChild(this);
- };
- iconResults.appendChild(img);
+ const img = document.createElement("img");
+ img.src = src;
+ img.onclick = function () {
+ selectWebIcon(src);
+ };
+ img.onerror = function () {
+ this.parentNode.removeChild(this);
+ };
+ iconResults.appendChild(img);
});
}
@@ -699,10 +699,10 @@ function reloadPaymentMethods() {
const paymentMethodsEndpoint = "endpoints/payments/get.php";
fetch(paymentMethodsEndpoint)
- .then(response => response.text())
- .then(data => {
- paymentsContainer.innerHTML = data;
- });
+ .then(response => response.text())
+ .then(data => {
+ paymentsContainer.innerHTML = data;
+ });
}
function addPaymentMethod() {
@@ -718,33 +718,33 @@ function addPaymentMethod() {
method: "POST",
body: formData
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- showSuccessMessage(data.message);
- paymentMethodForm.reset();
- resetFormIcon();
- reloadPaymentMethods();
- } else {
- showErrorMessage(data.errorMessage);
- }
- submitButton.disabled = false;
- })
- .catch(error => {
- showErrorMessage(translate('unknown_error'));
- submitButton.disabled = false;
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showSuccessMessage(data.message);
+ paymentMethodForm.reset();
+ resetFormIcon();
+ reloadPaymentMethods();
+ } else {
+ showErrorMessage(data.errorMessage);
+ }
+ submitButton.disabled = false;
+ })
+ .catch(error => {
+ showErrorMessage(translate('unknown_error'));
+ submitButton.disabled = false;
+ });
}
function deletePaymentMethod(paymentId) {
- fetch(`endpoints/payments/delete.php?id=${paymentId}`, {
- method: 'DELETE',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ id: paymentId }),
- })
+ fetch(`endpoints/payments/delete.php?id=${paymentId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ id: paymentId }),
+ })
.then(response => response.json())
.then(data => {
if (data.success) {
@@ -768,24 +768,24 @@ function savePaymentMethodsSorting() {
const formData = new FormData();
paymentMethodIds.forEach(paymentMethodId => {
- formData.append('paymentMethodIds[]', paymentMethodId);
+ formData.append('paymentMethodIds[]', paymentMethodId);
});
fetch('endpoints/payments/sort.php', {
method: 'POST',
body: formData
})
- .then(response => response.json())
- .then(data => {
+ .then(response => response.json())
+ .then(data => {
if (data.success) {
- showSuccessMessage(data.message);
+ showSuccessMessage(data.message);
} else {
- showErrorMessage(data.errorMessage);
+ showErrorMessage(data.errorMessage);
}
- })
- .catch(error => {
+ })
+ .catch(error => {
showErrorMessage(translate('unknown_error'));
- });
+ });
}
var el = document.getElementById('payments-list');
@@ -801,45 +801,45 @@ var sortable = Sortable.create(el, {
});
-document.addEventListener('DOMContentLoaded', function() {
-
- document.getElementById("userForm").addEventListener("submit", function(event) {
- event.preventDefault();
- document.getElementById("userSubmit").disabled = true;
- const formData = new FormData(event.target);
- fetch("endpoints/user/save_user.php", {
- method: "POST",
- body: formData
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- document.getElementById("avatar").src = document.getElementById("avatarImg").src;
- var newUsername = document.getElementById("username").value;
- document.getElementById("user").textContent = newUsername;
- showSuccessMessage(data.message);
- if (data.reload) {
- location.reload();
- }
- } else {
- showErrorMessage(data.errorMessage);
+document.addEventListener('DOMContentLoaded', function () {
+
+ document.getElementById("userForm").addEventListener("submit", function (event) {
+ event.preventDefault();
+ document.getElementById("userSubmit").disabled = true;
+ const formData = new FormData(event.target);
+ fetch("endpoints/user/save_user.php", {
+ method: "POST",
+ body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ document.getElementById("avatar").src = document.getElementById("avatarImg").src;
+ var newUsername = document.getElementById("username").value;
+ document.getElementById("user").textContent = newUsername;
+ showSuccessMessage(data.message);
+ if (data.reload) {
+ location.reload();
}
- document.getElementById("userSubmit").disabled = false;
- })
- .catch(error => {
- showErrorMessage(translate('unknown_error'));
- });
+ } else {
+ showErrorMessage(data.errorMessage);
+ }
+ document.getElementById("userSubmit").disabled = false;
+ })
+ .catch(error => {
+ showErrorMessage(translate('unknown_error'));
});
+ });
- var removePaymentButtons = document.querySelectorAll(".delete-payment-method");
- removePaymentButtons.forEach(function(button) {
- button.addEventListener('click', function(event) {
- event.preventDefault();
- event.stopPropagation();
- let paymentId = event.target.getAttribute('data-paymentid');
- deletePaymentMethod(paymentId);
- });
- });
+ var removePaymentButtons = document.querySelectorAll(".delete-payment-method");
+ removePaymentButtons.forEach(function (button) {
+ button.addEventListener('click', function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ let paymentId = event.target.getAttribute('data-paymentid');
+ deletePaymentMethod(paymentId);
+ });
+ });
});
@@ -852,44 +852,44 @@ function addFixerKeyButton() {
fetch("endpoints/currency/fixer_api_key.php", {
method: "POST",
headers: {
- "Content-Type": "application/x-www-form-urlencoded",
+ "Content-Type": "application/x-www-form-urlencoded",
},
body: `api_key=${encodeURIComponent(apiKey)}&provider=${encodeURIComponent(provider)}`,
})
- .then(response => response.json())
- .then(data => {
+ .then(response => response.json())
+ .then(data => {
if (data.success) {
- showSuccessMessage(data.message);
- document.getElementById("addFixerKey").disabled = false;
- convertCurrencyCheckbox.disabled = false;
- // update currency exchange rates
- fetch("endpoints/currency/update_exchange.php?force=true");
+ showSuccessMessage(data.message);
+ document.getElementById("addFixerKey").disabled = false;
+ convertCurrencyCheckbox.disabled = false;
+ // update currency exchange rates
+ fetch("endpoints/currency/update_exchange.php?force=true");
} else {
- showErrorMessage(data.message);
- document.getElementById("addFixerKey").disabled = false;
+ showErrorMessage(data.message);
+ document.getElementById("addFixerKey").disabled = false;
}
- })
- .catch(error => {
- showErrorMessage(error);
- document.getElementById("addFixerKey").disabled = false;
- });
+ })
+ .catch(error => {
+ showErrorMessage(error);
+ document.getElementById("addFixerKey").disabled = false;
+ });
}
function storeSettingsOnDB(endpoint, value) {
fetch('endpoints/settings/' + endpoint + '.php', {
method: 'POST',
headers: {
- 'Content-Type': 'application/json'
+ 'Content-Type': 'application/json'
},
- body: JSON.stringify({"value": value})
+ body: JSON.stringify({ "value": value })
})
- .then(response => response.json())
- .then(data => {
+ .then(response => response.json())
+ .then(data => {
if (data.success) {
- showSuccessMessage(data.message);
+ showSuccessMessage(data.message);
} else {
- showErrorMessage(data.errorMessage);
+ showErrorMessage(data.errorMessage);
}
- });
+ });
}
function setShowMonthlyPrice() {
@@ -923,7 +923,7 @@ function setHideDisabled() {
function setDisabledToBottom() {
const disabledToBottomCheckbox = document.querySelector("#disabledtobottom");
const value = disabledToBottomCheckbox.checked;
-
+
storeSettingsOnDB('disabled_to_bottom', value);
}
@@ -937,27 +937,27 @@ function setShowOriginalPrice() {
function saveCategorySorting() {
const categories = document.getElementById('categories');
const categoryIds = Array.from(categories.children).map(category => category.dataset.categoryid);
-
+
const formData = new FormData();
categoryIds.forEach(categoryId => {
- formData.append('categoryIds[]', categoryId);
+ formData.append('categoryIds[]', categoryId);
});
-
+
fetch('endpoints/categories/sort.php', {
method: 'POST',
body: formData
})
- .then(response => response.json())
- .then(data => {
+ .then(response => response.json())
+ .then(data => {
if (data.success) {
- showSuccessMessage(data.message);
+ showSuccessMessage(data.message);
} else {
- showErrorMessage(data.errorMessage);
+ showErrorMessage(data.errorMessage);
}
- })
- .catch(error => {
+ })
+ .catch(error => {
showErrorMessage(translate('unknown_error'));
- });
+ });
}
var el = document.getElementById('categories');
@@ -974,50 +974,50 @@ var sortable = Sortable.create(el, {
function exportAsJson() {
fetch("endpoints/subscriptions/export.php")
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const subscriptions = JSON.stringify(data.subscriptions);
- const element = document.createElement('a');
- element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(subscriptions));
- element.setAttribute('download', 'subscriptions.json');
- element.style.display = 'none';
- document.body.appendChild(element);
- element.click();
- document.body.removeChild(element);
- } else {
- showErrorMessage(data.message);
- }
- })
- .catch(error => {
- console.log(error);
- showErrorMessage(translate('unknown_error'));
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ const subscriptions = JSON.stringify(data.subscriptions);
+ const element = document.createElement('a');
+ element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(subscriptions));
+ element.setAttribute('download', 'subscriptions.json');
+ element.style.display = 'none';
+ document.body.appendChild(element);
+ element.click();
+ document.body.removeChild(element);
+ } else {
+ showErrorMessage(data.message);
+ }
+ })
+ .catch(error => {
+ console.log(error);
+ showErrorMessage(translate('unknown_error'));
+ });
}
function exportAsCsv() {
fetch("endpoints/subscriptions/export.php")
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const subscriptions = data.subscriptions;
- const header = Object.keys(subscriptions[0]).join(',');
- const csv = subscriptions.map(subscription => Object.values(subscription).join(',')).join('\n');
- const csvWithHeader = header + '\n' + csv;
- const element = document.createElement('a');
- element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csvWithHeader));
- element.setAttribute('download', 'subscriptions.csv');
- element.style.display = 'none';
- document.body.appendChild(element);
- element.click();
- document.body.removeChild(element);
- } else {
- showErrorMessage(data.message);
- }
- })
- .catch(error => {
- showErrorMessage(translate('unknown_error'));
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ const subscriptions = data.subscriptions;
+ const header = Object.keys(subscriptions[0]).join(',');
+ const csv = subscriptions.map(subscription => Object.values(subscription).join(',')).join('\n');
+ const csvWithHeader = header + '\n' + csv;
+ const element = document.createElement('a');
+ element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csvWithHeader));
+ element.setAttribute('download', 'subscriptions.csv');
+ element.style.display = 'none';
+ document.body.appendChild(element);
+ element.click();
+ document.body.removeChild(element);
+ } else {
+ showErrorMessage(data.message);
+ }
+ })
+ .catch(error => {
+ showErrorMessage(translate('unknown_error'));
+ });
}
function deleteAccount(userId) {
@@ -1036,15 +1036,167 @@ function deleteAccount(userId) {
},
body: JSON.stringify({ userId: userId }),
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- window.location.href = 'logout.php';
- } else {
- showErrorMessage(data.message);
- }
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ window.location.href = 'logout.php';
+ } else {
+ showErrorMessage(data.message);
+ }
+ })
+ .catch((error) => {
+ showErrorMessage(translate('unknown_error'));
+ });
+}
+
+function enableTotp() {
+ const totpSecret = document.querySelector('#totp-secret');
+ const totpSecretCode = document.querySelector('#totp-secret-code');
+ const qrCode = document.getElementById('totp-qr-code');
+ totpSecret.value = '';
+ totpSecretCode.textContent = '';
+ qrCode.innerHTML = '';
+
+ fetch('endpoints/user/enable_totp.php?generate=true', {
+ method: 'GET'
})
- .catch((error) => {
- showErrorMessage(translate('unknown_error'));
- });
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ totpSecret.value = data.secret;
+ totpSecretCode.textContent = data.secret;
+ new QRCode(qrCode, data.qrCodeUrl);
+
+ openTotpPopup();
+ } else {
+ showErrorMessage(data.message);
+ }
+ })
+ .catch(error => {
+ showErrorMessage(error);
+ });
+}
+
+function openTotpPopup() {
+ const enableTotpButton = document.getElementById('enableTotp');
+ enableTotpButton.disabled = true;
+
+ const totpPopup = document.getElementById('totp-popup');
+ totpPopup.classList.add('is-open');
+}
+
+function closeTotpPopup() {
+ const enableTotpButton = document.getElementById('enableTotp');
+ enableTotpButton.disabled = false;
+ const totpPopup = document.getElementById('totp-popup');
+ totpPopup.classList.remove('is-open');
+
+ const totpBackupCodes = document.getElementById('totp-backup-codes');
+ if (!totpBackupCodes.classList.contains('hide')) {
+ location.reload();
+ }
+}
+
+function submitTotp() {
+ const totpCode = document.getElementById('totp').value;
+ const totpSecret = document.getElementById('totp-secret').value;
+
+ fetch('endpoints/user/enable_totp.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ totpCode: totpCode, totpSecret: totpSecret }),
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showSuccessMessage(data.message);
+ const backupCodes = data.backupCodes;
+ const backupCodesList = document.getElementById('backup-codes');
+ backupCodesList.innerHTML = '';
+ backupCodes.forEach(code => {
+ const li = document.createElement('li');
+ li.textContent = code;
+ backupCodesList.appendChild(li);
+ });
+
+ const totpSetup = document.getElementById('totp-setup');
+ const totpBackupCodes = document.getElementById('totp-backup-codes');
+
+ totpSetup.classList.add('hide');
+ totpBackupCodes.classList.remove('hide');
+ } else {
+ showErrorMessage(data.message);
+ console.log(error);
+ }
+ })
+ .catch(error => {
+ showErrorMessage(error);
+ console.log(error);
+ });
+}
+
+function copyBackupCodes() {
+ const backupCodes = document.querySelectorAll('#backup-codes li');
+ const codes = Array.from(backupCodes).map(code => code.textContent).join('\n');
+
+ navigator.clipboard.writeText(codes)
+ .then(() => {
+ showSuccessMessage(translate('copied_to_clipboard'));
+ })
+ .catch(() => {
+ showErrorMessage(translate('unknown_error'));
+ });
+}
+
+function downloadBackupCodes() {
+ const backupCodes = document.querySelectorAll('#backup-codes li');
+ const codes = Array.from(backupCodes).map(code => code.textContent).join('\n');
+ const element = document.createElement('a');
+
+ element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(codes));
+ element.setAttribute('download', 'wallos-backup-codes.txt');
+ element.style.display = 'none';
+ document.body.appendChild(element);
+
+ element.click();
+
+ document.body.removeChild(element);
+}
+
+function closeTotpDisablePopup() {
+ const totpPopup = document.getElementById('totp-disable-popup');
+ totpPopup.classList.remove('is-open');
+}
+
+function disableTotp() {
+ const totpPopup = document.getElementById('totp-disable-popup');
+ totpPopup.classList.add('is-open');
+}
+
+function submitDisableTotp() {
+ const totpCode = document.getElementById('totp-disable').value;
+
+ fetch('endpoints/user/disable_totp.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ totpCode: totpCode }),
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showSuccessMessage(data.message);
+ if (data.reload) {
+ location.reload();
+ }
+ } else {
+ showErrorMessage(data.message);
+ }
+ })
+ .catch(error => {
+ showErrorMessage(error);
+ });
}
\ No newline at end of file
diff --git a/service-worker.js b/service-worker.js
index 231b9a8d7..e2bdeb6b3 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -57,6 +57,7 @@ self.addEventListener('install', function (event) {
'scripts/i18n/getlang.js',
'scripts/libs/chart.js',
'scripts/libs/sortable.min.js',
+ 'scripts/libs/qrcode.min.js',
'images/icon/favicon.ico',
'images/icon/android-chrome-192x192.png',
'images/icon/apple-touch-icon-180',
diff --git a/settings.php b/settings.php
index b60458a83..33ae335a9 100644
--- a/settings.php
+++ b/settings.php
@@ -1,8 +1,14 @@
+