Compare commits
7 Commits
96e2e5f475
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 31290517ba | |||
| d169b85f54 | |||
| 9f10c8ffb5 | |||
| a692304748 | |||
| e66f3a0360 | |||
| 0bde9ef7c9 | |||
| c1da4a95c3 |
@@ -13,3 +13,19 @@ DATABASE_PREFIX=
|
||||
DATABASE_CHARSET=utf8
|
||||
|
||||
MYSQL_ROOT_PASSWORD=change-me-to-a-strong-root-password
|
||||
|
||||
# Self-registration: lets visitors create their own free 'admin' account
|
||||
# (email + CAPTCHA -> mailed password -> forced reset on first login).
|
||||
ALLOW_SELF_REGISTRATION=false
|
||||
|
||||
# Only needed when ALLOW_SELF_REGISTRATION=true.
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_ENCRYPTION=tls
|
||||
# Set to false to relay unauthenticated through an internal mail server (no
|
||||
# MAIL_USERNAME/MAIL_PASSWORD needed in that case).
|
||||
MAIL_SMTP_AUTH=true
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=noreply@example.com
|
||||
MAIL_FROM_NAME=QRForge
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
.idea
|
||||
.DS_Store
|
||||
.env
|
||||
/data/
|
||||
+10
@@ -95,12 +95,22 @@ RUN cp -R ./php-qrcode/src /var/www/html/
|
||||
|
||||
WORKDIR /var/www/html
|
||||
RUN composer update
|
||||
RUN composer require phpmailer/phpmailer:^6.9
|
||||
COPY ./src ./
|
||||
COPY ./db/migrations ./db/migrations
|
||||
RUN chmod 755 *;
|
||||
|
||||
# Qr code storage lives outside the document root so files can only be reached through
|
||||
# the authenticated qrcode_image.php / qrcode_zip_download.php endpoints.
|
||||
RUN mkdir -p /var/www/qrcode-storage/zip && chmod -R 777 /var/www/qrcode-storage
|
||||
|
||||
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
|
||||
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
|
||||
# on a brand new volume, so without this an existing install's schema silently falls
|
||||
# behind the code on every `git pull` + restart.
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["php", "-S", "0.0.0.0:80"]
|
||||
|
||||
@@ -51,7 +51,9 @@ RUN cp -R ./php-qrcode/src /var/www/html/
|
||||
|
||||
WORKDIR /var/www/html
|
||||
RUN composer update
|
||||
RUN composer require phpmailer/phpmailer:^6.9
|
||||
COPY ./src ./
|
||||
COPY ./db/migrations ./db/migrations
|
||||
RUN chown -R www-data:www-data /var/www/html \
|
||||
&& find /var/www/html -type f -exec chmod 644 {} \; \
|
||||
&& find /var/www/html -type d -exec chmod 755 {} \;
|
||||
@@ -62,5 +64,13 @@ RUN mkdir -p /var/www/qrcode-storage/zip \
|
||||
&& chown -R www-data:www-data /var/www/qrcode-storage \
|
||||
&& chmod -R 775 /var/www/qrcode-storage
|
||||
|
||||
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
|
||||
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
|
||||
# on a brand new volume, so without this an existing install's schema silently falls
|
||||
# behind the code on every `git pull` + restart.
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
EXPOSE 9000
|
||||
CMD ["php-fpm"]
|
||||
|
||||
@@ -8,8 +8,8 @@ of the original [PHP Dynamic Qr code](https://github.com/giandonatoinverso/PHP-D
|
||||
project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
|
||||
|
||||
- **Try it free:** [qr.ensembia.com](https://qr.ensembia.com) - fully functional OSS test
|
||||
instance. Self-service signup isn't live yet, so log in with the temporary shared demo
|
||||
account `admin` / `admin` in the meantime.
|
||||
instance. [Register your own free account](https://qr.ensembia.com/register.php)
|
||||
(email + a self-hosted CAPTCHA, no third-party service) - no shared demo login needed.
|
||||
- **Commercial VIP edition:** the ability to give sub-users the ability to create
|
||||
QR codes as well, from their own (sub)account. If you have a bigger organisation,
|
||||
having more users being able to create new QR codes delegates your workload. To
|
||||
@@ -43,7 +43,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
|
||||
# What is included
|
||||
|
||||
- PHP 8.4 application source
|
||||
- Database schema + migrations
|
||||
- Database schema + migrations (applied automatically on every container start,
|
||||
so `git pull` + restart is enough to bring an existing install up to date)
|
||||
- Docker Compose files (dev and production)
|
||||
- CSS/JS assets
|
||||
|
||||
|
||||
+9
-3
@@ -19,14 +19,20 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`can_view_static` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`can_view_dynamic` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`owner_admin_id` int(25) DEFAULT NULL,
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
`must_set_email` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`self_registered_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`)
|
||||
UNIQUE KEY `username` (`username`),
|
||||
UNIQUE KEY `email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=0 ;
|
||||
|
||||
-- Default super admin account. Credentials: superadmin / superadmin
|
||||
-- must_change_password=1 forces a password change on first login (see Fase 1 hardening).
|
||||
INSERT INTO `users` (`id`, `username`, `password`, `series_id`, `remember_token`, `expires`, `type`, `must_change_password`, `password_changed_at`) VALUES
|
||||
(1, 'superadmin', '$2y$10$xpZc5KC.aU2XHkcqhuZGFuAnqmtL4Unt8MysOyylceq.19XIyoZpG', NULL, NULL, NULL, 'super', 1, NULL);
|
||||
-- must_set_email=1: no email yet, so login falls back to username until it's set (same
|
||||
-- one-time interstitial pre-migration accounts get - see set_email.php).
|
||||
INSERT INTO `users` (`id`, `username`, `password`, `series_id`, `remember_token`, `expires`, `type`, `must_change_password`, `password_changed_at`, `must_set_email`) VALUES
|
||||
(1, 'superadmin', '$2y$10$xpZc5KC.aU2XHkcqhuZGFuAnqmtL4Unt8MysOyylceq.19XIyoZpG', NULL, NULL, NULL, 'super', 1, NULL, 1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `dynamic_qrcodes` (
|
||||
`id` int(10) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Self-registration: adds email as the login identifier (mirrors qr-vip's
|
||||
-- 006_vip_and_create_rights.sql email migration) plus a marker for
|
||||
-- self-registered accounts.
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- 1. email: becomes the login identifier going forward. Nullable so existing accounts (which
|
||||
-- have no email) don't violate a NOT NULL constraint; a unique index still allows unlimited
|
||||
-- NULLs in InnoDB, so pre-existing NULL-email rows never collide with each other.
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email'
|
||||
);
|
||||
SET @sql := IF(@col_exists = 0,
|
||||
'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(255) DEFAULT NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND INDEX_NAME = 'email'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2. must_set_email: forces existing (pre-migration) accounts through a one-time "set your
|
||||
-- email" interstitial on next login, mirroring must_change_password. New accounts created
|
||||
-- after this migration always have an email from creation, so they never get this flag.
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'must_set_email'
|
||||
);
|
||||
SET @sql := IF(@col_exists = 0,
|
||||
'ALTER TABLE `users` ADD COLUMN `must_set_email` TINYINT(1) NOT NULL DEFAULT 0',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `users` SET `must_set_email` = 1 WHERE `email` IS NULL;
|
||||
|
||||
-- 3. self_registered_at: NULL for accounts created by an admin/super, set for accounts created
|
||||
-- through register.php. Purely informational/reporting for now.
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'self_registered_at'
|
||||
);
|
||||
SET @sql := IF(@col_exists = 0,
|
||||
'ALTER TABLE `users` ADD COLUMN `self_registered_at` DATETIME DEFAULT NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
+11
-6
@@ -31,11 +31,20 @@ services:
|
||||
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}"
|
||||
DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
|
||||
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}"
|
||||
ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}"
|
||||
MAIL_HOST: "${MAIL_HOST:-}"
|
||||
MAIL_PORT: "${MAIL_PORT:-587}"
|
||||
MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-tls}"
|
||||
MAIL_SMTP_AUTH: "${MAIL_SMTP_AUTH:-true}"
|
||||
MAIL_USERNAME: "${MAIL_USERNAME:-}"
|
||||
MAIL_PASSWORD: "${MAIL_PASSWORD:-}"
|
||||
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}"
|
||||
MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}"
|
||||
depends_on:
|
||||
qrforge-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- qrforge_qrcode_storage:/var/www/qrcode-storage
|
||||
- ./data/qrcode-storage:/var/www/qrcode-storage
|
||||
networks:
|
||||
- qrforge-network
|
||||
|
||||
@@ -43,7 +52,7 @@ services:
|
||||
image: "mysql:8.0"
|
||||
restart: "unless-stopped"
|
||||
volumes:
|
||||
- qrforge_db_data:/var/lib/mysql
|
||||
- ./data/mysql:/var/lib/mysql
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
|
||||
@@ -58,10 +67,6 @@ services:
|
||||
networks:
|
||||
- qrforge-network
|
||||
|
||||
volumes:
|
||||
qrforge_db_data:
|
||||
qrforge_qrcode_storage:
|
||||
|
||||
networks:
|
||||
qrforge-network:
|
||||
driver: bridge
|
||||
|
||||
+22
-6
@@ -15,13 +15,33 @@ services:
|
||||
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}"
|
||||
DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
|
||||
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}"
|
||||
ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}"
|
||||
# Defaults here are dev-convenience only (mailhog, no auth) - a real deployment's
|
||||
# .env must override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD
|
||||
# explicitly, same as DATABASE_PASSWORD above already requires.
|
||||
MAIL_HOST: "${MAIL_HOST:-mailhog}"
|
||||
MAIL_PORT: "${MAIL_PORT:-1025}"
|
||||
MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-}"
|
||||
MAIL_SMTP_AUTH: "${MAIL_SMTP_AUTH:-false}"
|
||||
MAIL_USERNAME: "${MAIL_USERNAME:-}"
|
||||
MAIL_PASSWORD: "${MAIL_PASSWORD:-}"
|
||||
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}"
|
||||
MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}"
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
qrforge-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- qrforge_qrcode_storage:/var/www/qrcode-storage
|
||||
- ./data/qrcode-storage:/var/www/qrcode-storage
|
||||
networks:
|
||||
- qrforge-network
|
||||
|
||||
mailhog:
|
||||
image: "mailhog/mailhog:v1.0.1"
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "8025:8025" # web UI: http://localhost:8025
|
||||
networks:
|
||||
- qrforge-network
|
||||
|
||||
@@ -29,7 +49,7 @@ services:
|
||||
image: "mysql:8.0"
|
||||
restart: "unless-stopped"
|
||||
volumes:
|
||||
- qrforge_db_data:/var/lib/mysql
|
||||
- ./data/mysql:/var/lib/mysql
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?zet MYSQL_ROOT_PASSWORD in .env}"
|
||||
@@ -44,10 +64,6 @@ services:
|
||||
networks:
|
||||
- qrforge-network
|
||||
|
||||
volumes:
|
||||
qrforge_db_data:
|
||||
qrforge_qrcode_storage:
|
||||
|
||||
networks:
|
||||
qrforge-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
php /var/www/html/scripts/migrate.php
|
||||
|
||||
exec docker-php-entrypoint "$@"
|
||||
+3
-2
@@ -51,8 +51,9 @@ require_once BASE_PATH . '/includes/auth_validate.php';
|
||||
This is the free, open-source (MIT) edition of QRForge. It runs unmodified
|
||||
as a live, fully functional try-out at
|
||||
<a href="https://qr.ensembia.com" target="_blank">qr.ensembia.com</a> -
|
||||
self-service signup isn't live yet, so log in with the temporary shared
|
||||
demo account <code>admin</code> / <code>admin</code> in the meantime.
|
||||
<a href="https://qr.ensembia.com/register.php" target="_blank">register your
|
||||
own free account</a> there (email + a self-hosted CAPTCHA, no third-party
|
||||
service).
|
||||
</p>
|
||||
<p>
|
||||
The commercial VIP edition (paid create-rights and logo-embedded QR codes)
|
||||
|
||||
+19
-8
@@ -6,17 +6,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
{
|
||||
csrf_verify_or_die();
|
||||
|
||||
$username = filter_input(INPUT_POST, 'username');
|
||||
// Login moves from username to email. Accept either during the transition -
|
||||
// existing pre-migration accounts have no email yet (see must_set_email/set_email.php),
|
||||
// so a plain username must keep working until they've set one.
|
||||
$identifier = filter_input(INPUT_POST, 'email');
|
||||
$password = filter_input(INPUT_POST, 'password');
|
||||
$remember = filter_input(INPUT_POST, 'remember');
|
||||
|
||||
if (!$username || !$password) {
|
||||
$_SESSION['login_failure'] = 'Invalid username or password';
|
||||
if (!$identifier || !$password) {
|
||||
$_SESSION['login_failure'] = 'Invalid email or password';
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (qr_is_login_locked_out($username)) {
|
||||
if (qr_is_login_locked_out($identifier)) {
|
||||
$_SESSION['login_failure'] = 'Too many failed login attempts. Try again in 15 minutes.';
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
@@ -25,12 +28,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
// Get DB instance.
|
||||
$db = getDbInstance();
|
||||
|
||||
$db->where('username', $username);
|
||||
$db->where('email', $identifier);
|
||||
$row = $db->getOne('users');
|
||||
|
||||
if ($db->count < 1) {
|
||||
// Compatibility fallback for accounts that haven't set an email yet.
|
||||
$db = getDbInstance();
|
||||
$db->where('username', $identifier);
|
||||
$row = $db->getOne('users');
|
||||
}
|
||||
|
||||
if ($db->count >= 1 && password_verify($password, $row['password']))
|
||||
{
|
||||
qr_record_login_attempt($username, true);
|
||||
qr_record_login_attempt($identifier, true);
|
||||
|
||||
// Voorkom session fixation: nieuwe sessie-id na een geslaagde login.
|
||||
session_regenerate_id(true);
|
||||
@@ -40,6 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
$_SESSION['user_id'] = $row['id'];
|
||||
$_SESSION['username'] = $row['username'];
|
||||
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
|
||||
$_SESSION['must_set_email'] = !empty($row['must_set_email']);
|
||||
$_SESSION['can_view_static'] = !empty($row['can_view_static']);
|
||||
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
|
||||
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row);
|
||||
@@ -86,8 +97,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
}
|
||||
else
|
||||
{
|
||||
qr_record_login_attempt($username, false);
|
||||
$_SESSION['login_failure'] = 'Invalid username or password';
|
||||
qr_record_login_attempt($identifier, false);
|
||||
$_SESSION['login_failure'] = 'Invalid email or password';
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// Public endpoint (no auth): renders a self-hosted CAPTCHA image for register.php.
|
||||
// No third-party service (reCAPTCHA/Turnstile/etc) - a simple math challenge drawn
|
||||
// with GD onto a noisy background, expected answer kept server-side in the session.
|
||||
require_once 'includes/bootstrap.php';
|
||||
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
$_SESSION['captcha_answer'] = (string) ($a + $b);
|
||||
$text = "{$a} + {$b} =";
|
||||
|
||||
$width = 160;
|
||||
$height = 60;
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
$bg = imagecolorallocate($image, 245, 245, 245);
|
||||
$fg = imagecolorallocate($image, 30, 30, 30);
|
||||
imagefill($image, 0, 0, $bg);
|
||||
|
||||
// Noise: random lines behind the text, purely cosmetic distortion.
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$lineColor = imagecolorallocate($image, random_int(180, 220), random_int(180, 220), random_int(180, 220));
|
||||
imageline($image, random_int(0, $width), random_int(0, $height), random_int(0, $width), random_int(0, $height), $lineColor);
|
||||
}
|
||||
|
||||
$fontFile = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||||
if (is_file($fontFile) && function_exists('imagettftext')) {
|
||||
$fontSize = 22;
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
|
||||
$textWidth = abs($bbox[2] - $bbox[0]);
|
||||
$textHeight = abs($bbox[1] - $bbox[7]);
|
||||
$x = (int) (($width - $textWidth) / 2);
|
||||
$y = (int) (($height + $textHeight) / 2);
|
||||
imagettftext($image, $fontSize, 0, $x, $y, $fg, $fontFile, $text);
|
||||
} else {
|
||||
imagestring($image, 5, 10, 20, $text, $fg);
|
||||
}
|
||||
|
||||
header('Content-Type: image/png');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
imagepng($image);
|
||||
imagedestroy($image);
|
||||
@@ -16,3 +16,14 @@ define('DATABASE_CHARSET', getenv('DATABASE_CHARSET') ?: 'utf8');
|
||||
define('TYPE', getenv('TYPE') ?: 'local');
|
||||
define('BASE_URL', getenv('BASE_URL') ?: 'http://localhost');
|
||||
define('QRCODE_GENERATOR', getenv('QRCODE_GENERATOR') ?: 'external-api.qrserver.com'); // opties: external-api.qrserver.com of internal-chillerlan.qrcode
|
||||
|
||||
define('ALLOW_SELF_REGISTRATION', filter_var(getenv('ALLOW_SELF_REGISTRATION'), FILTER_VALIDATE_BOOLEAN));
|
||||
|
||||
define('MAIL_HOST', getenv('MAIL_HOST') ?: '');
|
||||
define('MAIL_PORT', filter_var(getenv('MAIL_PORT'), FILTER_VALIDATE_INT) ?: 587);
|
||||
define('MAIL_ENCRYPTION', getenv('MAIL_ENCRYPTION') !== false ? getenv('MAIL_ENCRYPTION') : 'tls'); // opties: tls, ssl, '' (geen)
|
||||
define('MAIL_SMTP_AUTH', getenv('MAIL_SMTP_AUTH') !== false ? filter_var(getenv('MAIL_SMTP_AUTH'), FILTER_VALIDATE_BOOLEAN) : true);
|
||||
define('MAIL_USERNAME', getenv('MAIL_USERNAME') ?: '');
|
||||
define('MAIL_PASSWORD', getenv('MAIL_PASSWORD') ?: '');
|
||||
define('MAIL_FROM_ADDRESS', getenv('MAIL_FROM_ADDRESS') ?: 'noreply@example.com');
|
||||
define('MAIL_FROM_NAME', getenv('MAIL_FROM_NAME') ?: 'QRForge');
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-envelope"></i></span>
|
||||
</div>
|
||||
|
||||
<input type="email" name="email" placeholder="Email" class="form-control" value="<?php echo ($edit) ? htmlspecialchars($user['email'] ?? '', ENT_QUOTES, 'UTF-8') : ''; ?>" autocomplete="off">
|
||||
</div>
|
||||
<small class="form-text text-muted">Used to log in once set. Leave blank to prompt for it on next login.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group">
|
||||
<label for="password">Password *</label>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=2fa" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -38,6 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -62,6 +61,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=bitcoin" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -47,6 +46,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<small class="form-text text-muted mb-2">
|
||||
@@ -26,6 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=bookmark" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -20,6 +19,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=email" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -28,6 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=event" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group">
|
||||
@@ -72,6 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=location" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-8">
|
||||
<div class="form-group" style="position: relative;">
|
||||
@@ -30,6 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=paypal" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -100,6 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=phone" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -23,6 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=skype" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group">
|
||||
@@ -9,6 +8,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=sms" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -30,6 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=text" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-6">
|
||||
<div class="form-group">
|
||||
@@ -9,6 +8,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=vcard" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<!-- First row -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
@@ -149,6 +148,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=whatsapp" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -30,6 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form class="form" action="static_qrcode.php?type=wifi" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
@@ -33,6 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="5%">ID</th>
|
||||
<th width="45%">Username</th>
|
||||
<th width="40%">Type</th>
|
||||
<th width="25%">Username</th>
|
||||
<th width="30%">Email</th>
|
||||
<th width="30%">Type</th>
|
||||
<th width="10%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -16,6 +17,7 @@
|
||||
<tr>
|
||||
<td><?php echo $row['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($row['username']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['email'] ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['type']); ?></td>
|
||||
<td>
|
||||
<!-- EDIT -->
|
||||
|
||||
@@ -12,3 +12,4 @@ require_once __DIR__ . '/security.php';
|
||||
qr_session_start();
|
||||
qr_enforce_session_timeout();
|
||||
qr_enforce_password_change();
|
||||
qr_enforce_email_set();
|
||||
|
||||
@@ -36,3 +36,63 @@
|
||||
navigator.serviceWorker.register('service-worker.js');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Idle-timeout warning: the session dies silently after
|
||||
<?php echo SESSION_IDLE_TIMEOUT; ?> seconds of inactivity (no PHP page
|
||||
load), which loses whatever form the user is filling in. This warns a
|
||||
couple of minutes before that happens and offers a "stay logged in"
|
||||
button that pings the server without navigating away.
|
||||
-->
|
||||
<div id="session-timeout-toast" class="toast" role="alert" aria-live="assertive" aria-atomic="true"
|
||||
style="position:fixed;bottom:20px;right:20px;z-index:2000;min-width:320px;display:none;">
|
||||
<div class="toast-header bg-warning">
|
||||
<i class="fa fa-clock mr-2"></i>
|
||||
<strong class="mr-auto">Session expiring soon</strong>
|
||||
</div>
|
||||
<div class="toast-body bg-white">
|
||||
<span id="session-timeout-message">You'll be logged out in a couple of minutes due to inactivity.</span>
|
||||
<div class="mt-2">
|
||||
<button type="button" id="session-timeout-extend" class="btn btn-sm btn-primary">Stay logged in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var SESSION_IDLE_TIMEOUT = <?php echo (int) SESSION_IDLE_TIMEOUT; ?>;
|
||||
var WARNING_LEAD_TIME = 120; // show the warning this many seconds before expiry
|
||||
var toast = document.getElementById('session-timeout-toast');
|
||||
var message = document.getElementById('session-timeout-message');
|
||||
var extendBtn = document.getElementById('session-timeout-extend');
|
||||
var warnTimer = null;
|
||||
|
||||
function showWarning() {
|
||||
toast.style.display = 'block';
|
||||
}
|
||||
|
||||
function scheduleWarning() {
|
||||
clearTimeout(warnTimer);
|
||||
var delayMs = Math.max(0, (SESSION_IDLE_TIMEOUT - WARNING_LEAD_TIME) * 1000);
|
||||
warnTimer = setTimeout(showWarning, delayMs);
|
||||
}
|
||||
|
||||
extendBtn.addEventListener('click', function () {
|
||||
fetch('session_ping.php', { method: 'GET', redirect: 'manual', credentials: 'same-origin' })
|
||||
.then(function (response) {
|
||||
// redirect: 'manual' turns a server-side redirect (session already
|
||||
// dead) into an opaque response instead of silently following it.
|
||||
if (response.type === 'opaqueredirect' || !response.ok) {
|
||||
throw new Error('expired');
|
||||
}
|
||||
toast.style.display = 'none';
|
||||
scheduleWarning();
|
||||
})
|
||||
.catch(function () {
|
||||
message.textContent = 'Your session already expired - please copy any unsaved work before reloading.';
|
||||
extendBtn.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
scheduleWarning();
|
||||
})();
|
||||
</script>
|
||||
@@ -66,8 +66,11 @@ function qr_enforce_password_change() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also exempt set_email.php - an account can need both flows at once (e.g. a fresh
|
||||
// self-registered row, or a pre-migration account that never set a password either),
|
||||
// and each enforcer redirecting to its own page while blocking the other's would loop forever.
|
||||
$current_script = basename(parse_url($_SERVER['SCRIPT_NAME'], PHP_URL_PATH));
|
||||
$exempt = ['change_password.php', 'logout.php'];
|
||||
$exempt = ['change_password.php', 'set_email.php', 'logout.php'];
|
||||
|
||||
if (in_array($current_script, $exempt, true)) {
|
||||
return;
|
||||
@@ -77,6 +80,26 @@ function qr_enforce_password_change() {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stuurt ingelogde gebruikers zonder e-mailadres naar set_email.php, behalve op de
|
||||
* wijzigingspagina's zelf en logout.
|
||||
*/
|
||||
function qr_enforce_email_set() {
|
||||
if (empty($_SESSION['user_logged_in']) || empty($_SESSION['must_set_email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current_script = basename(parse_url($_SERVER['SCRIPT_NAME'], PHP_URL_PATH));
|
||||
$exempt = ['set_email.php', 'change_password.php', 'logout.php'];
|
||||
|
||||
if (in_array($current_script, $exempt, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('Location: set_email.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF-bescherming
|
||||
*/
|
||||
@@ -118,6 +141,18 @@ function csrf_verify_header_or_die() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifieert het antwoord op de zelf-gehoste CAPTCHA (captcha.php). Verbruikt het
|
||||
* verwachte antwoord uit de sessie na de eerste check, zodat elke afbeelding maar
|
||||
* eenmaal te gebruiken is (voorkomt hergebruik van hetzelfde plaatje/antwoord).
|
||||
*/
|
||||
function captcha_is_valid($submittedAnswer) {
|
||||
$expected = $_SESSION['captcha_answer'] ?? null;
|
||||
unset($_SESSION['captcha_answer']);
|
||||
|
||||
return $expected !== null && is_string($submittedAnswer) && hash_equals($expected, trim($submittedAnswer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting op login
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
require_once __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
/**
|
||||
* Sends a temporary/initial password to a newly created or provisioned account.
|
||||
* Returns true on success, false on failure (never throws - callers decide how to
|
||||
* surface a mail failure without blocking the account creation itself).
|
||||
*/
|
||||
public function sendInitialPassword($toEmail, $tempPassword) {
|
||||
$subject = 'Your ' . MAIL_FROM_NAME . ' account';
|
||||
$body = "An account was created for you.\n\n"
|
||||
. "Email: {$toEmail}\n"
|
||||
. "Temporary password: {$tempPassword}\n\n"
|
||||
. "You'll be asked to set a new password the first time you log in.\n\n"
|
||||
. rtrim(BASE_URL, '/') . "/login.php";
|
||||
|
||||
return $this->send($toEmail, $subject, $body);
|
||||
}
|
||||
|
||||
private function send($toEmail, $subject, $body) {
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = MAIL_HOST;
|
||||
$mail->Port = MAIL_PORT;
|
||||
$mail->SMTPAuth = MAIL_SMTP_AUTH;
|
||||
|
||||
if (MAIL_SMTP_AUTH) {
|
||||
$mail->Username = MAIL_USERNAME;
|
||||
$mail->Password = MAIL_PASSWORD;
|
||||
}
|
||||
|
||||
if (MAIL_ENCRYPTION !== '') {
|
||||
$mail->SMTPSecure = MAIL_ENCRYPTION;
|
||||
}
|
||||
|
||||
$mail->setFrom(MAIL_FROM_ADDRESS, MAIL_FROM_NAME);
|
||||
$mail->addAddress($toEmail);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $body;
|
||||
$mail->isHTML(false);
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
} catch (PHPMailerException $e) {
|
||||
error_log('Mailer: failed to send to ' . $toEmail . ': ' . $mail->ErrorInfo);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once 'config/config.php';
|
||||
require_once BASE_PATH . '/lib/Mailer/Mailer.php';
|
||||
|
||||
class Users
|
||||
{
|
||||
@@ -31,6 +32,23 @@ class Users
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side validation of the (optional, for admin-created accounts) email address.
|
||||
* Empty is allowed here - an account without one gets must_set_email=1, same as a
|
||||
* pre-migration legacy account (see the callers below).
|
||||
*/
|
||||
private function validateEmail($email) {
|
||||
if ($email === '' || $email === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($email) || strlen($email) > 255 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return 'Please enter a valid email address.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -113,11 +131,19 @@ class Users
|
||||
$this->failure($validation_error, 'Location: user.php');
|
||||
}
|
||||
|
||||
$email = trim($input_data['email'] ?? '');
|
||||
$email_error = $this->validateEmail($email);
|
||||
if ($email_error !== null) {
|
||||
$this->failure($email_error, 'Location: user.php');
|
||||
}
|
||||
|
||||
if (!isset($input_data['password']) || strlen($input_data['password']) < 10) {
|
||||
$this->failure('Password must be at least 10 characters long.', 'Location: user.php');
|
||||
}
|
||||
|
||||
$data_to_db["username"] = $input_data["username"];
|
||||
$data_to_db["email"] = $email !== '' ? $email : null;
|
||||
$data_to_db['must_set_email'] = $email === '' ? 1 : 0;
|
||||
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
|
||||
$data_to_db["type"] = $requested_type;
|
||||
$data_to_db['owner_admin_id'] = $owner_admin_id;
|
||||
@@ -130,6 +156,16 @@ class Users
|
||||
if ($db->count >= 1)
|
||||
$this->failure('Username already exists');
|
||||
|
||||
if ($email !== '') {
|
||||
$db = getDbInstance();
|
||||
$db->where('email', $email);
|
||||
$db->get('users');
|
||||
|
||||
if ($db->count >= 1)
|
||||
$this->failure('An account with this email already exists', 'Location: user.php');
|
||||
}
|
||||
|
||||
$db = getDbInstance();
|
||||
$last_id = $db->insert('users', $data_to_db);
|
||||
|
||||
if ($last_id) {
|
||||
@@ -138,6 +174,89 @@ class Users
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public self-registration (register.php). No session/permission checks - this is
|
||||
* the one path where an unauthenticated visitor creates their own account. Always
|
||||
* creates a free-forever 'admin' (self-scoped, no tenant), matching what a manually
|
||||
* created OSS admin gets. Returns ['ok' => true] on success or
|
||||
* ['ok' => false, 'error' => string] - callers are responsible for flash/redirect,
|
||||
* unlike addUser()/editUser() which redirect themselves (this runs pre-login, on a
|
||||
* page with its own layout).
|
||||
*/
|
||||
public function registerSelfUser($email) {
|
||||
if (!ALLOW_SELF_REGISTRATION) {
|
||||
return ['ok' => false, 'error' => 'Self-registration is not enabled.'];
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'error' => 'Please enter a valid email address.'];
|
||||
}
|
||||
|
||||
$db = getDbInstance();
|
||||
$db->where('email', $email);
|
||||
$existing = $db->getOne('users');
|
||||
|
||||
if (!empty($existing)) {
|
||||
return ['ok' => false, 'error' => 'An account with this email already exists.'];
|
||||
}
|
||||
|
||||
$username = $this->deriveUniqueUsername($email);
|
||||
$tempPassword = bin2hex(random_bytes(8));
|
||||
|
||||
$data_to_db = [
|
||||
'username' => $username,
|
||||
'password' => password_hash($tempPassword, PASSWORD_DEFAULT),
|
||||
'type' => 'admin',
|
||||
'owner_admin_id' => null,
|
||||
'email' => $email,
|
||||
'must_change_password' => 1,
|
||||
'self_registered_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$db = getDbInstance();
|
||||
$last_id = $db->insert('users', $data_to_db);
|
||||
|
||||
if (!$last_id) {
|
||||
return ['ok' => false, 'error' => 'Could not create the account: ' . $db->getLastError()];
|
||||
}
|
||||
|
||||
audit_log('user_self_registered', 'user', $last_id);
|
||||
|
||||
$mailer = new Mailer();
|
||||
$mailer->sendInitialPassword($email, $tempPassword);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a username candidate from the email's local part (letters/digits/dot/
|
||||
* underscore/hyphen only, matching validateUsernameAndType()'s rules), appending a
|
||||
* numeric suffix if it's already taken.
|
||||
*/
|
||||
private function deriveUniqueUsername($email) {
|
||||
$localPart = strtolower(strstr($email, '@', true) ?: $email);
|
||||
$base = preg_replace('/[^a-z0-9._-]/', '', $localPart);
|
||||
$base = substr($base, 0, 45) ?: 'user';
|
||||
|
||||
if (strlen($base) < 3) {
|
||||
$base = str_pad($base, 3, '0');
|
||||
}
|
||||
|
||||
$candidate = $base;
|
||||
$suffix = 1;
|
||||
|
||||
$db = getDbInstance();
|
||||
$db->where('username', $candidate);
|
||||
while ($db->getOne('users') !== null) {
|
||||
$candidate = $base . $suffix;
|
||||
$suffix++;
|
||||
$db = getDbInstance();
|
||||
$db->where('username', $candidate);
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit user.
|
||||
*
|
||||
@@ -174,6 +293,12 @@ class Users
|
||||
$this->failure($validation_error, 'Location: user.php?'.$query_string);
|
||||
}
|
||||
|
||||
$email = trim($input_data['email'] ?? '');
|
||||
$email_error = $this->validateEmail($email);
|
||||
if ($email_error !== null) {
|
||||
$this->failure($email_error, 'Location: user.php?'.$query_string);
|
||||
}
|
||||
|
||||
if (isset($input_data['password']) && strlen($input_data['password']) > 0 && strlen($input_data['password']) < 10) {
|
||||
$this->failure('Password must be at least 10 characters long.', 'Location: user.php?'.$query_string);
|
||||
}
|
||||
@@ -187,8 +312,25 @@ class Users
|
||||
$this->failure('Username already exists', 'Location: user.php?'.$query_string);
|
||||
}
|
||||
|
||||
if ($email !== '') {
|
||||
$db = getDbInstance();
|
||||
$db->where('email', $email);
|
||||
$db->where('id', $input_data["id"], '!=');
|
||||
$row = $db->getOne('users');
|
||||
|
||||
if (!empty($row['email'])) {
|
||||
$this->failure('An account with this email already exists', 'Location: user.php?'.$query_string);
|
||||
}
|
||||
}
|
||||
|
||||
$data_to_db["username"] = $input_data["username"];
|
||||
$data_to_db["type"] = $requested_type;
|
||||
// Only touch email/must_set_email if an email was actually submitted - an admin
|
||||
// leaving the field blank on an already-set account shouldn't wipe it back out.
|
||||
if ($email !== '') {
|
||||
$data_to_db['email'] = $email;
|
||||
$data_to_db['must_set_email'] = 0;
|
||||
}
|
||||
$data_to_db['can_view_static'] = !empty($input_data['can_view_static']) ? 1 : 0;
|
||||
$data_to_db['can_view_dynamic'] = !empty($input_data['can_view_dynamic']) ? 1 : 0;
|
||||
|
||||
|
||||
+7
-1
@@ -40,6 +40,7 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
|
||||
$_SESSION['type'] = $row['type'];
|
||||
$_SESSION['username'] = $row['username'];
|
||||
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
|
||||
$_SESSION['must_set_email'] = !empty($row['must_set_email']);
|
||||
$_SESSION['can_view_static'] = !empty($row['can_view_static']);
|
||||
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
|
||||
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row);
|
||||
@@ -81,10 +82,12 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Sign in to start your session</p>
|
||||
|
||||
<?php include './includes/flash_messages.php'; ?>
|
||||
|
||||
<form method="POST" action="authenticate.php">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" name="username" class="form-control" placeholder="Username" required="required">
|
||||
<input type="text" name="email" class="form-control" placeholder="Email" required="required">
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text">
|
||||
<span class="fa fa-user"></span>
|
||||
@@ -133,6 +136,9 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (ALLOW_SELF_REGISTRATION): ?>
|
||||
<p class="mt-3 text-center"><a href="register.php">Register for free</a></p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
<!-- /.login-card-body -->
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
require_once 'includes/bootstrap.php';
|
||||
require_once 'lib/Users/Users.php';
|
||||
|
||||
if (!ALLOW_SELF_REGISTRATION) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_SESSION['user_logged_in']) && $_SESSION['user_logged_in'] === TRUE) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify_or_die();
|
||||
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if (!captcha_is_valid($_POST['captcha'] ?? '')) {
|
||||
$_SESSION['failure'] = 'Incorrect CAPTCHA answer, please try again.';
|
||||
} else {
|
||||
$users = new Users();
|
||||
$result = $users->registerSelfUser($email);
|
||||
|
||||
if ($result['ok']) {
|
||||
$_SESSION['success'] = 'Account created! Check your inbox for a temporary password.';
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$_SESSION['failure'] = $result['error'];
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Register - QRForge</title>
|
||||
<?php include './includes/head.php'; ?>
|
||||
|
||||
<body class="login-page" style="min-height: 512.391px;">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<img src="dist/img/brand/logo.svg" alt="QRForge" style="max-width: 260px;">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Create your free account</p>
|
||||
|
||||
<?php include './includes/flash_messages.php'; ?>
|
||||
|
||||
<form method="POST" action="register.php">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div class="input-group mb-3">
|
||||
<input type="email" name="email" class="form-control" placeholder="Email address" required="required">
|
||||
</div>
|
||||
<div class="mb-3 text-center">
|
||||
<img src="captcha.php" alt="CAPTCHA" id="captcha-image" style="cursor:pointer;" title="Click to refresh">
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" name="captcha" class="form-control" placeholder="Answer the sum above" required="required" autocomplete="off">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-block">Create account</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-3 text-center"><a href="login.php">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../plugins/jquery/jquery.min.js"></script>
|
||||
<script src="../../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="../../dist/js/adminlte.js"></script>
|
||||
<script>
|
||||
document.getElementById('captcha-image').addEventListener('click', function () {
|
||||
this.src = 'captcha.php?' + Date.now();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
// Lightweight migration runner: applies any db/migrations/*.sql file not yet recorded
|
||||
// in schema_migrations, in filename order. Every migration file is itself idempotent
|
||||
// (checks information_schema before altering), so re-running an already-applied file
|
||||
// is a safe no-op - this script leans on that instead of needing transactional rollback.
|
||||
// Run automatically by the container entrypoint on every start (see docker/entrypoint.sh),
|
||||
// so a `git pull` + restart is enough to bring an existing install's schema up to date -
|
||||
// docker-entrypoint-initdb.d only ever runs db/init.sql, and only on a brand new volume.
|
||||
|
||||
require_once __DIR__ . '/../config/environment.php';
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
|
||||
$mysqli = new mysqli(DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME, DATABASE_PORT);
|
||||
if ($mysqli->connect_errno) {
|
||||
fwrite(STDERR, "migrate.php: could not connect to database: {$mysqli->connect_error}\n");
|
||||
exit(1);
|
||||
}
|
||||
$mysqli->set_charset(DATABASE_CHARSET);
|
||||
|
||||
$mysqli->query(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||
);
|
||||
|
||||
$files = glob(__DIR__ . '/../db/migrations/*.sql');
|
||||
sort($files, SORT_STRING);
|
||||
|
||||
$applied = [];
|
||||
$result = $mysqli->query('SELECT filename FROM schema_migrations');
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$applied[$row['filename']] = true;
|
||||
}
|
||||
|
||||
$ran = 0;
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (isset($applied[$filename])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "Applying migration: $filename\n";
|
||||
|
||||
if (!$mysqli->multi_query(file_get_contents($file))) {
|
||||
fwrite(STDERR, "migrate.php: failed to apply $filename: {$mysqli->error}\n");
|
||||
exit(1);
|
||||
}
|
||||
// multi_query queues result sets asynchronously - drain them all before the next
|
||||
// query, and check for a mid-batch error on each one.
|
||||
do {
|
||||
if ($res = $mysqli->store_result()) {
|
||||
$res->free();
|
||||
}
|
||||
if ($mysqli->errno) {
|
||||
fwrite(STDERR, "migrate.php: error while applying $filename: {$mysqli->error}\n");
|
||||
exit(1);
|
||||
}
|
||||
} while ($mysqli->more_results() && $mysqli->next_result());
|
||||
|
||||
$stmt = $mysqli->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, NOW())');
|
||||
$stmt->bind_param('s', $filename);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$ran++;
|
||||
}
|
||||
|
||||
echo $ran === 0 ? "No pending migrations.\n" : "Applied $ran migration(s).\n";
|
||||
|
||||
$mysqli->close();
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// Lightweight keep-alive endpoint: including bootstrap.php refreshes
|
||||
// $_SESSION['last_activity'], extending the idle timeout without navigating
|
||||
// away from (and losing) whatever form the user is currently filling in.
|
||||
// Deliberately doesn't use auth_validate.php's redirect-to-login-on-failure
|
||||
// behavior: this is called from JS, and a 401 lets the caller show "your
|
||||
// session already expired" instead of silently following a redirect.
|
||||
require_once 'includes/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (empty($_SESSION['user_logged_in'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
require_once 'includes/bootstrap.php';
|
||||
|
||||
if (empty($_SESSION['user_logged_in'])) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$forced = !empty($_SESSION['must_set_email']);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify_or_die();
|
||||
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['failure'] = 'Please enter a valid email address.';
|
||||
} else {
|
||||
$db = getDbInstance();
|
||||
$db->where('email', $email);
|
||||
$db->where('id', $_SESSION['user_id'], '!=');
|
||||
$existing = $db->getOne('users');
|
||||
|
||||
if (!empty($existing['email'])) {
|
||||
$_SESSION['failure'] = 'An account with this email already exists.';
|
||||
} else {
|
||||
$db = getDbInstance();
|
||||
$db->where('id', $_SESSION['user_id']);
|
||||
$db->update('users', [
|
||||
'email' => $email,
|
||||
'must_set_email' => 0,
|
||||
]);
|
||||
|
||||
$_SESSION['must_set_email'] = false;
|
||||
audit_log('email_set');
|
||||
|
||||
$_SESSION['success'] = 'Email address saved.';
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Set email - QRForge</title>
|
||||
<?php include './includes/head.php'; ?>
|
||||
|
||||
<body class="login-page" style="min-height: 512.391px;">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<img src="dist/img/brand/logo.svg" alt="QRForge" style="max-width: 260px;">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">
|
||||
<?php echo $forced
|
||||
? 'Please set an email address for your account before continuing. This will become your login.'
|
||||
: 'Set your email address'; ?>
|
||||
</p>
|
||||
|
||||
<?php include './includes/flash_messages.php'; ?>
|
||||
|
||||
<form method="POST" action="set_email.php">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div class="input-group mb-3">
|
||||
<input type="email" name="email" class="form-control" placeholder="Email address" required="required">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-block">Save email</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$forced): ?>
|
||||
<p class="mt-3 text-center"><a href="index.php">Back to dashboard</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../plugins/jquery/jquery.min.js"></script>
|
||||
<script src="../../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="../../dist/js/adminlte.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -9,7 +9,7 @@ $users = new Users();
|
||||
if (!in_array($_SESSION['type'], ['super', 'admin'], true))
|
||||
$users->failure('Only "super admin" and "admin" accounts can access the user management page', 'Location: index.php');
|
||||
|
||||
$select = array('id', 'username', 'type');
|
||||
$select = array('id', 'username', 'email', 'type');
|
||||
$search_fields = array('username');
|
||||
require_once BASE_PATH . '/includes/search_order.php';
|
||||
$page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? 1;
|
||||
|
||||
Reference in New Issue
Block a user