13 Commits

Author SHA1 Message Date
dillard 31290517ba Add a lightweight schema migration runner, fix missing PHPMailer in prod image
docker-entrypoint-initdb.d only applies db/init.sql, and only on a brand new
volume - existing installs never got db/migrations/*.sql applied unless
someone ran them by hand. This bit twice this session (qr-oss's own init.sql
was missing a migration, and qr-vip's live DB was two migrations behind after
a code deploy). scripts/migrate.php now runs automatically on every container
start via a new entrypoint wrapper, applying any not-yet-recorded migration -
safe to run repeatedly since every migration file already checks
information_schema before altering.

Also caught in the process: Dockerfile.fpm (the production image) never
installed phpmailer/phpmailer, unlike the dev Dockerfile - meaning
self-registration's password email has been silently fatal-erroring in
production since the nginx+php-fpm switch.
2026-07-15 01:19:47 +02:00
dillard d169b85f54 Fold email columns into init.sql, drop the admin/admin demo mention
Fresh installs (docker compose up -d --build, exactly what the README
tells a new self-hoster to run) never got the email/must_set_email/
self_registered_at columns added in migration 006 - only init.sql runs
automatically, migrations always need a manual step, and this one
never got folded into the base schema. A genuine first-time clone
would have broken immediately on login. Verified with a real fresh
volume: seed superadmin now correctly walks through forced password
change -> forced email set -> dashboard, no manual migration needed.

Also: qr.ensembia.com's README/About text still pointed at the
shared admin/admin demo login - replaced with a link to register.php
now that self-registration is live (the shared account has been
removed from the live instance).
2026-07-14 15:58:47 +02:00
dillard 9f10c8ffb5 Fix mail config not reaching vm420 + let admins view/set a user's email
docker-compose.yml (the file vm420 actually runs, not
docker-compose.prod.yml) hardcoded MAIL_HOST=mailhog and
MAIL_SMTP_AUTH=false with no way to override from .env, and never
passed MAIL_USERNAME/MAIL_PASSWORD through at all - so setting real
values in .env silently had no effect on the running container.
Registrations succeeded and created accounts, but the password email
was never actually sent (PHPMailer tried to reach a nonexistent
"mailhog" host inside vm420's network). Switched to
${MAIL_HOST:-mailhog}-style fallbacks, same pattern already used for
DATABASE_PASSWORD, so local dev still defaults to mailhog for free
while a real .env override now actually takes effect.

Separately: the admin-panel user form never had an email field at
all (only register.php could set one) - a superadmin had no way to
view or correct a self-registered user's email address. Added the
field (optional, uniqueness-checked, defaults to forcing
must_set_email on next login if left blank) to form_users.php,
Users::addUser()/editUser(), and the users list/table.
2026-07-14 12:47:28 +02:00
dillard a692304748 Add self-registration: free accounts via email + self-hosted CAPTCHA
New public register.php flow: email + a GD-rendered math CAPTCHA (no
third-party service), a mailed temporary password doubling as email
verification, forced password change on first login. Gated behind a
new ALLOW_SELF_REGISTRATION toggle (default off).

Login moves from username to email (falls back to username for
pre-migration accounts without one yet, mirroring qr-vip's existing
migration 006 pattern) - self-registration needs email as the
identifier. New set_email.php interstitial for legacy accounts.

Adds a small PHPMailer-based Mailer class (SMTP, with an
unauthenticated-relay option via MAIL_SMTP_AUTH=false) since no mail
infrastructure existed in this app before.
2026-07-14 04:05:25 +02:00
dillard e66f3a0360 Switch DB and qr-storage from named volumes to bind mounts
docker compose down -v silently deletes named volumes with no
confirmation - not acceptable now that this holds real user/customer
data. Bind mounts under ./data/ are never touched by down -v, and give
a fixed host path that's simple to include in a backup routine later.
2026-07-13 16:48:36 +02:00
dillard 0bde9ef7c9 Move filename/style options after the type-specific content fields
qrcode_options.php (foreground/background, style presets, frame text,
filename/format/owner) was included at the top of every static QR type
form, ahead of the actual content fields (address, phone number, vCard
name, etc). Moved it to just before the submit button across all 16
static form templates, so the fields you actually came to fill in are
first and the shared styling/filename options come after.
2026-07-13 16:13:09 +02:00
dillard c1da4a95c3 Warn before the idle-timeout logs a user out mid-form
Session timeout (30 min) only resets on a real PHP page load, but the
Location QR type's address search runs entirely client-side against
Nominatim, so a slow fill-in can silently expire the session and bounce
the user back to login, losing the form. Adds a toast that appears a
couple of minutes before expiry with a "stay logged in" button that
pings session_ping.php to refresh last_activity without navigating away.
2026-07-12 14:14:27 +02:00
dillard 96e2e5f475 Correct README wording for role-based access and VIP edition
The 'user' role description implied OSS users can get per-account
create rights - that's a VIP-only feature. Also replaced the VIP
edition blurb with clearer wording about what the subscription
actually unlocks.
2026-07-12 09:36:40 +02:00
dillard e154e92c1a Add country field to vCard QR codes
Also fixes the ADR component order to match the vCard 4.0 spec
(pobox;ext;street;locality;region;code;country) - postcode and state
were previously swapped.
2026-07-12 09:36:14 +02:00
dillard fba327f5d6 Prevent a super admin from changing their own access level
editUser() let a super user submit any type for any target, including
themselves - accidentally downgrading your own account could lock you
out of admin functions. Self-edits now keep the existing type
regardless of what was submitted; the type radios are disabled in the
UI for that case with an explanatory note.
2026-07-12 09:36:04 +02:00
dillard 8ba999a301 Use logo-on-white PWA icons instead of the transparent variant
dist/img/icon-192.png and icon-512.png had a fully transparent
background, which some Android launchers render poorly (adaptive
icon masking). Swapped in the approved logo-on-white PNGs from the
brand kit.
2026-07-12 09:35:45 +02:00
dillard 62ac0c93a1 Fix GD build missing FreeType/JPEG support
libfreetype6-dev and libjpeg62-turbo-dev were installed as apt
dependencies but docker-php-ext-install gd was never configured with
--with-freetype --with-jpeg, so GD silently built without them.
imagettftext() didn't exist, so frame text under QR codes always fell
back to GD's blocky built-in bitmap font regardless of the font
selected in the UI. JPEG support was missing too as a side effect.
2026-07-12 09:35:32 +02:00
dillard 7d9ae6acd2 Fix nginx 403 on root path when index.php isn't baked into the nginx image
try_files' $uri/ fallback matched the container's document root as an
existing directory and tried to serve a local directory index, but
Dockerfile.nginx only copies static assets (not index.php) into the
nginx image — index.php only exists in the php-fpm container. Without
autoindex, nginx returned 403 for any request to "/". Dropping $uri/
lets it fall straight through to the front-controller fastcgi catch-all.
2026-07-11 16:37:36 +02:00
49 changed files with 859 additions and 68 deletions
+16
View File
@@ -13,3 +13,19 @@ DATABASE_PREFIX=
DATABASE_CHARSET=utf8 DATABASE_CHARSET=utf8
MYSQL_ROOT_PASSWORD=change-me-to-a-strong-root-password 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
+1
View File
@@ -3,3 +3,4 @@
.idea .idea
.DS_Store .DS_Store
.env .env
/data/
+11
View File
@@ -75,6 +75,7 @@ RUN cd /opt \
&& rm /opt/composer-setup.php /opt/composer-setup.sha384sum && rm /opt/composer-setup.php /opt/composer-setup.sha384sum
RUN docker-php-source extract RUN docker-php-source extract
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
RUN docker-php-ext-install pdo_mysql zip exif pcntl gd RUN docker-php-ext-install pdo_mysql zip exif pcntl gd
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
RUN docker-php-ext-install gettext && docker-php-ext-enable gettext RUN docker-php-ext-install gettext && docker-php-ext-enable gettext
@@ -94,12 +95,22 @@ RUN cp -R ./php-qrcode/src /var/www/html/
WORKDIR /var/www/html WORKDIR /var/www/html
RUN composer update RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./ COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chmod 755 *; RUN chmod 755 *;
# Qr code storage lives outside the document root so files can only be reached through # 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. # 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 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 EXPOSE 80
CMD ["php", "-S", "0.0.0.0:80"] CMD ["php", "-S", "0.0.0.0:80"]
+10
View File
@@ -51,7 +51,9 @@ RUN cp -R ./php-qrcode/src /var/www/html/
WORKDIR /var/www/html WORKDIR /var/www/html
RUN composer update RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./ COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chown -R www-data:www-data /var/www/html \ RUN chown -R www-data:www-data /var/www/html \
&& find /var/www/html -type f -exec chmod 644 {} \; \ && find /var/www/html -type f -exec chmod 644 {} \; \
&& find /var/www/html -type d -exec chmod 755 {} \; && 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 \ && chown -R www-data:www-data /var/www/qrcode-storage \
&& chmod -R 775 /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 EXPOSE 9000
CMD ["php-fpm"] CMD ["php-fpm"]
+11 -6
View File
@@ -8,10 +8,13 @@ of the original [PHP Dynamic Qr code](https://github.com/giandonatoinverso/PHP-D
project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/). project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
- **Try it free:** [qr.ensembia.com](https://qr.ensembia.com) - fully functional OSS test - **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 instance. [Register your own free account](https://qr.ensembia.com/register.php)
account `admin` / `admin` in the meantime. (email + a self-hosted CAPTCHA, no third-party service) - no shared demo login needed.
- **Commercial VIP edition** (self-service create-rights, logo-embedded QR codes): - **Commercial VIP edition:** the ability to give sub-users the ability to create
[www.qrforge.eu](https://www.qrforge.eu). 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
fund our open-source project, a small fee (€49/year subscription per organisation
("tenant")) is requested for this. [www.qrforge.eu](https://www.qrforge.eu).
- **Self-host it yourself:** this repository, MIT-licensed. - **Self-host it yourself:** this repository, MIT-licensed.
# Features # Features
@@ -31,7 +34,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
- Installable as a PWA - Installable as a PWA
- Role-based access: `super` (full access + user management), `admin` - Role-based access: `super` (full access + user management), `admin`
(scoped to their own codes and sub-users), `user` (read-only, with (scoped to their own codes and sub-users), `user` (read-only, with
optional per-account create rights and view toggles set by an admin) optional view toggles set by an admin; per-account create rights are
a VIP-edition feature, not available in this OSS version)
- Dashboard with QR/scan statistics and a 7-day activity chart - Dashboard with QR/scan statistics and a 7-day activity chart
- CSRF protection, login rate limiting, session hardening, audit log - CSRF protection, login rate limiting, session hardening, audit log
- Docker Compose setup, both a dev image and a production Nginx + PHP-FPM image - Docker Compose setup, both a dev image and a production Nginx + PHP-FPM image
@@ -39,7 +43,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
# What is included # What is included
- PHP 8.4 application source - 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) - Docker Compose files (dev and production)
- CSS/JS assets - CSS/JS assets
+9 -3
View File
@@ -19,14 +19,20 @@ CREATE TABLE IF NOT EXISTS `users` (
`can_view_static` tinyint(1) NOT NULL DEFAULT 0, `can_view_static` tinyint(1) NOT NULL DEFAULT 0,
`can_view_dynamic` tinyint(1) NOT NULL DEFAULT 0, `can_view_dynamic` tinyint(1) NOT NULL DEFAULT 0,
`owner_admin_id` int(25) DEFAULT NULL, `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`), PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=0 ; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=0 ;
-- Default super admin account. Credentials: superadmin / superadmin -- Default super admin account. Credentials: superadmin / superadmin
-- must_change_password=1 forces a password change on first login (see Fase 1 hardening). -- 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 -- must_set_email=1: no email yet, so login falls back to username until it's set (same
(1, 'superadmin', '$2y$10$xpZc5KC.aU2XHkcqhuZGFuAnqmtL4Unt8MysOyylceq.19XIyoZpG', NULL, NULL, NULL, 'super', 1, NULL); -- 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` ( CREATE TABLE IF NOT EXISTS `dynamic_qrcodes` (
`id` int(10) NOT NULL AUTO_INCREMENT, `id` int(10) NOT NULL AUTO_INCREMENT,
+51
View File
@@ -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
View File
@@ -31,11 +31,20 @@ services:
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}" DATABASE_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}"
DATABASE_PREFIX: "${DATABASE_PREFIX:-}" DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}" 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: depends_on:
qrforge-db: qrforge-db:
condition: service_healthy condition: service_healthy
volumes: volumes:
- qrforge_qrcode_storage:/var/www/qrcode-storage - ./data/qrcode-storage:/var/www/qrcode-storage
networks: networks:
- qrforge-network - qrforge-network
@@ -43,7 +52,7 @@ services:
image: "mysql:8.0" image: "mysql:8.0"
restart: "unless-stopped" restart: "unless-stopped"
volumes: volumes:
- qrforge_db_data:/var/lib/mysql - ./data/mysql:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment: environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}" MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
@@ -58,10 +67,6 @@ services:
networks: networks:
- qrforge-network - qrforge-network
volumes:
qrforge_db_data:
qrforge_qrcode_storage:
networks: networks:
qrforge-network: qrforge-network:
driver: bridge driver: bridge
+22 -6
View File
@@ -15,13 +15,33 @@ services:
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}" DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}"
DATABASE_PREFIX: "${DATABASE_PREFIX:-}" DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}" 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: ports:
- "80:80" - "80:80"
depends_on: depends_on:
qrforge-db: qrforge-db:
condition: service_healthy condition: service_healthy
volumes: 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: networks:
- qrforge-network - qrforge-network
@@ -29,7 +49,7 @@ services:
image: "mysql:8.0" image: "mysql:8.0"
restart: "unless-stopped" restart: "unless-stopped"
volumes: volumes:
- qrforge_db_data:/var/lib/mysql - ./data/mysql:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment: environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?zet MYSQL_ROOT_PASSWORD in .env}" MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?zet MYSQL_ROOT_PASSWORD in .env}"
@@ -44,10 +64,6 @@ services:
networks: networks:
- qrforge-network - qrforge-network
volumes:
qrforge_db_data:
qrforge_qrcode_storage:
networks: networks:
qrforge-network: qrforge-network:
driver: bridge driver: bridge
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
php /var/www/html/scripts/migrate.php
exec docker-php-entrypoint "$@"
+1 -1
View File
@@ -11,7 +11,7 @@ server {
add_header Referrer-Policy "same-origin" always; add_header Referrer-Policy "same-origin" always;
location / { location / {
try_files $uri $uri/ /index.php$is_args$args; try_files $uri /index.php$is_args$args;
} }
location ~ \.php$ { location ~ \.php$ {
+3 -2
View File
@@ -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 This is the free, open-source (MIT) edition of QRForge. It runs unmodified
as a live, fully functional try-out at as a live, fully functional try-out at
<a href="https://qr.ensembia.com" target="_blank">qr.ensembia.com</a> - <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 <a href="https://qr.ensembia.com/register.php" target="_blank">register your
demo account <code>admin</code> / <code>admin</code> in the meantime. own free account</a> there (email + a self-hosted CAPTCHA, no third-party
service).
</p> </p>
<p> <p>
The commercial VIP edition (paid create-rights and logo-embedded QR codes) The commercial VIP edition (paid create-rights and logo-embedded QR codes)
+19 -8
View File
@@ -6,17 +6,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
{ {
csrf_verify_or_die(); 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'); $password = filter_input(INPUT_POST, 'password');
$remember = filter_input(INPUT_POST, 'remember'); $remember = filter_input(INPUT_POST, 'remember');
if (!$username || !$password) { if (!$identifier || !$password) {
$_SESSION['login_failure'] = 'Invalid username or password'; $_SESSION['login_failure'] = 'Invalid email or password';
header('Location: login.php'); header('Location: login.php');
exit; 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.'; $_SESSION['login_failure'] = 'Too many failed login attempts. Try again in 15 minutes.';
header('Location: login.php'); header('Location: login.php');
exit; exit;
@@ -25,12 +28,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
// Get DB instance. // Get DB instance.
$db = getDbInstance(); $db = getDbInstance();
$db->where('username', $username); $db->where('email', $identifier);
$row = $db->getOne('users'); $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'])) 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. // Voorkom session fixation: nieuwe sessie-id na een geslaagde login.
session_regenerate_id(true); session_regenerate_id(true);
@@ -40,6 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
$_SESSION['user_id'] = $row['id']; $_SESSION['user_id'] = $row['id'];
$_SESSION['username'] = $row['username']; $_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']); $_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_static'] = !empty($row['can_view_static']);
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']); $_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row); $_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row);
@@ -86,8 +97,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
} }
else else
{ {
qr_record_login_attempt($username, false); qr_record_login_attempt($identifier, false);
$_SESSION['login_failure'] = 'Invalid username or password'; $_SESSION['login_failure'] = 'Invalid email or password';
header('Location: login.php'); header('Location: login.php');
exit; exit;
} }
+41
View File
@@ -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);
+11
View File
@@ -16,3 +16,14 @@ define('DATABASE_CHARSET', getenv('DATABASE_CHARSET') ?: 'utf8');
define('TYPE', getenv('TYPE') ?: 'local'); define('TYPE', getenv('TYPE') ?: 'local');
define('BASE_URL', getenv('BASE_URL') ?: 'http://localhost'); 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('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');
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+21 -3
View File
@@ -12,6 +12,20 @@
</div> </div>
</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="col-sm-4">
<div class="form-group"> <div class="form-group">
<label for="password">Password *</label> <label for="password">Password *</label>
@@ -26,25 +40,29 @@
</div> </div>
<?php if ($_SESSION['type'] === 'super'): ?> <?php if ($_SESSION['type'] === 'super'): ?>
<?php $editing_self = $edit && (int) $user['id'] === (int) $_SESSION['user_id']; ?>
<div class="col-sm-4"> <div class="col-sm-4">
<label for="user-type">User type *</label> <label for="user-type">User type *</label>
<div class="form-group"> <div class="form-group">
<div class="radio"> <div class="radio">
<label class="radio"> <label class="radio">
<input type="radio" name="type" value="super" required="required" <?php echo ($edit && $user['type'] =='super') ? "checked": "" ; ?>/> Super admin</label> <input type="radio" name="type" value="super" required="required" <?php echo ($edit && $user['type'] =='super') ? "checked": "" ; ?> <?php echo $editing_self ? "disabled" : ""; ?>/> Super admin</label>
</div> </div>
<div class="radio"> <div class="radio">
<label class="radio"> <label class="radio">
<input type="radio" name="type" value="admin" required="required" <?php echo ($edit && $user['type'] =='admin') ? "checked": "" ; ?>/> Admin</label> <input type="radio" name="type" value="admin" required="required" <?php echo ($edit && $user['type'] =='admin') ? "checked": "" ; ?> <?php echo $editing_self ? "disabled" : ""; ?>/> Admin</label>
</div> </div>
<div class="radio"> <div class="radio">
<label class="radio"> <label class="radio">
<input type="radio" name="type" value="user" required="required" id="type-user" <?php echo ($edit && $user['type'] =='user') ? "checked": "" ; ?>/> User (read-only)</label> <input type="radio" name="type" value="user" required="required" id="type-user" <?php echo ($edit && $user['type'] =='user') ? "checked": "" ; ?> <?php echo $editing_self ? "disabled" : ""; ?>/> User (read-only)</label>
</div> </div>
</div> </div>
<?php if ($editing_self): ?>
<small class="form-text text-muted">You can't change your own access level.</small>
<?php endif; ?>
</div> </div>
<div class="col-sm-12 mt-2" id="user-view-toggles"> <div class="col-sm-12 mt-2" id="user-view-toggles">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=2fa" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=2fa" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -38,6 +37,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -62,6 +61,7 @@
})(); })();
</script> </script>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=bitcoin" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=bitcoin" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -47,6 +46,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<small class="form-text text-muted mb-2"> <small class="form-text text-muted mb-2">
@@ -26,6 +25,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=bookmark" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=bookmark" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -20,6 +19,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=email" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=email" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -28,6 +27,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=event" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=event" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-4"> <div class="col-sm-4">
<div class="form-group"> <div class="form-group">
@@ -72,6 +71,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=location" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=location" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-8"> <div class="col-sm-8">
<div class="form-group" style="position: relative;"> <div class="form-group" style="position: relative;">
@@ -30,6 +29,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=paypal" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=paypal" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -100,6 +99,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=phone" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=phone" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -23,6 +22,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=skype" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=skype" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-4"> <div class="col-sm-4">
<div class="form-group"> <div class="form-group">
@@ -9,6 +8,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=sms" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=sms" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -30,6 +29,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=text" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=text" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-6"> <div class="col-sm-6">
<div class="form-group"> <div class="form-group">
@@ -9,6 +8,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+8 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=vcard" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=vcard" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<!-- First row --> <!-- First row -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
@@ -139,9 +138,17 @@
</div> </div>
</div> </div>
<div class="col-6 col-md-3">
<div class="form-group">
<label>Country</label>
<input type="text" name="country" value="" placeholder="" class="form-control">
</div> </div>
</div> </div>
</div>
</div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=whatsapp" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=whatsapp" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -30,6 +29,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=wifi" method="post" id="static_form" enctype="multipart/form-data"> <form class="form" action="static_qrcode.php?type=wifi" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms --> <!-- Input forms -->
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
@@ -33,6 +32,7 @@
</div> </div>
</div> </div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
+4 -2
View File
@@ -6,8 +6,9 @@
<thead> <thead>
<tr> <tr>
<th width="5%">ID</th> <th width="5%">ID</th>
<th width="45%">Username</th> <th width="25%">Username</th>
<th width="40%">Type</th> <th width="30%">Email</th>
<th width="30%">Type</th>
<th width="10%">Actions</th> <th width="10%">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -16,6 +17,7 @@
<tr> <tr>
<td><?php echo $row['id']; ?></td> <td><?php echo $row['id']; ?></td>
<td><?php echo htmlspecialchars($row['username']); ?></td> <td><?php echo htmlspecialchars($row['username']); ?></td>
<td><?php echo htmlspecialchars($row['email'] ?? ''); ?></td>
<td><?php echo htmlspecialchars($row['type']); ?></td> <td><?php echo htmlspecialchars($row['type']); ?></td>
<td> <td>
<!-- EDIT --> <!-- EDIT -->
+1
View File
@@ -12,3 +12,4 @@ require_once __DIR__ . '/security.php';
qr_session_start(); qr_session_start();
qr_enforce_session_timeout(); qr_enforce_session_timeout();
qr_enforce_password_change(); qr_enforce_password_change();
qr_enforce_email_set();
+60
View File
@@ -36,3 +36,63 @@
navigator.serviceWorker.register('service-worker.js'); navigator.serviceWorker.register('service-worker.js');
} }
</script> </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>
+36 -1
View File
@@ -66,8 +66,11 @@ function qr_enforce_password_change() {
return; 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)); $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)) { if (in_array($current_script, $exempt, true)) {
return; return;
@@ -77,6 +80,26 @@ function qr_enforce_password_change() {
exit; 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 * 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 * Rate limiting op login
*/ */
+56
View File
@@ -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;
}
}
}
+3 -3
View File
@@ -171,7 +171,7 @@ class StaticQrcode {
* create a qr code of type "vcard" * create a qr code of type "vcard"
* *
*/ */
public function vcardQrcode($fullname, $nickname, $email, $website, $phone, $home_phone, $work_phone, $company, $role, $categories, $note, $photo, $address, $city, $postcode, $state) public function vcardQrcode($fullname, $nickname, $email, $website, $phone, $home_phone, $work_phone, $company, $role, $categories, $note, $photo, $address, $city, $postcode, $state, $country)
{ {
if($fullname != NULL && $phone != NULL){ if($fullname != NULL && $phone != NULL){
@@ -188,7 +188,7 @@ class StaticQrcode {
$vcard->categories($categories); $vcard->categories($categories);
$vcard->note($note); $vcard->note($note);
$vcard->photo($photo); $vcard->photo($photo);
$vcard->address($address, $city, $postcode, $state); $vcard->address($address, $city, $state, $postcode, $country);
$vcard->create(); $vcard->create();
$this->sData = $vcard->get(); $this->sData = $vcard->get();
@@ -202,7 +202,7 @@ class StaticQrcode {
$this->sContent .= '<div class="col-sm-4">'; $this->sContent .= '<div class="col-sm-4">';
$this->sContent .= '<strong>Phone:</strong> '.$phone.'<br>'.'<strong>Home Phone:</strong> '.$home_phone.'<br>'.'<strong>Work phone:</strong> '.$work_phone.'<br>'.'<strong>Address:</strong> '.$address.'&nbsp;'.$city.'&nbsp;'.$postcode.'&nbsp;'.$state.'</div>'; $this->sContent .= '<strong>Phone:</strong> '.$phone.'<br>'.'<strong>Home Phone:</strong> '.$home_phone.'<br>'.'<strong>Work phone:</strong> '.$work_phone.'<br>'.'<strong>Address:</strong> '.$address.'&nbsp;'.$city.'&nbsp;'.$postcode.'&nbsp;'.$state.'&nbsp;'.$country.'</div>';
$this->sContent .= '</div>'; $this->sContent .= '</div>';
+150 -1
View File
@@ -1,5 +1,6 @@
<?php <?php
require_once 'config/config.php'; require_once 'config/config.php';
require_once BASE_PATH . '/lib/Mailer/Mailer.php';
class Users class Users
{ {
@@ -31,6 +32,23 @@ class Users
return null; 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'); $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) { if (!isset($input_data['password']) || strlen($input_data['password']) < 10) {
$this->failure('Password must be at least 10 characters long.', 'Location: user.php'); $this->failure('Password must be at least 10 characters long.', 'Location: user.php');
} }
$data_to_db["username"] = $input_data["username"]; $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['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
$data_to_db["type"] = $requested_type; $data_to_db["type"] = $requested_type;
$data_to_db['owner_admin_id'] = $owner_admin_id; $data_to_db['owner_admin_id'] = $owner_admin_id;
@@ -130,6 +156,16 @@ class Users
if ($db->count >= 1) if ($db->count >= 1)
$this->failure('Username already exists'); $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); $last_id = $db->insert('users', $data_to_db);
if ($last_id) { 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. * Edit user.
* *
@@ -160,13 +279,26 @@ class Users
'edit' => "true", 'edit' => "true",
)); ));
$requested_type = $_SESSION['type'] === 'admin' ? 'user' : ($input_data['type'] ?? ''); $is_self_edit = (int) $input_data['id'] === (int) $_SESSION['user_id'];
// A user editing their own account keeps their current type, even if a
// different value was submitted - prevents accidentally (or deliberately)
// locking yourself out by downgrading your own access level.
$requested_type = $_SESSION['type'] === 'admin'
? 'user'
: ($is_self_edit ? $target['type'] : ($input_data['type'] ?? ''));
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $requested_type); $validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $requested_type);
if ($validation_error !== null) { if ($validation_error !== null) {
$this->failure($validation_error, 'Location: user.php?'.$query_string); $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) { 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); $this->failure('Password must be at least 10 characters long.', 'Location: user.php?'.$query_string);
} }
@@ -180,8 +312,25 @@ class Users
$this->failure('Username already exists', 'Location: user.php?'.$query_string); $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["username"] = $input_data["username"];
$data_to_db["type"] = $requested_type; $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_static'] = !empty($input_data['can_view_static']) ? 1 : 0;
$data_to_db['can_view_dynamic'] = !empty($input_data['can_view_dynamic']) ? 1 : 0; $data_to_db['can_view_dynamic'] = !empty($input_data['can_view_dynamic']) ? 1 : 0;
+3 -2
View File
@@ -43,10 +43,11 @@ class vCard
* *
* @return self * @return self
*/ */
public function address($sAddress, $sCity, $sPostcode, $sState) public function address($sAddress, $sCity, $sState, $sPostcode, $sCountry)
{ {
// Component order per vCard 4.0 ADR: pobox;ext;street;locality;region;code;country
$this->sData .= 'ADR:;;'.$sAddress.';'; $this->sData .= 'ADR:;;'.$sAddress.';';
$this->sData .= $sCity.';'.$sPostcode.';'.$sState."\n"; $this->sData .= $sCity.';'.$sState.';'.$sPostcode.';'.$sCountry."\n";
return $this; return $this;
} }
+7 -1
View File
@@ -40,6 +40,7 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
$_SESSION['type'] = $row['type']; $_SESSION['type'] = $row['type'];
$_SESSION['username'] = $row['username']; $_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']); $_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_static'] = !empty($row['can_view_static']);
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']); $_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row); $_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"> <div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p> <p class="login-box-msg">Sign in to start your session</p>
<?php include './includes/flash_messages.php'; ?>
<form method="POST" action="authenticate.php"> <form method="POST" action="authenticate.php">
<?php echo csrf_field(); ?> <?php echo csrf_field(); ?>
<div class="input-group mb-3"> <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-append">
<div class="input-group-text"> <div class="input-group-text">
<span class="fa fa-user"></span> <span class="fa fa-user"></span>
@@ -133,6 +136,9 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
</div> </div>
<?php endif; ?> <?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> </div>
<!-- /.login-card-body --> <!-- /.login-card-body -->
+86
View File
@@ -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>
+72
View File
@@ -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();
+18
View File
@@ -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]);
+89
View File
@@ -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
View File
@@ -65,7 +65,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"]) && !isset($_P
case 'location': $static_qrcode_instance->locationQrcode($_POST['latitude'], $_POST['longitude']); case 'location': $static_qrcode_instance->locationQrcode($_POST['latitude'], $_POST['longitude']);
break; break;
case 'vcard': $static_qrcode_instance->vcardQrcode($_POST['full_name'], $_POST['nickname'], $_POST['email'], $_POST['website'], $_POST['phone'], $_POST['home_phone'], $_POST['work_phone'], $_POST['company'], $_POST['role'], $_POST['categories'], $_POST['note'], $_POST['photo'], $_POST['address'], $_POST['city'], $_POST['post_code'], $_POST['state']); case 'vcard': $static_qrcode_instance->vcardQrcode($_POST['full_name'], $_POST['nickname'], $_POST['email'], $_POST['website'], $_POST['phone'], $_POST['home_phone'], $_POST['work_phone'], $_POST['company'], $_POST['role'], $_POST['categories'], $_POST['note'], $_POST['photo'], $_POST['address'], $_POST['city'], $_POST['post_code'], $_POST['state'], $_POST['country']);
break; break;
case 'event': $static_qrcode_instance->eventQrcode($_POST['title'], $_POST['start'], $_POST['end'], $_POST['timezone'], $_POST['location'], $_POST['description'], $_POST['url']); case 'event': $static_qrcode_instance->eventQrcode($_POST['title'], $_POST['start'], $_POST['end'], $_POST['timezone'], $_POST['location'], $_POST['description'], $_POST['url']);
+1 -1
View File
@@ -9,7 +9,7 @@ $users = new Users();
if (!in_array($_SESSION['type'], ['super', 'admin'], true)) 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'); $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'); $search_fields = array('username');
require_once BASE_PATH . '/includes/search_order.php'; require_once BASE_PATH . '/includes/search_order.php';
$page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? 1; $page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? 1;