Compare commits

...
7 Commits
Author SHA1 Message Date
Thanakorn c6e5ab2841 Version theme CSS/JS URLs so browsers drop the cached CDN main.css 2026-09-24 15:38:10 +07:00
Thanakorn 9bb92bc20a Merge branch 'fix/security-baseline' 2026-09-24 14:56:20 +07:00
Thanakorn f11af6e949 Self-host front-end libraries, minimal sign-in header and CSP
- sign-in pages load only what they use (include_login_header.php, ajax_core.js)
- every library, font and data file served from assets/vendor with pinned
  versions (VERSIONS.json); no CDN at runtime
- Content-Security-Policy on app, sign-in and landing pages
- remove httpbin Dropzone target and source-map references
2026-09-24 14:53:41 +07:00
Thanakorn 8705be0d1b Enforce roles on admin endpoints and return real status codes
- users, SMTP and batch-lock endpoints are owner/admin only
- engines answer 400/403/404/409/500 instead of 200 with an error body;
  database errors no longer leak to the client
2026-09-24 14:53:40 +07:00
Thanakorn 73c680e844 Harden sign-in and password reset
- OTP attempt limits, constant-time compare, random reference codes
- DB-backed rate limits (429) on sign-in, OTP, reset, register, onboarding
- one generic sign-in failure message; reset request no longer reveals accounts
- no password kept in the session; real status codes on failures
2026-09-24 14:53:40 +07:00
Thanakorn ae98dcdcdd Harden web root, secrets and realtime auth
- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and
  app internals; security headers, HSTS over TLS, optional HTTPS redirect
- uploads served through app/file.php to signed-in users only
- Apache/PHP hardening config for the container (ServerTokens, expose_php)
- least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php;
  SMTP passwords re-encrypted with a random IV (secret_box.php)
- Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets
- escape notification text; CLI guards on build scripts; no fixed demo password
2026-09-24 14:53:40 +07:00
Thanakorn e579dd596c Start every session through session.php; idle timeout, app access and auth status codes 2026-09-24 14:30:35 +07:00
409 changed files with 5266 additions and 1010 deletions
+53
View File
@@ -0,0 +1,53 @@
# wms-app — web server rules for the repository root.
#
# The whole repository sits under the web root (/wms-app/), so everything that is
# not part of the running app must be refused here: git history, .env files,
# deployment and build folders, SDLC documents, the Node server source, CLI-only
# PHP scripts and library internals. Needs AllowOverride All (docker/php/apache-wms.conf
# enables it for the container) plus mod_rewrite and mod_headers.
Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
# HTTP → HTTPS when the deployment says TLS is available (FORCE_HTTPS=true in the
# environment). Honours X-Forwarded-Proto so it also works behind a TLS proxy.
RewriteCond %{ENV:FORCE_HTTPS} ^true$
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Dotfiles and dot-folders anywhere: .git, .env, .claude, .htaccess, .mcp.json …
RewriteRule (^|/)\. - [R=404,L]
# Folders that are never served.
RewriteRule ^(nodejs|docker|sdlc|sdlc-delivery|scripts|lib|notes|docs|vendor|node_modules)(/|$) - [R=404,L]
# Repository files at the root: build/deploy config, CLI scripts, archives, docs.
RewriteRule ^(composer\.(json|lock)|docker-compose\.ya?ml|setup\.php|demo_seed[^/]*\.php)$ - [R=404,L]
RewriteRule \.(zip|tar|gz|tgz|sql|sh|md|log|bak|old|orig|swp|dist|example|ini|yml|yaml|lock|env|pem|key|crt|map)$ - [R=404,L]
# App internals included by the entry points, never requested directly: config,
# DB connection, shared utilities, manager classes, bundled libraries (PHPMailer
# ships get_oauth_token.php), and the page fragments.
RewriteRule ^app/(config[^/]*\.php|dbconn\.php|preset\.php)$ - [R=404,L]
RewriteRule ^app/assets/utils/ - [R=404,L]
RewriteRule ^app/include_[^/]+\.php$ - [R=404,L]
# Uploaded files are served through a PHP gate that requires a signed-in session.
RewriteRule ^app/uploads/(.+)$ app/file.php?path=$1 [L,QSA,B]
</IfModule>
<IfModule mod_headers.c>
# Sent on every response (pages, API JSON, static files). Pages add a
# Content-Security-Policy of their own from include_header.php.
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), payment=(), usb=()"
Header always unset X-Powered-By
Header unset X-Powered-By
# HSTS only means anything over HTTPS; browsers ignore it on plain HTTP.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on' || %{HTTP:X-Forwarded-Proto} == 'https'"
</IfModule>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/CompanySettingManager.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+2 -2
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -179,7 +179,7 @@
<?php require '../include_ending.php'; ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/chart.js/4.5.1/chart.umd.min.js"></script>
<style>
/* Brief yellow flash when a card updates silently via WebSocket */
@keyframes card-flash {
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
require_once '../../../assets/utils/classes_ac/AccountFormulaManager.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
+12 -1
View File
@@ -1,8 +1,11 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/OperationLockManager.php';
// Batch GL posting takes this lock; posting itself is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
$result = $lock_manager->acquire(
@@ -10,7 +13,15 @@ try {
(int)($data['ttl_minutes'] ?? 120)
);
$answer = array_merge($answer, $result);
if (empty($result['success'])) {
http_response_code(409); // another tab or user holds the lock
}
} catch (PDOException $e) {
error_log('[acquire_op_lock] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -8,6 +8,7 @@ $doc_type = trim((string)($data['doc_type'] ?? ''));
$source_id = (int)($data['source_id'] ?? 0);
if (!$doc_type || $source_id <= 0) {
http_response_code(400);
$answer['message'] = 'doc_type and source_id required.';
exit(json_encode($answer));
}
@@ -27,8 +28,13 @@ try {
$answer['success'] = 1;
$answer['output'] = $detail;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -12,8 +12,13 @@ try {
(int)($data['formula_id'] ?? 0)
);
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -12,6 +12,7 @@ $to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ??
$dept_id = (int)($data['department_id'] ?? 0);
if ($account_code === '') {
http_response_code(400);
$answer['message'] = 'account_code is required.';
exit(json_encode($answer));
}
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -7,8 +7,13 @@ try {
$gl_query = new GlQueryManager($pdo2, $company_id);
$answer['output'] = $gl_query->getJournalDetail((int)($data['gl_id'] ?? 0));
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -11,8 +11,13 @@ try {
trim((string)($data['date_to'] ?? ''))
);
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,14 +1,22 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/BatchActionManager.php';
// Logged at the end of a batch GL posting run, which is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
$batch_action->log($data);
$answer['success'] = 1;
$answer['message'] = 'Batch action logged.';
} catch (PDOException $e) {
error_log('[log_batch_action] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+2 -1
View File
@@ -1,11 +1,12 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_role($user_role, ['owner', 'admin']);
if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) {
http_response_code(400);
$answer['message'] = 'Account code, name, and type are required';
exit(json_encode($answer));
}
@@ -1,11 +1,12 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
require_role($user_role, ['owner', 'admin']);
if (empty($data['dept_code']) || empty($data['dept_name'])) {
http_response_code(400);
$answer['message'] = 'Department code and name are required';
exit(json_encode($answer));
}
+8 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
@@ -30,6 +30,7 @@ $posting_map = [
];
if (!isset($posting_map[$doc_type]) || $id <= 0) {
http_response_code(400);
$answer['message'] = 'Invalid doc_type or id.';
exit(json_encode($answer));
}
@@ -73,9 +74,15 @@ try {
'has_expense' => $has_expense,
], $company_id);
} catch (PDOException $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+5 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/CompanySettingManager.php';
require_role($user_role, ['owner', 'admin']);
@@ -30,14 +30,17 @@ if ($data['action'] === 'save') {
$to = trim((string)($data['open_to'] ?? ''));
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
http_response_code(400);
$answer['message'] = 'Invalid open_from date. Use YYYY-MM-DD.';
exit(json_encode($answer));
}
if ($to !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
http_response_code(400);
$answer['message'] = 'Invalid open_to date. Use YYYY-MM-DD.';
exit(json_encode($answer));
}
if ($from && $to && $from > $to) {
http_response_code(400);
$answer['message'] = 'Open From must be on or before Open To.';
exit(json_encode($answer));
}
@@ -50,5 +53,6 @@ if ($data['action'] === 'save') {
exit(json_encode($answer));
}
http_response_code(400);
$answer['message'] = 'Invalid action.';
exit(json_encode($answer));
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/ProductManager.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
@@ -1,14 +1,22 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/OperationLockManager.php';
// Batch GL posting takes this lock; posting itself is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
$lock_manager->release(trim((string)($data['operation_type'] ?? '')));
$answer['success'] = 1;
$answer['message'] = 'Lock released.';
} catch (PDOException $e) {
error_log('[release_op_lock] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+2 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
@@ -7,6 +7,7 @@ require_role($user_role, ['owner', 'admin']);
$id = (int)($data['id'] ?? 0);
if (!$id) {
http_response_code(400);
$answer['message'] = 'Missing id';
exit(json_encode($answer));
}
@@ -1,12 +1,12 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
require_role($user_role, ['owner', 'admin']);
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$dept = new DepartmentManager($pdo2, $company_id);
$dept->delete($id);
@@ -1,10 +1,11 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
http_response_code(400);
$answer['message'] = 'Missing id';
exit(json_encode($answer));
}
@@ -12,6 +13,7 @@ if (!$id) {
$coa = new ChartOfAccounts($pdo2, $company_id);
$row = $coa->getById($id);
if (!$row) {
http_response_code(404);
$answer['message'] = 'Account not found';
exit(json_encode($answer));
}
@@ -1,14 +1,14 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$dept = new DepartmentManager($pdo2, $company_id);
$row = $dept->getById($id);
if (!$row) { $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
if (!$row) { http_response_code(404); $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
$answer['output'] = $row;
$answer['success'] = 1;
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
@@ -25,6 +25,7 @@ if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $journal_date, $m)) {
}
if (!$journal_date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $journal_date)) {
http_response_code(400);
$answer['message'] = 'Valid journal date is required.';
exit(json_encode($answer));
}
@@ -51,11 +52,13 @@ foreach ($lines_raw as $l) {
}
if (count($lines) < 2) {
http_response_code(400);
$answer['message'] = 'At least two journal lines are required.';
exit(json_encode($answer));
}
if (abs($total_debit - $total_credit) > 0.005) {
http_response_code(400);
$answer['message'] = 'Journal is not balanced. Debit ' . number_format($total_debit, 2) . ' ≠ Credit ' . number_format($total_credit, 2) . '.';
exit(json_encode($answer));
}
@@ -83,9 +86,15 @@ try {
$answer['success'] = 1;
$answer['gl_id'] = $gl_id;
notify_node('gl_posted', gl_posted_payload('manual', (int)$gl_id, $event_action, $lines), $company_id);
} catch (PDOException $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -221,9 +221,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -246,9 +246,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -204,9 +204,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -209,9 +209,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -252,9 +252,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap";@import"https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.35.0/tabler-icons.min.css";/*!
@charset "UTF-8";@import"../vendor/fonts/poppins/poppins.css";@import"../vendor/tabler-icons/3.35.0/tabler-icons.min.css";/*!
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+370
View File
@@ -0,0 +1,370 @@
/**
* ajax_core.js — helpers every page needs, including the sign-in pages:
* HTML escaping, form-data collection, the ajax_request() wrapper, the
* page-wide form-submit guard and live required-field validation.
*
* Loaded by include_header.php (before custom.js) and by the minimal
* login/include_login_header.php, so the sign-in pages no longer download
* custom.js with every feature's API URLs.
*/
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
// for anything written into markup but wrong inside a form field: a note saved
// as 5" pipe <spare> came back as 5&quot; pipe &lt;spare&gt;. Field values
// are never parsed as HTML, so decoding them here is safe.
function decode_html(value) {
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
name = name.toLowerCase();
if (name === 'quot') return '"';
if (name === 'lt') return '<';
if (name === 'gt') return '>';
if (name === 'amp') return '&';
return "'";
});
}
(function ($) {
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
var original_val = $.fn.val;
$.fn.val = function (value) {
if (arguments.length && typeof value === 'string') {
// Only free-text fields; a <select> value must keep matching its option.
var text_fields = this.filter('input, textarea');
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
}
return original_val.apply(this, arguments);
};
$.fn.val.__decodes_html = true;
})(window.jQuery);
/** =========================
* FORMS
* ========================= */
// Prevent all forms from refreshing the page
$(function () {
$("form").on("submit", function (e) {
e.preventDefault();
});
});
function prepare_form_data(check_required, raw_data) {
var q = {};
// Get session context
const session_element = document.getElementById('session-context');
if (session_element) {
q['company_id'] = session_element.dataset.companyId;
q['otp'] = session_element.dataset.otp;
}
// Include GET parameters
const url_params = new URLSearchParams(window.location.search);
url_params.forEach((value, key) => {
q[key] = value;
});
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
$(".form-control:not([type=search]), .form-select").each(function () {
if (!$(this).attr("id")) return true;
var el = $(this).get(0);
if (!el || !el.nodeName) return true;
q[$(this).attr("id")] = $(this).val();
});
// Validate required fields
if (check_required === 1) {
const required_inputs = document.querySelectorAll('[required]');
let is_valid = true;
required_inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-invalid');
is_valid = false;
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
});
if (!is_valid) {
alert("Please fill in all mandatory fields.");
isAjaxProcessing = false;
return false;
}
}
return (raw_data) ? q : JSON.stringify(q);
}
// server_url is set by include_topbar.php (app pages) and login/include_login_header.php.
function app_base_url() {
if (typeof server_url !== 'undefined' && server_url) return server_url;
return (document.body && document.body.dataset.serverUrl) || '/';
}
/** =========================
* AJAX WRAPPER
* ========================= */
// Prevent double firing
let isAjaxProcessing = false;
function ajax_request(options) {
if (isAjaxProcessing && options.queueLock !== false) {
// Instead of rejecting, we just return a "never-ending" promise
// or a resolved promise that does nothing.
console.warn("Request is busy... ignoring click.");
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
}
if (options.queueLock !== false) {
isAjaxProcessing = true;
}
// Auto prepare form data
if (options.autoPrepare === true) {
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
if (payloadJson === false) {
isAjaxProcessing = false;
return Promise.reject("validation_failed");
}
if (options.data) {
Object.entries(options.data).forEach(([key, value]) => {
payloadJson[key] = value;
});
}
if (options.action) {
// modify action
if (options.action === 'manage') {
options.action = (payloadJson['id']) ? 'update' : 'create';
}
// add action to JSON
payloadJson['action'] = options.action;
} else {
isAjaxProcessing = false;
return Promise.reject("please_define_action");
}
options.data = { json: JSON.stringify(payloadJson) };
if (options.debugMode) {
isAjaxProcessing = false;
// Show FormData contents if applicable
if (options.formData instanceof FormData) {
// Log original formData before merging
for (let [key, value] of options.formData.entries()) {
console.log("FORMDATA: " + key, value);
}
}
// Show stringified JSON payload
console.log("REQUEST DATA:", options.data);
}
// IF formData exist, we pass as $_POST [not json]
if (options.formData instanceof FormData) {
// THE BYPASS: If formData exists, move all text data into it
Object.entries(payloadJson).forEach(([key, value]) => {
options.formData.append(key, value);
});
// Override options.data with the full FormData object
options.data = options.formData;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- START MODIFIED $.AJAX BLOCK ---
let isSendingFiles = (options.data instanceof FormData);
// Show loading overlay
if (options.noLoading !== true) {
$.LoadingOverlay("show", {
imageColor: "#525252",
imageAnimation: "2s rotate_right",
background: "rgba(255,255,255,0.8)"
});
}
return $.ajax({
async: true,
type: options.type || "POST",
url: options.url,
data: options.data,
dataType: "json",
// These two settings are only triggered when sending files
processData: isSendingFiles ? false : true,
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
// for CSRF validation
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
})
.then(function (res) {
isAjaxProcessing = false;
if (options.noLoading !== true) $.LoadingOverlay("hide");
if (options.debugMode) {
console.log("RESPONSE:", res);
return res;
}
if (!res || res.success != 1) {
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
options.onError?.(null, res?.message || 'api_failed');
throw new Error(res?.message || "api_failed");
}
options.onSuccess?.(res);
return res;
})
.catch(function (xhr) {
isAjaxProcessing = false;
$.LoadingOverlay("hide");
// Errors re-thrown from .then() — pass through
if (xhr instanceof Error) {
throw xhr;
}
// Session displaced — another login took over this account
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
window.location.href = app_base_url() + 'index.php';
});
return;
}
// Session ended on the server (idle timeout, not signed in, password changed):
// drop per-tab data and go back to the sign-in form.
const endedCodes = ['session_expired', 'auth_required', 'password_changed'];
if (xhr?.status === 401 && endedCodes.includes(xhr?.responseJSON?.code)) {
try { sessionStorage.clear(); } catch (e) {}
bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() {
window.location.href = app_base_url() + 'login/index.php';
});
return;
}
// File / payload too large (nginx 413)
if (xhr?.status === 413) {
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
options.onError?.(xhr, 'payload_too_large');
throw xhr;
}
// Usage limit reached — show upgrade notice instead of generic error
if (xhr?.status === 402) {
const d = xhr?.responseJSON ?? {};
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
const detail = [daily, weekly].filter(Boolean).join('&nbsp;&nbsp;|&nbsp;&nbsp;');
bootbox.alert(
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
);
options.onError?.(xhr, 'limit_reached');
throw xhr;
}
// Extract server's error message from JSON response
let serverMessage = xhr?.responseJSON?.message;
// Fallback: parse responseText if responseJSON isn't set
if (!serverMessage && xhr?.responseText) {
try {
serverMessage = JSON.parse(xhr.responseText)?.message;
} catch (e) {
// Response wasn't JSON — real server crash or HTML error page
}
}
if (serverMessage) {
if (options.noAlert !== true) bootbox.alert(serverMessage);
options.onError?.(xhr, serverMessage);
} else {
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
options.onError?.(xhr, null);
}
throw xhr;
});
}
/** =========================
* REAL-TIME REQUIRED VALIDATION
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
const required_inputs = document.querySelectorAll('[required]');
required_inputs.forEach(input => {
input.addEventListener('input', function () {
if (this.value.trim() !== "") {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
});
});
-331
View File
@@ -1,38 +1,3 @@
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
// for anything written into markup but wrong inside a form field: a note saved
// as 5" pipe <spare> came back as 5&quot; pipe &lt;spare&gt;. Field values
// are never parsed as HTML, so decoding them here is safe.
function decode_html(value) {
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
name = name.toLowerCase();
if (name === 'quot') return '"';
if (name === 'lt') return '<';
if (name === 'gt') return '>';
if (name === 'amp') return '&';
return "'";
});
}
(function ($) {
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
var original_val = $.fn.val;
$.fn.val = function (value) {
if (arguments.length && typeof value === 'string') {
// Only free-text fields; a <select> value must keep matching its option.
var text_fields = this.filter('input, textarea');
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
}
return original_val.apply(this, arguments);
};
$.fn.val.__decodes_html = true;
})(window.jQuery);
/** =========================
* SIDEBAR ACTIVE STATE
@@ -93,10 +58,6 @@ function debounce(fn, wait) {
* ========================= */
$(function () {
// Prevent all forms from refreshing the page
$("form").on("submit", function (e) {
e.preventDefault();
});
// Initialize autocomplete for product search inputs
init_product_search_inputs();
@@ -678,278 +639,6 @@ function populate_dept_filter(select_id) {
});
}
function prepare_form_data(check_required, raw_data) {
var q = {};
// Get session context
const session_element = document.getElementById('session-context');
if (session_element) {
q['company_id'] = session_element.dataset.companyId;
q['otp'] = session_element.dataset.otp;
}
// Include GET parameters
const url_params = new URLSearchParams(window.location.search);
url_params.forEach((value, key) => {
q[key] = value;
});
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
$(".form-control:not([type=search]), .form-select").each(function () {
if (!$(this).attr("id")) return true;
var el = $(this).get(0);
if (!el || !el.nodeName) return true;
q[$(this).attr("id")] = $(this).val();
});
// Validate required fields
if (check_required === 1) {
const required_inputs = document.querySelectorAll('[required]');
let is_valid = true;
required_inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-invalid');
is_valid = false;
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
});
if (!is_valid) {
alert("Please fill in all mandatory fields.");
isAjaxProcessing = false;
return false;
}
}
return (raw_data) ? q : JSON.stringify(q);
}
/** =========================
* AJAX WRAPPER
* ========================= */
// Prevent double firing
let isAjaxProcessing = false;
function ajax_request(options) {
if (isAjaxProcessing && options.queueLock !== false) {
// Instead of rejecting, we just return a "never-ending" promise
// or a resolved promise that does nothing.
console.warn("Request is busy... ignoring click.");
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
}
if (options.queueLock !== false) {
isAjaxProcessing = true;
}
// Auto prepare form data
if (options.autoPrepare === true) {
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
if (payloadJson === false) {
isAjaxProcessing = false;
return Promise.reject("validation_failed");
}
if (options.data) {
Object.entries(options.data).forEach(([key, value]) => {
payloadJson[key] = value;
});
}
if (options.action) {
// modify action
if (options.action === 'manage') {
options.action = (payloadJson['id']) ? 'update' : 'create';
}
// add action to JSON
payloadJson['action'] = options.action;
} else {
isAjaxProcessing = false;
return Promise.reject("please_define_action");
}
options.data = { json: JSON.stringify(payloadJson) };
if (options.debugMode) {
isAjaxProcessing = false;
// Show FormData contents if applicable
if (options.formData instanceof FormData) {
// Log original formData before merging
for (let [key, value] of options.formData.entries()) {
console.log("FORMDATA: " + key, value);
}
}
// Show stringified JSON payload
console.log("REQUEST DATA:", options.data);
}
// IF formData exist, we pass as $_POST [not json]
if (options.formData instanceof FormData) {
// THE BYPASS: If formData exists, move all text data into it
Object.entries(payloadJson).forEach(([key, value]) => {
options.formData.append(key, value);
});
// Override options.data with the full FormData object
options.data = options.formData;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- START MODIFIED $.AJAX BLOCK ---
let isSendingFiles = (options.data instanceof FormData);
// Show loading overlay
if (options.noLoading !== true) {
$.LoadingOverlay("show", {
imageColor: "#525252",
imageAnimation: "2s rotate_right",
background: "rgba(255,255,255,0.8)"
});
}
return $.ajax({
async: true,
type: options.type || "POST",
url: options.url,
data: options.data,
dataType: "json",
// These two settings are only triggered when sending files
processData: isSendingFiles ? false : true,
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
// for CSRF validation
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
})
.then(function (res) {
isAjaxProcessing = false;
if (options.noLoading !== true) $.LoadingOverlay("hide");
if (options.debugMode) {
console.log("RESPONSE:", res);
return res;
}
if (!res || res.success != 1) {
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
options.onError?.(null, res?.message || 'api_failed');
throw new Error(res?.message || "api_failed");
}
options.onSuccess?.(res);
return res;
})
.catch(function (xhr) {
isAjaxProcessing = false;
$.LoadingOverlay("hide");
// Errors re-thrown from .then() — pass through
if (xhr instanceof Error) {
throw xhr;
}
// Session displaced — another login took over this account
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
window.location.href = server_url + 'index.php';
});
return;
}
// File / payload too large (nginx 413)
if (xhr?.status === 413) {
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
options.onError?.(xhr, 'payload_too_large');
throw xhr;
}
// Usage limit reached — show upgrade notice instead of generic error
if (xhr?.status === 402) {
const d = xhr?.responseJSON ?? {};
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
const detail = [daily, weekly].filter(Boolean).join('&nbsp;&nbsp;|&nbsp;&nbsp;');
bootbox.alert(
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
);
options.onError?.(xhr, 'limit_reached');
throw xhr;
}
// Extract server's error message from JSON response
let serverMessage = xhr?.responseJSON?.message;
// Fallback: parse responseText if responseJSON isn't set
if (!serverMessage && xhr?.responseText) {
try {
serverMessage = JSON.parse(xhr.responseText)?.message;
} catch (e) {
// Response wasn't JSON — real server crash or HTML error page
}
}
if (serverMessage) {
if (options.noAlert !== true) bootbox.alert(serverMessage);
options.onError?.(xhr, serverMessage);
} else {
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
options.onError?.(xhr, null);
}
throw xhr;
});
}
/** =========================
@@ -1046,26 +735,6 @@ flatpickr(".flatpickr", {
});
/** =========================
* REAL-TIME REQUIRED VALIDATION
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
const required_inputs = document.querySelectorAll('[required]');
required_inputs.forEach(input => {
input.addEventListener('input', function () {
if (this.value.trim() !== "") {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
});
});
/**
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* app_access.php — which app (WMS / Accounting) a script belongs to, and whether
* the signed-in user's app_access allows it.
*
* app_access used to only choose which menus the topbar drew; a WMS-only user
* could still open the accounting pages and call their APIs directly. db_auth.php
* (API engines) and include_topbar.php (pages) now both enforce it through here.
*/
// Accounting endpoints the WMS screens also call (master-data lookups, the
// batch operation lock, and the GL panel on purchase invoices).
const APP_ACCESS_SHARED_ACCOUNTING = [
'accounting/api/engine/account.php',
'accounting/api/engine/account_formula.php',
'accounting/api/engine/department.php',
'accounting/api/engine/acquire_op_lock.php',
'accounting/api/engine/release_op_lock.php',
'accounting/api/engine/get_gl_by_source.php',
];
/** The app a script under app/ belongs to: 'accounting', or null for WMS/shared. */
function app_access_app_for(string $script_name): ?string {
$path = str_replace('\\', '/', $script_name);
$pos = strpos($path, '/app/');
if ($pos === false) return null;
$rel = substr($path, $pos + 5);
if (in_array($rel, APP_ACCESS_SHARED_ACCOUNTING, true)) return null;
if (preg_match('#^(accounting|ac_dashboard|revenue|expense|finance|journal)/#', $rel)) return 'accounting';
return null;
}
/** Whether an app_access value ('wms', 'accounting', 'all') includes $app. */
function app_access_allows(string $access, string $app): bool {
return $access === 'all' || $access === $app;
}
@@ -14,20 +14,25 @@
*
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
* A human-readable reference number (6 uppercase letters) is also generated and emailed
* so the user can confirm they received the correct OTP request.
* A random reference number (6 uppercase letters) is also generated and emailed so the
* user can confirm they received the correct OTP request. It is not derived from the
* OTP: a derived reference let anyone who saw it recover the OTP offline.
*
* HTTP handler methods for thin AJAX endpoint wrappers:
* handleRequestOtp($user_id, $company_id)
* handleRequestOtp($user_id, $company_id) — signed-in profile page
* handleRequestOtpPublic($user_id, $company_id) — login page; same answer whether
* or not the account exists
* handleConfirmReset($user_id, $data)
*
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
* reset_otp, reset_otp_time, reset_reference, reset_user_id
* reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts
*
* Security:
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
* - At most OTP_MAX_ATTEMPTS wrong entries per issued OTP, then it is discarded.
* - OTPs are compared with hash_equals().
* - Session is fully destroyed on successful reset, forcing re-authentication.
* - All DB queries use PDO prepared statements with bound parameters.
* - AJAX handler methods output JSON via json_encode (XSS-safe).
@@ -43,6 +48,12 @@ class PasswordResetManager {
/** OTP validity window in minutes — matches the login OTP window. */
const OTP_EXPIRY_MINUTES = 5;
/** Wrong OTP entries allowed per issued OTP before it is discarded. */
const OTP_MAX_ATTEMPTS = 5;
/** Answer shown on the login page whether or not the account exists. */
const PUBLIC_REQUEST_MESSAGE = "If an account matches, we've sent an OTP to its email.";
/**
* @param PDO $pdo1 PDO connection to the wms database (user table).
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
@@ -94,10 +105,10 @@ class PasswordResetManager {
throw new \RuntimeException('No email address found for this account.');
}
// Generate 6-digit TOTP and a human-readable 6-letter reference number
// Generate 6-digit TOTP and a random 6-letter reference number
$otp_time = time();
$otp = $this->generateOTP($user['password'], $otp_time);
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
$reference_number = $this->randomReference();
// Send via the mailer module (uses company SMTP or falls back to system default)
require_once $this->include_url . '/assets/utils/module/mailer.php';
@@ -124,6 +135,7 @@ class PasswordResetManager {
$_SESSION['reset_otp_time'] = $otp_time;
$_SESSION['reset_reference'] = $reference_number;
$_SESSION['reset_user_id'] = $user_id;
$_SESSION['reset_attempts'] = 0;
return [
'masked_email' => $this->maskEmail($user['email']),
@@ -131,6 +143,24 @@ class PasswordResetManager {
];
}
/**
* Start a reset that can never succeed, for a login-page request whose
* username/email matches no account. The session then looks exactly like a
* real request (random unguessable OTP, reset_user_id 0), so the confirm step
* answers "Incorrect OTP" instead of revealing that the account is missing.
*
* @return string Random 6-letter reference, same shape as a real one.
*/
public function startDecoy(): string {
$reference = $this->randomReference();
$_SESSION['reset_otp'] = bin2hex(random_bytes(16));
$_SESSION['reset_otp_time'] = time();
$_SESSION['reset_reference'] = $reference;
$_SESSION['reset_user_id'] = 0;
$_SESSION['reset_attempts'] = 0;
return $reference;
}
/**
* Verify the OTP and force-set a new password via PasswordManager.
*
@@ -170,8 +200,14 @@ class PasswordResetManager {
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
}
// Verify OTP value
if (trim($otp_input) !== $_SESSION['reset_otp']) {
// Verify OTP value — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP,
// so the 6-digit code cannot be brute-forced inside its 5-minute window.
if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) {
$_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1;
if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) {
$this->clearSession();
throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.');
}
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
}
@@ -234,6 +270,39 @@ class PasswordResetManager {
exit;
}
/**
* Handle the login-page request-OTP call. The answer is the same whether or
* not the username/email matches an account (no account enumeration): no
* masked email, a generic message and a reference number. Mail failures are
* logged, not reported, for the same reason.
*
* On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" }
*
* @param int|null $user_id Resolved account, or null when nothing matched.
* @param int $company_id Company SMTP scope (0 = use system default).
*/
public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void {
$reference = null;
if ($user_id) {
try {
$reference = $this->requestOtp($user_id, $company_id)['reference'];
} catch (\Exception $e) {
error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage());
}
}
if ($reference === null) {
$reference = $this->startDecoy();
}
echo json_encode([
'success' => 1,
'message' => self::PUBLIC_REQUEST_MESSAGE,
'reference' => $reference,
]);
exit;
}
/**
* Handle an AJAX confirm-reset call and echo a JSON response.
*
@@ -312,23 +381,17 @@ class PasswordResetManager {
}
/**
* Convert a positive integer into a base-26 uppercase letter string.
* Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email
* and the confirmation screen. Carries no information about the OTP.
*
* Used to turn the numeric reference OTP into a human-friendly 6-letter
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
* The result is left-padded with 'A' to always return a 6-character string.
*
* @param int $num Positive integer to convert.
* @return string 6-character uppercase string (e.g. "AAAABC").
* @return string 6-character uppercase string.
*/
private function numberToLetters(int $num): string {
private function randomReference(): string {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
for ($i = 0; $i < 6; $i++) {
$result .= chr(65 + random_int(0, 25));
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
return $result;
}
/**
@@ -356,14 +419,15 @@ class PasswordResetManager {
*
* Called on OTP expiry (to invalidate the request) and on successful
* reset (before session_destroy). Does not destroy the full session —
* only the 4 reset-specific keys are unset.
* only the reset-specific keys are unset.
*/
private function clearSession(): void {
unset(
$_SESSION['reset_otp'],
$_SESSION['reset_otp_time'],
$_SESSION['reset_reference'],
$_SESSION['reset_user_id']
$_SESSION['reset_user_id'],
$_SESSION['reset_attempts']
);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/../module/mailer.php';
require_once __DIR__ . '/../secret_box.php';
class SmtpManager
{
@@ -173,7 +174,7 @@ class SmtpManager
private function encryptPassword(string $plain): string
{
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
return secret_encrypt($plain, $this->pinkey);
}
}
?>
+3 -2
View File
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../dbconn.php';
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
// An empty configured secret must never match an empty header.
$secret = (string)($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
if (!defined('NODE_EMIT_SECRET') || NODE_EMIT_SECRET === '' || !hash_equals((string)NODE_EMIT_SECRET, $secret)) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
}
+30 -4
View File
@@ -38,6 +38,7 @@ set_exception_handler(function (Throwable $e) {
require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
require_once __DIR__."/db_helpers.php";
require_once __DIR__."/app_access.php";
if (!function_exists('require_role')) {
function require_role(string $user_role, array $allowed): void {
@@ -53,14 +54,14 @@ if(!empty($_SESSION["login_company_id"])){
// CSRF Validation — add right at the top of the logged-in block
if($_SERVER['REQUEST_METHOD'] === 'POST'){
$csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if(empty($csrf_token) || $csrf_token !== $_SESSION['csrf_token']){
if(empty($csrf_token) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf_token)){
http_response_code(403);
exit(json_encode(["message" => "Invalid request"]));
}
}
// validate otp
$sql = "SELECT `password`
$sql = "SELECT `password`, license, app_access
FROM user
WHERE user_id = :company_id";
$sth = $pdo1->prepare($sql);
@@ -68,7 +69,8 @@ if(!empty($_SESSION["login_company_id"])){
":company_id" => $_SESSION["login_user_id"]
]);
db_check($sth, $answer);
$password = $sth->fetchColumn();
$user_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$password = $user_row['password'] ?? '';
/** Generate OTP */
function generateOTP($sercet_key, $time_step = 180, $length = 6){
$counter = floor($_SESSION["otpTime"] / $time_step);
@@ -83,7 +85,9 @@ if(!empty($_SESSION["login_company_id"])){
$otp = generateOTP($password);
if( $_SESSION["otp"]!=$otp ){
http_response_code(401);
$answer["message"] = "Your password has been reset, Please logout and login again.";
$answer["code"] = "password_changed";
exit(json_encode($answer));
}
@@ -98,13 +102,29 @@ if(!empty($_SESSION["login_company_id"])){
$map = $sth->fetchAll(PDO::FETCH_ASSOC);
if( count($map)==0 ){
http_response_code(403);
$answer["message"] = "Your accessibility to this company has been removed.";
$answer["code"] = "access_removed";
exit(json_encode($answer));
}
$user_role = $map[0]['role'] ?? 'viewer';
$_SESSION['login_role'] = $user_role;
// App access (WMS / Accounting), re-read on every request so a change made in
// Setting → Users applies at once. Owners hold it on their own user row;
// invited users per company (same rule as login_confirm.php).
$app_access = (($user_row['license'] ?? 'owner') === 'owner')
? ($user_row['app_access'] ?? 'wms')
: ($map[0]['app_access'] ?? 'wms');
$_SESSION['login_app_access'] = $app_access;
$required_app = app_access_app_for($_SERVER['SCRIPT_NAME'] ?? '');
if ($required_app !== null && !app_access_allows($app_access, $required_app)) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Your account does not have access to this module.']));
}
// Single-session enforcement: if a session_token was issued at login, verify
// it still matches the DB. A mismatch means a newer login has taken over.
if (!empty($_SESSION['session_token'])) {
@@ -132,7 +152,12 @@ if(!empty($_SESSION["login_company_id"])){
// unless the engine explicitly declared itself a pre-auth route.
if (empty($_SESSION['login_company_id']) && !defined('UNAUTHENTICATED_ROUTE')) {
http_response_code(401);
exit(json_encode(['success' => 0, 'message' => 'Authentication required.']));
$expired = !empty($_SESSION['_idle_expired']);
exit(json_encode([
'success' => 0,
'message' => $expired ? 'Your session has expired. Please sign in again.' : 'Authentication required.',
'code' => $expired ? 'session_expired' : 'auth_required',
]));
}
// set up ANSWER
@@ -153,6 +178,7 @@ if (!is_array($data)) {
$data = $_POST;
} else {
// Truly no data received
http_response_code(400);
$answer["message"] = "Request denied: No valid JSON payload or Form Data detected.";
exit(json_encode($answer));
}
+2 -1
View File
@@ -72,7 +72,8 @@ class mailer{
"input" => $input
]);
return openssl_decrypt(trim($input["data"]), "AES-256-CBC", $input["key"], 0, "1234567890123456" );
require_once __DIR__ . '/../secret_box.php';
return secret_decrypt((string)$input["data"], (string)$input["key"]);
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* page_headers.php — security headers for HTML pages (app pages, sign-in pages).
*
* Call send_page_security_headers() before any output. The Content-Security-Policy
* lists what the pages actually load:
* - scripts, styles, fonts and data files are all self-hosted under assets/vendor/
* (versions in assets/vendor/VERSIONS.json), so no CDN host is allowed;
* - the Node.js real-time server (NODE_PUBLIC_URL) serves socket.io.js and the
* WebSocket connection;
* - 'unsafe-inline' because pages use inline <script> blocks and onclick=
* handlers; 'unsafe-eval' because alasql compiles its queries with new Function.
*/
if (!function_exists('send_page_security_headers')) {
function send_page_security_headers(): void {
if (headers_sent()) return;
$script = ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
$connect = ["'self'"];
if (defined('NODE_PUBLIC_URL')) {
$node = parse_url(NODE_PUBLIC_URL);
if (!empty($node['scheme']) && !empty($node['host'])) {
$origin = $node['host'] . (isset($node['port']) ? ':' . $node['port'] : '');
$secure = strtolower($node['scheme']) === 'https';
$script[] = ($secure ? 'https://' : 'http://') . $origin;
$connect[] = ($secure ? 'https://' : 'http://') . $origin;
$connect[] = ($secure ? 'wss://' : 'ws://') . $origin;
}
}
$csp = implode('; ', [
"default-src 'self'",
'script-src ' . implode(' ', $script),
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob:",
"media-src 'self' blob:",
'connect-src ' . implode(' ', $connect),
"worker-src 'self' blob:",
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
]);
header('Content-Security-Policy: ' . $csp, true);
header('X-Content-Type-Options: nosniff', true);
header('X-Frame-Options: SAMEORIGIN', true);
header('Referrer-Policy: strict-origin-when-cross-origin', true);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* rate_limit.php — DB-backed request throttle for the unauthenticated login,
* OTP and registration endpoints.
*
* Counters live in wms.auth_throttle (created by setup.php), not in the PHP
* session: an attacker simply drops the session cookie to reset a session
* counter. Each (bucket, key) pair counts hits in a fixed window; keys are
* stored as SHA-256 hashes so the table never holds raw IPs or emails.
*
* The helper fails open: if the table is missing or the query fails, the error
* is logged and the request is allowed, so a schema problem can never lock
* every user out of the login page.
*
* Usage:
* require_once '../../../assets/utils/rate_limit.php';
* rate_limit_guard($pdo1, [
* ['login_ip', rate_limit_client_ip(), 20, 900],
* ['login_user', $username, 10, 900],
* ]);
*/
if (!function_exists('rate_limit_client_ip')) {
/**
* The client address as Apache sees it. X-Forwarded-For is deliberately not
* trusted here: any client can send it, which would let them pick a fresh
* key for every request.
*/
function rate_limit_client_ip(): string {
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
}
}
if (!function_exists('rate_limit_hit')) {
/**
* Count one hit for ($bucket, $key) and report whether the limit is exceeded.
*
* @param PDO $pdo Connection to the wms (auth) database.
* @param string $bucket Endpoint/purpose name, e.g. 'login_ip'.
* @param string $key Raw key (IP, normalised username/email, user id).
* @param int $max Hits allowed per window.
* @param int $window_seconds Window length in seconds.
* @return bool true when this hit is over the limit.
*/
function rate_limit_hit(PDO $pdo, string $bucket, string $key, int $max, int $window_seconds): bool {
if ($key === '') return false;
$key_hash = hash('sha256', $bucket . '|' . $key);
try {
// One statement per hit: a new row starts at 1; an existing row either
// restarts its window (expired) or counts up. MySQL applies the SET
// list left to right, so `hits` still sees the old window_start.
$pdo->prepare(
"INSERT INTO auth_throttle (bucket, key_hash, window_start, hits)
VALUES (:b, :k, NOW(), 1)
ON DUPLICATE KEY UPDATE
hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1),
window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)"
)->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]);
$sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k");
$sth->execute([':b' => $bucket, ':k' => $key_hash]);
return (int)$sth->fetchColumn() > $max;
} catch (Throwable $e) {
error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage());
return false;
}
}
}
if (!function_exists('rate_limit_guard')) {
/**
* Count every check and stop the request with HTTP 429 if any is over its
* limit. Each check is [bucket, key, max, window_seconds].
*/
function rate_limit_guard(PDO $pdo, array $checks): void {
$limited = false;
foreach ($checks as [$bucket, $key, $max, $window]) {
if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) {
$limited = true;
}
}
if ($limited) {
rate_limit_reject();
}
}
}
if (!function_exists('rate_limit_reject')) {
/** Answer 429 with the same generic message everywhere and stop. */
function rate_limit_reject(): void {
http_response_code(429);
header('Retry-After: 300');
exit(json_encode([
'success' => 0,
'message' => 'Too many requests. Please wait a few minutes and try again.',
'code' => 'rate_limited',
]));
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* secret_box.php — reversible encryption for stored credentials (SMTP passwords).
*
* Format "v2:<base64(iv . ciphertext)>": AES-256-CBC with a random IV per value and
* a key derived from APP_SECRET_KEY (config.php, from the deployment environment).
*
* Older values were encrypted with the fixed key $pinkey ("wms") and a constant IV,
* which anyone reading the source can undo. secret_decrypt() still reads that legacy
* format so existing rows keep working; setup.php re-encrypts them to v2 and every
* save writes v2. Without APP_SECRET_KEY the legacy format is written (and logged)
* so a deployment that has not set the key yet keeps sending mail.
*/
const SECRET_BOX_LEGACY_IV = '1234567890123456';
function secret_box_key(): ?string {
if (!defined('APP_SECRET_KEY') || APP_SECRET_KEY === '') return null;
return hash('sha256', APP_SECRET_KEY, true);
}
function secret_encrypt(string $plain, string $legacy_key = 'wms'): string {
$key = secret_box_key();
if ($key === null) {
error_log('[secret_box] APP_SECRET_KEY is not set; storing a credential in the legacy format.');
return openssl_encrypt($plain, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
}
$iv = random_bytes(16);
$ct = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return 'v2:' . base64_encode($iv . $ct);
}
/** Returns the plain text, or false when the value cannot be decrypted. */
function secret_decrypt(string $stored, string $legacy_key = 'wms') {
$stored = trim($stored);
if (strncmp($stored, 'v2:', 3) === 0) {
$key = secret_box_key();
$raw = base64_decode(substr($stored, 3), true);
if ($key === null || $raw === false || strlen($raw) <= 16) return false;
return openssl_decrypt(substr($raw, 16), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16));
}
return openssl_decrypt($stored, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
}
/** Whether a stored value still uses the legacy fixed-key format. */
function secret_is_legacy(string $stored): bool {
return $stored !== '' && strncmp(trim($stored), 'v2:', 3) !== 0;
}
+35
View File
@@ -0,0 +1,35 @@
{
"resolved": {
"apexcharts": "7.5.1",
"flatpickr": "4.6.13",
"dropzone": "5.9.3",
"chart.js": "4.5.1",
"world_countries_lists": "3.3.0"
},
"files": {
"jquery/3.7.1/jquery.min.js": "https://code.jquery.com/jquery-3.7.1.min.js",
"popper/2.11.8/popper.min.js": "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js",
"bootstrap/5.3.8/bootstrap.min.js": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.min.js",
"bootstrap/5.3.8/bootstrap.min.css": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css",
"bootbox/4.4.0/bootbox.min.js": "https://cdnjs.cloudflare.com/ajax/libs/bootbox.js/4.4.0/bootbox.min.js",
"loadingoverlay/2.1.7/loadingoverlay.min.js": "https://cdn.jsdelivr.net/npm/gasparesganga-jquery-loading-overlay@2.1.7/dist/loadingoverlay.min.js",
"flatpickr/4.6.13/flatpickr.min.js": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js",
"flatpickr/4.6.13/flatpickr.min.css": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css",
"flatpickr/4.6.13/plugins/monthSelect/index.js": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/index.js",
"flatpickr/4.6.13/plugins/monthSelect/style.css": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/style.css",
"jquery-ui/1.14.1/jquery-ui.min.js": "https://code.jquery.com/ui/1.14.1/jquery-ui.min.js",
"jquery-ui/1.14.1/jquery-ui.css": "https://code.jquery.com/ui/1.14.1/themes/base/jquery-ui.css",
"alasql/4.6.6/alasql.min.js": "https://cdnjs.cloudflare.com/ajax/libs/alasql/4.6.6/alasql.min.js",
"dropzone/5.9.3/dropzone.min.js": "https://cdn.jsdelivr.net/npm/dropzone@5.9.3/dist/min/dropzone.min.js",
"apexcharts/7.5.1/apexcharts.min.js": "https://cdn.jsdelivr.net/npm/apexcharts@7.5.1/dist/apexcharts.min.js",
"html5-qrcode/2.3.8/html5-qrcode.min.js": "https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js",
"zxcvbn/4.4.2/zxcvbn.js": "https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js",
"jsbarcode/3.11.6/JsBarcode.all.min.js": "https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js",
"xlsx/0.18.5/xlsx.full.min.js": "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
"jspdf/2.5.1/jspdf.umd.min.js": "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js",
"jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js": "https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js",
"chart.js/4.5.1/chart.umd.min.js": "https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js",
"tabler-icons/3.35.0/tabler-icons.min.css": "https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.35.0/tabler-icons.min.css",
"world_countries_lists/3.3.0/countries.json": "https://cdn.jsdelivr.net/npm/world_countries_lists@3.3.0/data/countries/en/countries.json"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.monthSelectPlugin = factory());
}(this, (function () { 'use strict';
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
var __assign = function() {
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var monthToStr = function (monthNumber, shorthand, locale) { return locale.months[shorthand ? "shorthand" : "longhand"][monthNumber]; };
function clearNode(node) {
while (node.firstChild)
node.removeChild(node.firstChild);
}
function getEventTarget(event) {
try {
if (typeof event.composedPath === "function") {
var path = event.composedPath();
return path[0];
}
return event.target;
}
catch (error) {
return event.target;
}
}
var defaultConfig = {
shorthand: false,
dateFormat: "F Y",
altFormat: "F Y",
theme: "light",
};
function monthSelectPlugin(pluginConfig) {
var config = __assign(__assign({}, defaultConfig), pluginConfig);
return function (fp) {
fp.config.dateFormat = config.dateFormat;
fp.config.altFormat = config.altFormat;
var self = { monthsContainer: null };
function clearUnnecessaryDOMElements() {
if (!fp.rContainer)
return;
clearNode(fp.rContainer);
for (var index = 0; index < fp.monthElements.length; index++) {
var element = fp.monthElements[index];
if (!element.parentNode)
continue;
element.parentNode.removeChild(element);
}
}
function build() {
if (!fp.rContainer)
return;
self.monthsContainer = fp._createElement("div", "flatpickr-monthSelect-months");
self.monthsContainer.tabIndex = -1;
buildMonths();
fp.rContainer.appendChild(self.monthsContainer);
fp.calendarContainer.classList.add("flatpickr-monthSelect-theme-" + config.theme);
}
function buildMonths() {
if (!self.monthsContainer)
return;
clearNode(self.monthsContainer);
var frag = document.createDocumentFragment();
for (var i = 0; i < 12; i++) {
var month = fp.createDay("flatpickr-monthSelect-month", new Date(fp.currentYear, i), 0, i);
if (month.dateObj.getMonth() === new Date().getMonth() &&
month.dateObj.getFullYear() === new Date().getFullYear())
month.classList.add("today");
month.textContent = monthToStr(i, config.shorthand, fp.l10n);
month.addEventListener("click", selectMonth);
frag.appendChild(month);
}
self.monthsContainer.appendChild(frag);
if (fp.config.minDate &&
fp.currentYear === fp.config.minDate.getFullYear())
fp.prevMonthNav.classList.add("flatpickr-disabled");
else
fp.prevMonthNav.classList.remove("flatpickr-disabled");
if (fp.config.maxDate &&
fp.currentYear === fp.config.maxDate.getFullYear())
fp.nextMonthNav.classList.add("flatpickr-disabled");
else
fp.nextMonthNav.classList.remove("flatpickr-disabled");
}
function bindEvents() {
fp._bind(fp.prevMonthNav, "click", function (e) {
e.preventDefault();
e.stopPropagation();
fp.changeYear(fp.currentYear - 1);
selectYear();
buildMonths();
});
fp._bind(fp.nextMonthNav, "click", function (e) {
e.preventDefault();
e.stopPropagation();
fp.changeYear(fp.currentYear + 1);
selectYear();
buildMonths();
});
fp._bind(self.monthsContainer, "mouseover", function (e) {
if (fp.config.mode === "range")
fp.onMouseOver(getEventTarget(e), "flatpickr-monthSelect-month");
});
}
function setCurrentlySelected() {
if (!fp.rContainer)
return;
if (!fp.selectedDates.length)
return;
var currentlySelected = fp.rContainer.querySelectorAll(".flatpickr-monthSelect-month.selected");
for (var index = 0; index < currentlySelected.length; index++) {
currentlySelected[index].classList.remove("selected");
}
var targetMonth = fp.selectedDates[0].getMonth();
var month = fp.rContainer.querySelector(".flatpickr-monthSelect-month:nth-child(" + (targetMonth + 1) + ")");
if (month) {
month.classList.add("selected");
}
}
function selectYear() {
var selectedDate = fp.selectedDates[0];
if (selectedDate) {
selectedDate = new Date(selectedDate);
selectedDate.setFullYear(fp.currentYear);
if (fp.config.minDate && selectedDate < fp.config.minDate) {
selectedDate = fp.config.minDate;
}
if (fp.config.maxDate && selectedDate > fp.config.maxDate) {
selectedDate = fp.config.maxDate;
}
fp.currentYear = selectedDate.getFullYear();
}
fp.currentYearElement.value = String(fp.currentYear);
if (fp.rContainer) {
var months = fp.rContainer.querySelectorAll(".flatpickr-monthSelect-month");
months.forEach(function (month) {
month.dateObj.setFullYear(fp.currentYear);
if ((fp.config.minDate && month.dateObj < fp.config.minDate) ||
(fp.config.maxDate && month.dateObj > fp.config.maxDate)) {
month.classList.add("flatpickr-disabled");
}
else {
month.classList.remove("flatpickr-disabled");
}
});
}
setCurrentlySelected();
}
function selectMonth(e) {
e.preventDefault();
e.stopPropagation();
var eventTarget = getEventTarget(e);
if (!(eventTarget instanceof Element))
return;
if (eventTarget.classList.contains("flatpickr-disabled"))
return;
if (eventTarget.classList.contains("notAllowed"))
return; // necessary??
setMonth(eventTarget.dateObj);
if (fp.config.closeOnSelect) {
var single = fp.config.mode === "single";
var range = fp.config.mode === "range" && fp.selectedDates.length === 2;
if (single || range)
fp.close();
}
}
function setMonth(date) {
var selectedDate = new Date(fp.currentYear, date.getMonth(), date.getDate());
var selectedDates = [];
switch (fp.config.mode) {
case "single":
selectedDates = [selectedDate];
break;
case "multiple":
selectedDates.push(selectedDate);
break;
case "range":
if (fp.selectedDates.length === 2) {
selectedDates = [selectedDate];
}
else {
selectedDates = fp.selectedDates.concat([selectedDate]);
selectedDates.sort(function (a, b) { return a.getTime() - b.getTime(); });
}
break;
}
fp.setDate(selectedDates, true);
setCurrentlySelected();
}
var shifts = {
37: -1,
39: 1,
40: 3,
38: -3,
};
function onKeyDown(_, __, ___, e) {
var shouldMove = shifts[e.keyCode] !== undefined;
if (!shouldMove && e.keyCode !== 13) {
return;
}
if (!fp.rContainer || !self.monthsContainer)
return;
var currentlySelected = fp.rContainer.querySelector(".flatpickr-monthSelect-month.selected");
var index = Array.prototype.indexOf.call(self.monthsContainer.children, document.activeElement);
if (index === -1) {
var target = currentlySelected || self.monthsContainer.firstElementChild;
target.focus();
index = target.$i;
}
if (shouldMove) {
self.monthsContainer.children[(12 + index + shifts[e.keyCode]) % 12].focus();
}
else if (e.keyCode === 13 &&
self.monthsContainer.contains(document.activeElement)) {
setMonth(document.activeElement.dateObj);
}
}
function closeHook() {
var _a;
if (((_a = fp.config) === null || _a === void 0 ? void 0 : _a.mode) === "range" && fp.selectedDates.length === 1)
fp.clear(false);
if (!fp.selectedDates.length)
buildMonths();
}
// Help the prev/next year nav honor config.minDate (see 3fa5a69)
function stubCurrentMonth() {
config._stubbedCurrentMonth = fp._initialDate.getMonth();
fp._initialDate.setMonth(config._stubbedCurrentMonth);
fp.currentMonth = config._stubbedCurrentMonth;
}
function unstubCurrentMonth() {
if (!config._stubbedCurrentMonth)
return;
fp._initialDate.setMonth(config._stubbedCurrentMonth);
fp.currentMonth = config._stubbedCurrentMonth;
delete config._stubbedCurrentMonth;
}
function destroyPluginInstance() {
if (self.monthsContainer !== null) {
var months = self.monthsContainer.querySelectorAll(".flatpickr-monthSelect-month");
for (var index = 0; index < months.length; index++) {
months[index].removeEventListener("click", selectMonth);
}
}
}
return {
onParseConfig: function () {
fp.config.enableTime = false;
},
onValueUpdate: setCurrentlySelected,
onKeyDown: onKeyDown,
onReady: [
stubCurrentMonth,
clearUnnecessaryDOMElements,
build,
bindEvents,
setCurrentlySelected,
function () {
fp.config.onClose.push(closeHook);
fp.loadedPlugins.push("monthSelect");
},
],
onDestroy: [
unstubCurrentMonth,
destroyPluginInstance,
function () {
fp.config.onClose = fp.config.onClose.filter(function (hook) { return hook !== closeHook; });
},
],
};
};
}
return monthSelectPlugin;
})));
@@ -0,0 +1,117 @@
.flatpickr-monthSelect-months {
margin: 10px 1px 3px 1px;
flex-wrap: wrap;
}
.flatpickr-monthSelect-month {
background: none;
border: 1px solid transparent;
border-radius: 4px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #393939;
cursor: pointer;
display: inline-block;
font-weight: 400;
margin: 0.5px;
justify-content: center;
padding: 10px;
position: relative;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
text-align: center;
width: 33%;
}
.flatpickr-monthSelect-month.flatpickr-disabled {
color: #eee;
}
.flatpickr-monthSelect-month.flatpickr-disabled:hover,
.flatpickr-monthSelect-month.flatpickr-disabled:focus {
cursor: not-allowed;
background: none !important;
}
.flatpickr-monthSelect-theme-dark {
background: #3f4458;
}
.flatpickr-monthSelect-theme-dark .flatpickr-current-month input.cur-year {
color: #fff;
}
.flatpickr-monthSelect-theme-dark .flatpickr-months .flatpickr-prev-month,
.flatpickr-monthSelect-theme-dark .flatpickr-months .flatpickr-next-month {
color: #fff;
fill: #fff;
}
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month {
color: rgba(255, 255, 255, 0.95);
}
.flatpickr-monthSelect-month.today {
border-color: #959ea9;
}
.flatpickr-monthSelect-month.inRange,
.flatpickr-monthSelect-month.inRange.today,
.flatpickr-monthSelect-month:hover,
.flatpickr-monthSelect-month:focus {
background: #e6e6e6;
cursor: pointer;
outline: 0;
border-color: #e6e6e6;
}
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.inRange,
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:hover,
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:focus {
background: #646c8c;
border-color: #646c8c;
}
.flatpickr-monthSelect-month.today:hover,
.flatpickr-monthSelect-month.today:focus {
background: #959ea9;
border-color: #959ea9;
color: #fff;
}
.flatpickr-monthSelect-month.selected,
.flatpickr-monthSelect-month.startRange,
.flatpickr-monthSelect-month.endRange {
background-color: #569ff7;
box-shadow: none;
color: #fff;
border-color: #569ff7;
}
.flatpickr-monthSelect-month.startRange {
border-radius: 50px 0 0 50px;
}
.flatpickr-monthSelect-month.endRange {
border-radius: 0 50px 50px 0;
}
.flatpickr-monthSelect-month.startRange.endRange {
border-radius: 50px;
}
.flatpickr-monthSelect-month.inRange {
border-radius: 0;
box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
}
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.selected,
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.startRange,
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.endRange {
background: #80cbc4;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #80cbc4;
}
+324
View File
@@ -0,0 +1,324 @@
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 100;
font-display: swap;
src: url(pxiAyp8kv8JHgFVrJJLmE0tMMPKzSQ.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 100;
font-display: swap;
src: url(pxiAyp8kv8JHgFVrJJLmE0tCMPI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 200;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmv1pVGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 200;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmv1pVF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm21lVGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm21lVF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url(pxiGyp8kv8JHgFVrJJLufntAKPY.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url(pxiGyp8kv8JHgFVrJJLucHtA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmg1hVGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmg1hVF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmr19VGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmr19VF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmy15VGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLmy15VF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 800;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm111VGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 800;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm111VF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm81xVGdeOcEg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url(pxiDyp8kv8JHgFVrJJLm81xVF9eO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(pxiGyp8kv8JHgFVrLPTufntAKPY.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(pxiGyp8kv8JHgFVrLPTucHtA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLFj_Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLFj_Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLGT9Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLGT9Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLBT5Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url(pxiByp8kv8JHgFVrLBT5Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More