29 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
dillard 43504d92dc Fix stale GitHub repo URL on About page (pre-dates the repo rename) 2026-07-11 13:04:14 +02:00
dillard 402bda0fbf Fix nginx serving no static assets in production compose (ported from qr-vip)
Same bug as qr-vip (2026-07-11): nginx and php-fpm are separate
containers in docker-compose.prod.yml, but nginx had no copy of the
static assets it needs to serve directly, so they'd fall through
try_files to the login-gated index.php. Not currently visible on
qr.ensembia.com (which runs docker-compose.yml, the single-container dev
variant, not this prod file) but would hit anyone using the production
compose as documented. New Dockerfile.nginx copies in dist/, plugins/,
manifest.json, service-worker.js, and favicon.ico.
2026-07-11 12:16:13 +02:00
dillard b5bfd9a4d8 Rename internal Docker service/network/volume identifiers to qrforge-*
Cosmetic-only rename, no functional/data-layer change: service names
(php-dynamic-qrcode -> qrforge-app, php-dynamic-qrcode-db -> qrforge-db),
the network (php-dynamic-qrcode-network -> qrforge-network), and the two
named volumes (php_dynamic_qrcode_db_data -> qrforge_db_data,
php_dynamic_qrcode_saved_qrcode_data -> qrforge_qrcode_storage), plus
matching references in nginx.conf's fastcgi_pass and .env.example's
DATABASE_HOST default. Database name itself ('qrcode') intentionally
left unchanged per user request.

Volume rename requires an explicit data migration on already-deployed
hosts (a bare name change would otherwise attach a fresh empty volume) -
handled separately as part of this same deploy, not by this commit.
2026-07-11 12:02:12 +02:00
dillard def7b81a11 Fix IDOR: getQrcode/editQrcode/deleteQrcode had no ownership check
Found while auditing the owner-scope SQL fix from the last commit:
getQrcode() queried purely by id with no scope applied at all (not even
the buggy old form), and editQrcode()/deleteQrcode() both call
getQrcode() first but then run their own unscoped where('id', $id) for
the actual update/delete. Net effect: any authenticated admin/user with
edit or delete rights could view, edit, or delete *any other tenant's*
qr code just by guessing/incrementing the id - in the static/dynamic
edit forms, the bulk download/delete endpoint, and the single delete
flow alike.

Fixed by applying qr_apply_owner_scope() in getQrcode() (covers the
edit-prefill and delete-lookup paths, and exits via failure() before
reaching the actual write query if out of scope) and adding it directly
to the update/delete queries in editQrcode()/deleteQrcode() too, for
defense in depth rather than relying solely on the earlier check.
Verified with a two-tenant scenario (separate admin accounts): before
the fix admin B could view/edit-prefill/delete admin A's qr code, after
the fix all three are correctly blocked (404 / "not found" / delete is
silently a no-op) and admin A's code is untouched.

Users.php and presets.php were checked too and already scope correctly
via different, unaffected patterns - this was isolated to the two
Qrcode classes.
2026-07-11 11:40:06 +02:00
dillard 9645ce94e2 Fix stale qr-storage volume mount path in dev docker-compose.yml
Fase 1 hardening (2026-07-08) moved saved QR code storage from
src/saved_qrcode/ to /var/www/qrcode-storage/ (outside the webroot,
SAVED_QRCODE_DIRECTORY in config.php), but docker-compose.yml's volume
mount was never updated and still pointed at the old path
(/var/www/html/saved_qrcode). The named volume was therefore mounted
somewhere the app never wrote to - every actual qr code image the app
generates at /var/www/qrcode-storage/ lived only in the container's
ephemeral filesystem and was silently lost on every container
recreation, while the qrcode_storage volume itself stayed permanently
empty. DB rows (filenames/content) were never affected, only the
generated image files.

Confirmed as the cause of qr.ensembia.com's "old and new QR codes not
showing in the list" report: qrcode_image.php's is_file() check failed
because the file genuinely wasn't there anymore. docker-compose.prod.yml
already had the correct path - only the dev compose file (what
qr.ensembia.com actually runs) had this bug.
2026-07-11 09:47:13 +02:00
dillard 164872de4d Fix owner-scope query bug returning the wrong qr code by id
qr_apply_owner_scope() used where('id_owner', X) + orWhere('id_owner', NULL,
'IS'). Any caller that had already added its own where('id', $id) before
calling it (qrcode_image.php, bulk_action.php's download path) ended up
with "WHERE id = ? AND id_owner = ? OR id_owner IS NULL" - AND binds
tighter than OR in SQL, so this was actually "(id = ? AND id_owner = ?) OR
(id_owner IS NULL)", which silently detaches the id filter and returns an
arbitrary null-owner row instead (or nothing, if that row's file is
missing) whenever the intended row didn't have a null owner. This is what
broke qr code thumbnails/downloads on qr.ensembia.com for scoped (non-super)
accounts, old and newly-created codes alike - reproduced and confirmed
fixed with a local before/after query dump, then with a live HTTP request
scenario (two accounts, two codes, one null-owner).

Fixed by building the scope as a single parenthesized raw condition
instead of two separate where() calls, so it can't be split apart by
whatever the caller already added to the query.

Also this session, per user feedback on the OSS rebrand review:
- Format moved back next to Filename in both qr-creation forms (was
  separated from it when Filename got grouped with Owner last session).
- README/About now mention the temporary admin/admin demo account instead
  of the not-yet-built self-registration flow.
2026-07-11 09:39:18 +02:00
Dillard Blom ad5a5df81e Update LICENSE 2026-07-11 04:37:49 +02:00
dillard 23e8d9b765 Rebrand to QRForge (local only - do not push before domain confirmed)
Applies the approved QRForge brand kit throughout the app:
- New logo/icon SVGs, favicon set (ico/svg/png), apple-touch-icon, all
  copied from the approved brand kit into src/dist/img/brand/. Old
  Symbol_WhiteBlue.png/DynamicQRCode_Original.png removed (unused after
  the swap).
- Sidebar brand image/text, login and change-password page logos, all
  <title> tags, manifest.json name/theme-color, and the PWA icons
  (dist/img/icon-192.png/icon-512.png, same filenames so no other
  reference needed to change) updated to QRForge branding and the
  #2563EB brand blue.
- New about.php page (+ sidebar link): credits the original upstream
  fork (Giandonato Inverso) and chillerlan/php-qrcode, links to the free
  qr.ensembia.com try-out, the commercial www.qrforge.eu product page,
  and this GitHub repo for self-hosters.
- Footer now reads "QRForge" + "About / credits" + "Version 3.0"
  (replaces the inherited "PHP Qrcode Generator by Giandonato Inverso" /
  "Version 2.3.0" line - full credit moved to the About page instead).
- README.md rewritten: current feature set (all 16 static qr types,
  presets, scanner, PWA, location search, roles), qr.ensembia.com as the
  free try-out, www.qrforge.eu as the commercial product page, corrected
  Docker Compose setup steps (.env is required now, the old README still
  described the single-file demo setup from the original upstream fork).

IMPORTANT: not pushed to origin/gitea. Per user instruction, no push
until qrforge.eu domain registration is confirmed.
2026-07-11 02:48:05 +02:00
dillard a8f55506c8 Fase 3 feedback round 4: group filename with owner in the qr forms
Filename now sits in the same row as Owner instead of the crowded
top row shared with format/frame text/frame font/frame font size/icon.
Freeing that column lets those fields shift left and use the space
better. Applied to both the shared static-form partial
(qrcode_options.php) and the dynamic-form's separate copy of the same
fields (form_dynamic_add.php) - the two have diverged since Fase 3 and
don't share markup. Edit forms (form_static_edit.php/form_dynamic_edit.php)
already had filename/owner side by side, no change needed there.
2026-07-11 02:40:22 +02:00
dillard 89bf472867 Fase 3 feedback round 3: sidebar icons, thumbnail preview, location search
- Sidebar submenu bullets (List all/Add new/Batch create) swapped from
  far fa-circle to fas fa-angle-right - the outlined circle read as an
  unchecked radio button, per feedback.
- List-page qr thumbnails now sit in a fixed 100x100 box with
  object-fit:contain instead of width/height attrs, so taller images
  (e.g. icon-above-qr) no longer get squashed into a square. Thumbnails
  are now clickable, opening a shared Bootstrap modal with the full-size
  image (same data-toggle/data-target pattern already used for the
  delete-confirmation modal).
- Location QR form gets an address search box backed by OpenStreetMap
  Nominatim (dist/js/location-search.js): free-text query, pick a result,
  it fills in latitude/longitude. No API key needed; the browser talks to
  nominatim.openstreetmap.org directly, called out in the field's help
  text since queries leave the self-hosted server.
2026-07-11 02:18:34 +02:00
dillard 7276744f32 Fase 3 feedback round 2: live color preview, bigger top icon, dashboard links
- Style preview now updates immediately when picking a color via the
  colorpicker widget. It sets the input value through jQuery's synthetic
  trigger(), which a native addEventListener('change', ...) never sees -
  bound the listener through jQuery instead so both native and
  colorpicker-driven changes refresh the preview.
- Icon-above-QR max height raised from 25% to 62.5% of QR height (~2.5x
  bigger per feedback); the existing 60%-width cap now becomes the
  practical limit for most icons. Verified generated QR still decodes.
- Dashboard's "Dynamic Qr codes" and "Static QR codes" info-boxes now link
  to their list pages. "Total qr codes"/"Total Scans" left as-is - no
  combined-list or scan-report page exists yet to link them to.
- Random-style button now has a "Randomize" label to match its row-mates
  (Load preset/Save as preset/Style preview), instead of an empty spacer.
2026-07-11 02:09:58 +02:00
dillard 23daf9c236 Fase 3 feedback round: style preview, icon upload, font choice, optional bitcoin amount, WPA3
- Default the Owner select to the creator's own account instead of "All", so
  superadmin-created codes no longer silently become visible to every admin
  (the underlying NULL-fallback sharing behavior for an explicit "All" choice
  is unchanged).
- Add a live color/precision/size preview swatch next to the preset picker.
- Let the frame text use a chosen DejaVu font + font size instead of a fixed
  GD bitmap font.
- Add an optional self-uploaded icon rendered above the qr code (not embedded
  in it, so scanability is unaffected).
- Make the Bitcoin qr amount optional; a standing wallet address is useful
  without forcing a one-off amount per code.
- Add a WPA3 option to the WiFi qr encryption select.
- Fix a real bug surfaced while testing the preview/style JS: qrcode_options.php
  was included once per static qr type (16 times on one page) and each
  inclusion re-executed <script src="qrcode-style-tools.js">, so every button
  click fired once per type - e.g. saving one preset wrote 16 duplicate rows,
  and every tab except the first ("Text") had dead random-style/preset
  buttons since only the first DOM match ever got a listener. Moved the
  script include to load once per page and rewrote the JS to scope every
  lookup to the triggering element's own tab-pane/form instead of relying on
  getElementById's first-match behavior.
2026-07-09 23:11:36 +02:00
dillard c3c6f167e0 Fase 3 priority 2: presets, random style, qr scanner, PWA
Preset system: qr_presets table (migration 005) plus a presets.php AJAX
endpoint (list/save/delete, CSRF-protected, scoped to the logged-in user's
own id - presets are personal, never shared across accounts). UI/JS lives in
dist/js/qrcode-style-tools.js.

Random style button: client-side only, fills foreground/background with a
random hex color pair (playful randomize, no contrast/scannability
guarantee).

Qr scanner (scan_qrcode.php): camera or image upload, decoded entirely
client-side via html5-qrcode (CDN, pinned to 2.3.8).

PWA: manifest.json + service-worker.js, icons generated from the existing
DynamicQRCode_Original.png glyph. The service worker only caches static
assets (css/js/images) and deliberately never touches PHP pages, since those
carry CSRF tokens and session-specific content that must never be cached.

Fixes a gap found while testing: qrcode_options.php is only a shared partial
for the static qr forms - the dynamic qr form (form_dynamic_add.php) has its
own separate copy of the foreground/background/level/size/filename/format
fields (pre-existing structure, not something introduced here). That meant
frame_text and the new preset/random-style UI never showed up on the
dynamic qr page. Added the same fields there too, verified with a dynamic qr
plus frame text (150x180px, the expected +30px padding).
2026-07-09 00:49:45 +02:00
dillard 4e9fed755c Fase 3 v1: new qr types, svg export, clipboard, frame text, batch CSV
New static qr types:
- App Link: Android intent:// links (with package + optional browser
  fallback) or a generic custom-scheme URI. iOS Universal Links need no
  special encoding (they're just plain https:// URLs).
- Bluetooth: device name + MAC address. Purely informational, since unlike
  WIFI:/vCard there's no OS-native "scan to pair" convention.

SVG export: already worked (format whitelist/dropdown existed since Fase 1),
verified rather than reimplemented.

Copy-to-clipboard button next to the download button on both qr list tables,
using the Clipboard API against a fetched blob.

Optional frame text label rendered below the qr code via GD after
generation (raster formats only, no-op for svg/eps).

Batch CSV upload (batch_qrcode.php): filename,link rows create dynamic qr
codes with sane defaults, downloadable as a zip. Required refactoring
Qrcode-intchil.php's generation path (previously always redirected/exited
via failure()/success(), which can't run in a loop) into a private
renderAndStore() that throws instead, shared by addQrcode() and the new
addQrcodeBatch(). Qrcode.php's addQrcodeBatch() is a separate, deliberately
duplicated implementation instead, since its generation logic is small
enough that duplication carries less risk than refactoring the working
external-API code path.
2026-07-09 00:03:01 +02:00
dillard 6387d24846 Admin-scoped user accounts, secure qr code storage, PHP 8.4 upgrade
Admin-scoped users (answers: who can create a 'user' account, only super or
also an admin within their own scope?):
- New owner_admin_id column on users (migration 004). NULL means created by
  super (company-wide, previous behavior); otherwise scoped to that admin's
  own codes.
- Users::addUser/editUser/deleteUser now allow an 'admin' session, but force
  type='user' and owner_admin_id to their own id regardless of submitted
  input. user.php/users.php open up to admins with a restricted UI (no type
  picker, listing limited to their own created users).
- New qr_compute_scope_owner_id()/qr_apply_owner_scope()/qr_has_full_visibility()
  helpers in includes/security.php, replacing the ad-hoc type==='admin' checks
  in index.php, dynamic_qrcodes.php, static_qrcodes.php and bulk_action.php.
  A 'user' account created by an admin is now scoped to that admin's codes
  instead of seeing everything company-wide.

Qr code storage hardening: images were served as plain static files under the
document root with no auth check at all. Storage now lives outside the web
root; qrcode_image.php and qrcode_zip_download.php gate access with the same
permission model as the list pages, and the bulk zip download is bound to the
session that generated it.

PHP 8.4 + chillerlan/php-qrcode 6.0.1: bumped since this is a dockerized app,
so the PHP version shipped doesn't matter to end users. Note: the 6.0.1 tag
itself only requires PHP 8.2 - the earlier "needs 8.4" read was from an
unpinned clone of master, which has since moved past the tag. Fixed along the
way, surfaced by testing on 8.4:
- The hardcoded Imagick build (an old pinned master commit, workaround for
  3.7.0 being broken on PHP 8.3+) no longer compiles on 8.4. Imagick 3.8.1 is
  now a normal stable release, so the workaround is gone.
- config.php had display_errors=On + error_reporting(E_ALL), so PHP 8.4's new
  deprecation notices got dumped straight into the response before
  session_start() could run, breaking login outright. Also an info-disclosure
  risk on its own. Now logged instead of displayed.
- MysqliDb::insertMulti() had an implicit nullable parameter, now explicit.
- includes/auth_validate.php redirected unauthenticated requests but never
  called exit(), so the rest of the script kept running.
- Dockerfile.fpm was missing both git (needed to clone chillerlan/php-qrcode)
  and the imagick extension entirely.

Also removes the unused sample qr code images that shipped in the original
repo; storage now lives outside the document root so they were never going
to be served again.
2026-07-08 20:38:58 +02:00
dillard 52c9f65c61 Fase 2: read-only user role with per-category view toggles
Adds a third account type 'user' alongside super/admin: no create/edit/delete
rights on qr codes, view access to dynamic/static lists gated per-account by
two admin-controlled toggles (can_view_static, can_view_dynamic), and always
full visibility into the dashboard/reports regardless of those toggles.

- New columns can_view_static/can_view_dynamic on users (migrations/003)
- Users class + form_users.php: 'user' type option with the two toggles
- Access control: dynamic_qrcode.php/static_qrcode.php/bulk_action.php reject
  all mutations for type=user; dynamic_qrcodes.php/static_qrcodes.php enforce
  the view toggle and show all codes (no owner scoping, since 'user' owns none)
- Sidebar and list tables hide add/edit/delete/bulk UI for the read-only role
- index.php dashboard stats are unscoped for both 'super' and 'user'
2026-07-08 16:37:25 +02:00
116 changed files with 3647 additions and 404 deletions
+18 -2
View File
@@ -1,10 +1,10 @@
# Kopieer naar .env en pas de waarden aan. .env wordt niet gecommit (zie .gitignore).
# Copy to .env and adjust the values. .env is not committed (see .gitignore).
TYPE=docker
QRCODE_GENERATOR=internal-chillerlan.qrcode
BASE_URL=http://localhost
DATABASE_HOST=php-dynamic-qrcode-db
DATABASE_HOST=qrforge-db
DATABASE_PORT=3306
DATABASE_NAME=qrcode
DATABASE_USER=qrcode
@@ -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
+1
View File
@@ -3,3 +3,4 @@
.idea
.DS_Store
.env
/data/
+22 -10
View File
@@ -1,4 +1,4 @@
FROM php:8.3
FROM php:8.4
RUN if [ "$(grep '^VERSION_ID=' /etc/os-release | cut -d '=' -f 2 | tr -d '"')" -eq "9" ]; then \
sed -i -e 's/deb.debian.org/archive.debian.org/g' \
@@ -51,16 +51,12 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update -q \
sockets \
xsl \
zip \
imagick \
" \
&& case "$PHP_VERSION" in \
5.6.*) PHP_EXTENSIONS="$PHP_EXTENSIONS mcrypt mysql";; \
7.0.*|7.1.*) PHP_EXTENSIONS="$PHP_EXTENSIONS mcrypt";; \
esac \
# Install Imagick from master on PHP >= 8.3, because imagick 3.7.0 broke on latest PHP releases and Imagick maintainers don't care to tag a newer release
&& if [ $(php -r 'echo PHP_VERSION_ID;') -lt 80300 ]; then \
PHP_EXTENSIONS="$PHP_EXTENSIONS imagick"; \
else PHP_EXTENSIONS="$PHP_EXTENSIONS https://api.github.com/repos/Imagick/imagick/tarball/28f27044e435a2b203e32675e942eb8de620ee58"; \
fi \
&& install-php-extensions $PHP_EXTENSIONS \
&& if command -v a2enmod; then a2enmod rewrite; fi
@@ -79,6 +75,7 @@ RUN cd /opt \
&& rm /opt/composer-setup.php /opt/composer-setup.sha384sum
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 mysqli && docker-php-ext-enable mysqli
RUN docker-php-ext-install gettext && docker-php-ext-enable gettext
@@ -86,10 +83,10 @@ RUN docker-php-ext-install sockets && docker-php-ext-enable sockets
RUN mkdir -p /opt && chmod 777 /opt
WORKDIR /opt
# Vastgezet op 5.0.5 (laatste 5.x-release): vanaf 6.0.0 vereist de library PHP >= 8.4,
# terwijl deze image op PHP 8.3 draait. Een ongepinde clone van master is bovendien
# een reproduceerbaarheids-/supply-chain-risico (build kan zonder waarschuwing breken).
RUN git clone --branch 5.0.5 --depth 1 https://github.com/chillerlan/php-qrcode.git \
# Pinned to a specific release tag instead of an unpinned clone of master, which is a
# reproducibility/supply-chain risk (the build can break silently when upstream moves on,
# as happened when master started requiring PHP 8.4 while this image was still on 8.3).
RUN git clone --branch 6.0.1 --depth 1 https://github.com/chillerlan/php-qrcode.git \
&& chmod -R 777 ./php-qrcode
RUN cp ./php-qrcode/composer.json /var/www/html/composer.json
RUN mkdir -p /var/www/html/test && chmod 777 /var/www/html/test
@@ -98,7 +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"]
+22 -5
View File
@@ -1,4 +1,4 @@
FROM php:8.3-fpm
FROM php:8.4-fpm
RUN if [ "$(grep '^VERSION_ID=' /etc/os-release | cut -d '=' -f 2 | tr -d '"')" -eq "9" ]; then \
sed -i -e 's/deb.debian.org/archive.debian.org/g' \
@@ -13,6 +13,7 @@ RUN chmod +x /usr/local/bin/install-php-extensions
RUN DEBIAN_FRONTEND=noninteractive apt-get update -q \
&& DEBIAN_FRONTEND=noninteractive apt-get install -qq -y \
curl \
git \
libzip-dev \
libjpeg62-turbo-dev \
libpng-dev \
@@ -21,6 +22,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update -q \
&& install-php-extensions \
gd \
gettext \
imagick \
intl \
mysqli \
opcache \
@@ -41,19 +43,34 @@ RUN cd /opt \
RUN mkdir -p /opt && chmod 777 /opt
WORKDIR /opt
# Zie Dockerfile: vastgezet op 5.0.5, want 6.0.0+ vereist PHP >= 8.4.
RUN git clone --branch 5.0.5 --depth 1 https://github.com/chillerlan/php-qrcode.git \
# See Dockerfile: pinned to a specific release tag instead of an unpinned clone of master.
RUN git clone --branch 6.0.1 --depth 1 https://github.com/chillerlan/php-qrcode.git \
&& chmod -R 777 ./php-qrcode
RUN cp ./php-qrcode/composer.json /var/www/html/composer.json
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 {} \; \
&& chmod -R 775 /var/www/html/saved_qrcode
&& find /var/www/html -type d -exec 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 \
&& 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"]
+14
View File
@@ -0,0 +1,14 @@
FROM nginx:1.27-alpine
# The php-dynamic-qrcode container builds the full app (incl. composer/vendor) into its
# own image at /var/www/html. nginx runs as a separate container and has no access to
# that filesystem, so it needs its own copy of just the static assets it serves directly
# via try_files - everything else (*.php) is proxied to php-fpm regardless. Without this,
# every static asset request (CSS/JS/manifest) falls through nginx's try_files to
# index.php, which requires a login and silently redirects there instead of serving the
# file.
COPY src/dist /var/www/html/dist
COPY src/plugins /var/www/html/plugins
COPY src/manifest.json /var/www/html/manifest.json
COPY src/service-worker.js /var/www/html/service-worker.js
COPY src/favicon.ico /var/www/html/favicon.ico
+1
View File
@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2020 Giandonato Inverso
Copyright (c) 2026 Dillard Blom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+66 -56
View File
@@ -1,65 +1,75 @@
<p align="center"><img src="https://www.giandonatoinverso.it/qrcode/dist/img/DynamicQRCode_Original.png"></p>
<p align="center"><img src="src/dist/img/brand/logo.svg" alt="QRForge" width="320"></p>
**PHP Dynamic Qr code** is a script that allows the generation and saving of dynamic and static QR codes. It has a clean, responsive, and user-friendly design. It is based on [AdminLte](https://adminlte.io/), the "Best open source admin dashboard & control panel theme. Built on top of Bootstrap" and [Core PHP Admin Panel](https://github.com/chetans9/core-php-admin-panel), a simple Admin Panel written in core PHP that contains an implementation of general features you might need in your website admin panel like: record management (CRUD), secure authentication, pagination, filters.
<p align="center"><strong>Self-hosted, open-source QR code generator.</strong></p>
[LIVE DEMO](https://qrcode.giandonatoinverso.dev/)
**QRForge** creates and manages static and dynamic QR codes from a clean,
responsive control panel. It's a security-hardened, actively maintained fork
of the original [PHP Dynamic Qr code](https://github.com/giandonatoinverso/PHP-Dynamic-Qr-code)
project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
username: admin
password: admin
[DOCUMENTATION](https://giandonatoinverso.it/qrcode/documentation)
- **Try it free:** [qr.ensembia.com](https://qr.ensembia.com) - fully functional OSS test
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
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.
# Features
- **#1 Dynamic Qr code generator on GitHub with a database to store Qr codes**
- Create unlimited Qr codes
- Docker compose support
- Control panel with 2 access levels
- Multi-account
- Dashboard with advanced statistics on Qr codes created and on scans
- Bulk download, bulk delete
- Dynamic Qr code
- Create, modify, and delete Qr codes
- You can download your Qr codes when you want
- URL shortener with redirect
- Enable or disable the link redirect
- Static Qr code
- Text QR Code
- Email QR Code
- Phone QR Code
- Sms QR Code
- Whatsapp QR Code
- Skype QR Code
- Location QR Code
- Vcard QR Code
- Event/calendar QR Code
- Bookmark QR Code
- Wifi QR Code
- Paypal QR Code
- Bitcoin QR Code
- Customization of Qr codes
- 6 formats for images
- Foreground color
- Background color
- 4 levels of precision
- 10 sizes
- Responsive bootstrap-based design
- Easy to understand and expand code
- Full OOP with classes and well-documented
- Dynamic QR codes with a database-backed URL shortener
- Create, edit, delete, enable/disable the redirect
- Download any time, bulk download/delete
- Batch-generate from a CSV file
- 16 static QR code types: Text, Email, Phone, SMS, WhatsApp, Skype, Location,
vCard, Event/calendar, Bookmark, WiFi (incl. WPA3), PayPal, Bitcoin, 2FA,
App Link (Android intent / universal links), Bluetooth
- QR code styling: 6 export formats, foreground/background color, 4 precision
levels, 10 sizes, optional label text below the code (custom font + size),
optional icon shown above the code, save/load your own style presets
- Address search (OpenStreetMap Nominatim) for Location QR codes
- Built-in QR scanner (camera or image upload, decodes entirely client-side)
- Installable as a PWA
- Role-based access: `super` (full access + user management), `admin`
(scoped to their own codes and sub-users), `user` (read-only, with
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
- CSRF protection, login rate limiting, session hardening, audit log
- Docker Compose setup, both a dev image and a production Nginx + PHP-FPM image
## What is included
# What is included
- PHP files
- .sql file with sample data
- JS files
- CSS files
- Docker compose file
- PHP 8.4 application source
- 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
## Setup with docker compose
1. download docker-compose.yml file
2. Start docker stack
```bash
docker compose build --no-cache && docker compose up -d
```
3. Open your browser at http://localhost:80 and login with (username: superadmin, password: superadmin)
# Setup with Docker Compose
1. Clone this repository.
2. Copy `.env.example` to `.env` and set a real `DATABASE_PASSWORD` /
`MYSQL_ROOT_PASSWORD`.
3. Start the stack:
```bash
docker compose up -d --build
```
4. Open `http://localhost` and log in with `superadmin` / `superadmin`. You'll
be required to set a new password on first login.
For a production deployment behind a reverse proxy, use
`docker-compose.prod.yml` (Nginx + PHP-FPM) instead of the dev stack.
# Credits
- Originally forked from [PHP Dynamic Qr code](https://github.com/giandonatoinverso/PHP-Dynamic-Qr-code)
by Giandonato Inverso.
- QR code rendering powered by [chillerlan/php-qrcode](https://github.com/chillerlan/php-qrcode).
- Admin panel UI built on [AdminLTE](https://adminlte.io/).
# License
MIT - see [LICENSE](LICENSE).
+28 -5
View File
@@ -16,14 +16,23 @@ CREATE TABLE IF NOT EXISTS `users` (
`type` varchar(10) NOT NULL,
`must_change_password` tinyint(1) NOT NULL DEFAULT 0,
`password_changed_at` datetime DEFAULT NULL,
`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,
@@ -58,7 +67,7 @@ CREATE TABLE IF NOT EXISTS `static_qrcodes` (
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=0 ;
-- Security hardening (Fase 1): rate limiting op login pogingen
-- Security hardening (Fase 1): rate limiting on login attempts
CREATE TABLE IF NOT EXISTS `login_attempts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
@@ -70,7 +79,7 @@ CREATE TABLE IF NOT EXISTS `login_attempts` (
KEY `ip_attempted_at` (`ip_address`, `attempted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Security hardening (Fase 1): audit log van gevoelige acties
-- Security hardening (Fase 1): audit log of sensitive actions
CREATE TABLE IF NOT EXISTS `audit_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(25) DEFAULT NULL,
@@ -86,6 +95,20 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Fase 3 (priority 2): saved color/style presets per user
CREATE TABLE IF NOT EXISTS `qr_presets` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(25) NOT NULL,
`name` varchar(50) NOT NULL,
`foreground` varchar(10) NOT NULL,
`background` varchar(10) NOT NULL,
`level` varchar(1) NOT NULL DEFAULT 'L',
`size` int(10) unsigned NOT NULL DEFAULT 200,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+6 -6
View File
@@ -1,7 +1,7 @@
-- Fase 1 security hardening migratie.
-- Voer uit tegen een bestaande database (gebruikt de originele
-- giandonatoinverso/php-dynamic-qr-code-db image of een oudere init.sql).
-- Kolommen/tabellen worden alleen toegevoegd als ze nog niet bestaan.
-- Fase 1 security hardening migration.
-- Run against an existing database (using the original
-- giandonatoinverso/php-dynamic-qr-code-db image or an older init.sql).
-- Columns/tables are only added if they don't already exist.
SET @db := DATABASE();
@@ -23,8 +23,8 @@ SET @sql := IF(@col_exists = 0,
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Bestaand superadmin account met het fabriekswachtwoord (superadmin/superadmin)
-- moet bij eerstvolgende login het wachtwoord wijzigen.
-- An existing superadmin account with the factory password (superadmin/superadmin)
-- must change its password on next login.
UPDATE `users`
SET `must_change_password` = 1
WHERE `username` = 'superadmin'
+22
View File
@@ -0,0 +1,22 @@
-- Fase 2: read-only 'user' role with two visibility toggles.
-- type='user' requires no schema change (varchar(10), no enum constraint).
SET @db := DATABASE();
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'can_view_static'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `can_view_static` TINYINT(1) NOT NULL DEFAULT 0',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'can_view_dynamic'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `can_view_dynamic` TINYINT(1) NOT NULL DEFAULT 0',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+14
View File
@@ -0,0 +1,14 @@
-- Option 2: an admin may create their own 'user' accounts within their own scope.
-- owner_admin_id = NULL means: created by super, company-wide (previous behavior).
-- owner_admin_id = <id> means: created by that admin, sees only that admin's own codes.
SET @db := DATABASE();
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'owner_admin_id'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `owner_admin_id` INT(25) DEFAULT NULL',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+14
View File
@@ -0,0 +1,14 @@
-- Fase 3 (priority 2): saved color/style presets per user.
CREATE TABLE IF NOT EXISTS `qr_presets` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(25) NOT NULL,
`name` varchar(50) NOT NULL,
`foreground` varchar(10) NOT NULL,
`background` varchar(10) NOT NULL,
`level` varchar(1) NOT NULL DEFAULT 'L',
`size` int(10) unsigned NOT NULL DEFAULT 200,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+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;
+29 -23
View File
@@ -1,20 +1,21 @@
services:
nginx:
image: "nginx:1.27-alpine"
build:
context: .
dockerfile: Dockerfile.nginx
restart: "unless-stopped"
ports:
- "80:80"
# 443 pas openzetten zodra SSL-certificaten zijn gemount (bv. via certbot-volume
# of een losse reverse proxy zoals Caddy/Traefik ervoor). Zie infra-fase van het plan.
# Only open 443 once SSL certificates are mounted (e.g. via a certbot volume,
# or a separate reverse proxy like Caddy/Traefik in front). See the infra phase of the plan.
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- php_dynamic_qrcode_saved_qrcode_data:/var/www/html/saved_qrcode:ro
depends_on:
- php-dynamic-qrcode
- qrforge-app
networks:
- php-dynamic-qrcode-network
- qrforge-network
php-dynamic-qrcode:
qrforge-app:
build:
context: .
dockerfile: Dockerfile.fpm
@@ -22,45 +23,50 @@ services:
environment:
TYPE: "docker"
QRCODE_GENERATOR: "${QRCODE_GENERATOR:-internal-chillerlan.qrcode}"
BASE_URL: "${BASE_URL:?zet BASE_URL in .env, bv. https://qr.ensembia.com}"
DATABASE_HOST: "php-dynamic-qrcode-db"
BASE_URL: "${BASE_URL:?set BASE_URL in .env, e.g. https://qr.ensembia.com}"
DATABASE_HOST: "qrforge-db"
DATABASE_PORT: "3306"
DATABASE_NAME: "${DATABASE_NAME:-qrcode}"
DATABASE_USER: "${DATABASE_USER:-qrcode}"
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}"
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:
php-dynamic-qrcode-db:
qrforge-db:
condition: service_healthy
volumes:
- php_dynamic_qrcode_saved_qrcode_data:/var/www/html/saved_qrcode
- ./data/qrcode-storage:/var/www/qrcode-storage
networks:
- php-dynamic-qrcode-network
- qrforge-network
php-dynamic-qrcode-db:
qrforge-db:
image: "mysql:8.0"
restart: "unless-stopped"
volumes:
- php_dynamic_qrcode_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}"
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
MYSQL_DATABASE: "${DATABASE_NAME:-qrcode}"
MYSQL_USER: "${DATABASE_USER:-qrcode}"
MYSQL_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}"
MYSQL_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- php-dynamic-qrcode-network
volumes:
php_dynamic_qrcode_db_data:
php_dynamic_qrcode_saved_qrcode_data:
- qrforge-network
networks:
php-dynamic-qrcode-network:
qrforge-network:
driver: bridge
+29 -13
View File
@@ -1,5 +1,5 @@
services:
php-dynamic-qrcode:
qrforge-app:
build:
context: .
dockerfile: Dockerfile
@@ -8,28 +8,48 @@ services:
TYPE: "${TYPE:-docker}"
QRCODE_GENERATOR: "${QRCODE_GENERATOR:-internal-chillerlan.qrcode}"
BASE_URL: "${BASE_URL:-http://localhost}"
DATABASE_HOST: "php-dynamic-qrcode-db"
DATABASE_HOST: "qrforge-db"
DATABASE_PORT: "3306"
DATABASE_NAME: "${DATABASE_NAME:-qrcode}"
DATABASE_USER: "${DATABASE_USER:-qrcode}"
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:
php-dynamic-qrcode-db:
qrforge-db:
condition: service_healthy
volumes:
- php_dynamic_qrcode_saved_qrcode_data:/var/www/html/saved_qrcode
- ./data/qrcode-storage:/var/www/qrcode-storage
networks:
- php-dynamic-qrcode-network
- qrforge-network
php-dynamic-qrcode-db:
mailhog:
image: "mailhog/mailhog:v1.0.1"
restart: "unless-stopped"
ports:
- "8025:8025" # web UI: http://localhost:8025
networks:
- qrforge-network
qrforge-db:
image: "mysql:8.0"
restart: "unless-stopped"
volumes:
- php_dynamic_qrcode_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}"
@@ -42,12 +62,8 @@ services:
timeout: 5s
retries: 10
networks:
- php-dynamic-qrcode-network
volumes:
php_dynamic_qrcode_db_data:
php_dynamic_qrcode_saved_qrcode_data:
- qrforge-network
networks:
php-dynamic-qrcode-network:
qrforge-network:
driver: bridge
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
php /var/www/html/scripts/migrate.php
exec docker-php-entrypoint "$@"
+4 -8
View File
@@ -11,22 +11,18 @@ server {
add_header Referrer-Policy "same-origin" always;
location / {
try_files $uri $uri/ /index.php$is_args$args;
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass php-dynamic-qrcode:9000;
fastcgi_pass qrforge-app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Statisch gegenereerde qrcodes mogen gedownload worden, maar niet als PHP uitgevoerd.
location /saved_qrcode/ {
location ~ \.php$ {
deny all;
}
}
# Generated qr codes are stored outside the document root and are only served
# through the authenticated qrcode_image.php / qrcode_zip_download.php endpoints.
location ~ /\. {
deny all;
+103
View File
@@ -0,0 +1,103 @@
<?php
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
?>
<!DOCTYPE html>
<html lang="en">
<title>About - QRForge</title>
<head>
<?php include './includes/head.php'; ?>
</head>
<body class="hold-transition sidebar-mini layout-fixed layout-navbar-fixed layout-footer-fixed">
<div class="wrapper">
<!-- Navbar -->
<?php include './includes/navbar.php'; ?>
<!-- /.navbar -->
<!-- Main Sidebar Container -->
<?php include './includes/sidebar.php'; ?>
<!-- /.Main Sidebar Container -->
<!-- Content Wrapper. Contains page content -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">About</h1>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- /.container-fluid -->
</div>
<!-- /.content-header -->
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<div class="card card-primary">
<div class="card-body text-center">
<img src="dist/img/brand/logo-stacked.svg" alt="QRForge" style="max-width: 220px; margin: 20px 0;">
<p class="text-muted">Self-hosted static and dynamic QR code generator. Version 3.0.</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Commercial version</h3>
</div>
<div class="card-body">
<p>
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> -
<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)
is a separate product built on the same OSS core - see
<a href="https://www.qrforge.eu" target="_blank">www.qrforge.eu</a> for
pricing and details.
</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Self-hosting</h3>
</div>
<div class="card-body">
<p>
Prefer to run your own instance? QRForge is MIT-licensed and available on
GitHub:
<a href="https://github.com/dillardblom/QRForge-selfhosted" target="_blank">github.com/dillardblom/QRForge-selfhosted</a>.
</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Credits</h3>
</div>
<div class="card-body">
<p>
Originally forked from
<a href="https://github.com/giandonatoinverso/PHP-Dynamic-Qr-code" target="_blank">PHP Qrcode Generator by Giandonato Inverso</a>.
</p>
<p>
QR code rendering is powered by the
<a href="https://github.com/chillerlan/php-qrcode" target="_blank">chillerlan/php-qrcode</a>
library.
</p>
</div>
</div>
</div><!--/. container-fluid -->
</section><!-- /.content -->
</div><!-- /.content-wrapper -->
<!-- Footer and scripts -->
<?php include './includes/footer.php'; ?>
</body>
</html>
+22 -8
View File
@@ -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,10 @@ 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);
$_SESSION['last_activity'] = time();
audit_log('login_success');
@@ -83,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;
}
+225
View File
@@ -0,0 +1,225 @@
<?php
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
if ($_SESSION['type'] === 'user') {
$_SESSION['failure'] = 'The "user" role is read-only and cannot create qr codes.';
header('Location: index.php');
exit;
}
$dynamic_qrcode_instance = new DynamicQrcode();
$results = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
$id_owner = $_SESSION['type'] === 'super' ? ($_POST['id_owner'] ?? '') : $_SESSION['user_id'];
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['failure'] = 'Please choose a CSV file to upload.';
header('Location: batch_qrcode.php');
exit;
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
$rows = [];
if ($handle !== false) {
while (($row = fgetcsv($handle)) !== false) {
$rows[] = $row;
}
fclose($handle);
}
// Skip an optional header row.
if (!empty($rows) && strtolower(trim($rows[0][0] ?? '')) === 'filename') {
array_shift($rows);
}
$successes = [];
$failures = [];
$created_ids = [];
foreach ($rows as $index => $row) {
$line_number = $index + 1;
$filename = $row[0] ?? '';
$link = $row[1] ?? '';
$result = $dynamic_qrcode_instance->addQrcodeBatchRow($filename, $link, $id_owner);
if ($result['ok']) {
$successes[] = $filename;
$created_ids[] = $result['id'];
} else {
$failures[] = ['line' => $line_number, 'filename' => $filename, 'error' => $result['error']];
}
}
$zip_filename = null;
if (!empty($created_ids)) {
$db = getDbInstance();
$files = [];
foreach ($created_ids as $id) {
$db->where('id', $id);
$row = $db->getOne('dynamic_qrcodes');
if ($row !== null) {
$files[] = SAVED_QRCODE_DIRECTORY . $row['qrcode'];
}
}
$zip_filename = 'qrcodes_' . uniqid() . '.zip';
$zip_path = SAVED_QRCODE_DIRECTORY . 'zip/' . $zip_filename;
@unlink($zip_path);
$zip = new ZipArchive();
$zip->open($zip_path, ZipArchive::CREATE);
foreach ($files as $file) {
$content = @file_get_contents($file);
if ($content !== false) {
$zip->addFromString(basename($file), $content);
}
}
$zip->close();
$_SESSION['generated_zips'][] = $zip_filename;
audit_log('batch_qrcode_created', 'dynamic_qrcodes', implode(',', $created_ids));
}
$results = [
'successes' => $successes,
'failures' => $failures,
'zip_filename' => $zip_filename,
];
}
?>
<!DOCTYPE html>
<html lang="en">
<title>QRForge</title>
<head>
<?php include './includes/head.php'; ?>
</head>
<body class="hold-transition sidebar-mini layout-fixed layout-navbar-fixed layout-footer-fixed">
<div class="wrapper">
<!-- Navbar -->
<?php include './includes/navbar.php'; ?>
<!-- /.navbar -->
<!-- Main Sidebar Container -->
<?php include './includes/sidebar.php'; ?>
<!-- /.Main Sidebar Container -->
<!-- Content Wrapper. Contains page content -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Batch-create dynamic qr codes</h1>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- /.container-fluid -->
</div>
<!-- /.content-header -->
<!-- Flash messages -->
<?php include BASE_PATH.'/includes/flash_messages.php'; ?>
<!-- /.Flash messages -->
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<?php if ($results !== null): ?>
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Result</h3>
</div>
<div class="card-body">
<p><strong><?php echo count($results['successes']); ?></strong> qr code(s) created,
<strong><?php echo count($results['failures']); ?></strong> row(s) failed.</p>
<?php if ($results['zip_filename']): ?>
<a href="qrcode_zip_download.php?file=<?php echo rawurlencode($results['zip_filename']); ?>" class="btn btn-primary">
<i class="fa fa-download"></i> Download all as ZIP
</a>
<?php endif; ?>
<?php if (!empty($results['failures'])): ?>
<table class="table table-striped table-bordered mt-3">
<thead>
<tr>
<th>Line</th>
<th>Filename</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<?php foreach ($results['failures'] as $failure): ?>
<tr>
<td><?php echo (int) $failure['line']; ?></td>
<td><?php echo htmlspecialchars($failure['filename']); ?></td>
<td><?php echo htmlspecialchars($failure['error']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Upload a CSV file</h3>
</div>
<form action="" method="post" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="card-body">
<p>The CSV needs two columns: <code>filename,link</code>. An optional header row
starting with "filename" is skipped automatically. Each row creates one dynamic
qr code (PNG, default colors/size) redirecting to the given link.</p>
<div class="form-group">
<label for="csv_file">CSV file</label>
<input type="file" name="csv_file" id="csv_file" accept=".csv,text/csv" required="required" class="form-control">
</div>
<?php if ($_SESSION['type'] === 'super'): ?>
<div class="form-group">
<label for="id_owner">Owner</label>
<select name="id_owner" class="form-control">
<option value="" selected>All</option>
<?php
require_once BASE_PATH . '/lib/Users/Users.php';
$users_instance = new Users();
$users = $users_instance->getAllUsers();
foreach ($users as $user) {
?>
<option value="<?php echo $user["id"]; ?>"><?php echo htmlspecialchars($user["username"]); ?></option>
<?php
}
?>
</select>
</div>
<?php endif; ?>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary">Upload and generate</button>
</div>
</form>
</div>
</div><!--/. container-fluid -->
</section><!-- /.content -->
</div><!-- /.content-wrapper -->
<!-- Footer and scripts -->
<?php include './includes/footer.php'; ?>
</body>
</html>
+25 -11
View File
@@ -35,24 +35,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit();
}
if ($_SESSION['type'] === 'user') {
$view_flag = $type === 'dynamic' ? 'can_view_dynamic' : 'can_view_static';
if (empty($_SESSION[$view_flag] ?? null)) {
http_response_code(403);
echo json_encode(['data' => 'Not allowed to view this qr code type.', 'status' => 403]);
exit();
}
}
foreach ($params as $param) {
$db->where('id', $param);
if ($_SESSION['type'] !== 'super') {
$db->where('id_owner', $_SESSION['user_id']);
$db->orWhere('id_owner', NULL, 'IS');
}
qr_apply_owner_scope($db);
$row = $db->getOne("{$type}_qrcodes");
if ($row !== NULL) {
$files[] = SAVED_QRCODE_FOLDER . $row['qrcode'];
$files[] = SAVED_QRCODE_DIRECTORY . $row['qrcode'];
}
}
$zip = new ZipArchive();
$uniqid = uniqid();
$relative_dir = SAVED_QRCODE_FOLDER . 'zip/qrcodes_' . $uniqid . '.zip';
@unlink($relative_dir);
$url_path = SAVED_QRCODE_URL . 'zip/qrcodes_' . $uniqid . '.zip';
$zip->open($relative_dir, ZipArchive::CREATE);
$zip_filename = 'qrcodes_' . uniqid() . '.zip';
$zip_path = SAVED_QRCODE_DIRECTORY . 'zip/' . $zip_filename;
@unlink($zip_path);
$zip->open($zip_path, ZipArchive::CREATE);
foreach ($files as $file) {
$download_file = @file_get_contents($file, true);
@@ -61,14 +66,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$zip->close();
// Proof-of-generation: only this session may download this specific zip file.
$_SESSION['generated_zips'][] = $zip_filename;
audit_log('bulk_download', $type, implode(',', $params));
echo json_encode([
'data' => $url_path,
'data' => 'qrcode_zip_download.php?file=' . rawurlencode($zip_filename),
'status' => 200
]);
exit();
} else if($json["action"] == "delete") {
if ($_SESSION['type'] === 'user') {
http_response_code(403);
echo json_encode(['data' => 'The "user" role is read-only.', 'status' => 403]);
exit();
}
$params = $json['params'];
if (isset($json['type']) && in_array($json['type'], $allowed_types, true)) {
+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);
+2 -2
View File
@@ -47,13 +47,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
?>
<!DOCTYPE html>
<html lang="en">
<title>Change password - Qrcode Generator</title>
<title>Change password - 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/DynamicQRCode_Original.png" style="width: 95%; height: 95%">
<img src="dist/img/brand/logo.svg" alt="QRForge" style="max-width: 260px;">
</div>
<div class="card">
+9 -5
View File
@@ -2,8 +2,12 @@
//Note: This file should be included first in every php page.
require_once ('environment.php');
// Never display errors/warnings/deprecations in the response body: besides leaking
// internal file paths, it can inject output before session_start() runs and break
// login entirely (seen with PHP 8.4's new deprecation notices). Log them instead.
error_reporting(E_ALL);
ini_set('display_errors', 'On');
ini_set('display_errors', 'Off');
ini_set('log_errors', 'On');
define('BASE_PATH', dirname(dirname(__FILE__)));
define('CURRENT_PAGE', basename($_SERVER['REQUEST_URI']));
define('SCRIPT_NAME', ltrim(dirname($_SERVER['SCRIPT_NAME']), '/'));
@@ -17,10 +21,10 @@ require_once BASE_PATH . '/lib/MysqliDb/MysqliDb.php';
require_once BASE_PATH . '/helpers/helpers.php';
/* SAVED QR CODES */
//You can change the folder where the qr code will be saved
define('SAVED_QRCODE_FOLDER', './saved_qrcode/');
define('SAVED_QRCODE_DIRECTORY', BASE_PATH.'/saved_qrcode/');
define('SAVED_QRCODE_URL', base_url(). SCRIPT_FOLDER .'/saved_qrcode/');
// Storage lives outside the document root so files can only be reached through the
// authenticated qrcode_image.php / qrcode_zip_download.php endpoints, never as a direct
// static URL. See db/migrations and the "saved_qrcode" hardening note in the OSS repo.
define('SAVED_QRCODE_DIRECTORY', dirname(BASE_PATH).'/qrcode-storage/');
//You can change the page name for the redirect and the search parameter (the default is "id")
define('READ_PATH', base_url().'/read.php?id=');
+11
View File
@@ -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');
+17
View File
@@ -7,3 +7,20 @@
display: none;
color: red;
}
/* List-page qr code thumbnails: fixed box, but object-fit:contain instead of
width/height attrs so taller images (e.g. icon-above-qr) don't get squashed. */
.qr-thumb-link {
display: inline-block;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.qr-thumb-img {
width: 100px;
height: 100px;
object-fit: contain;
background: #fff;
border: 1px solid #dee2e6;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 151.74 151.74" role="img" aria-labelledby="qfi-title qfi-desc">
<title id="qfi-title">QRForge icon</title>
<desc id="qfi-desc">QR-block mark with outbound arrow tile.</desc>
<g transform="translate(-8.95,-2.57)">
<rect id="block-top-left" x="5.24" y="16.81" width="42.29" height="40.70" rx="2.5" fill="#334155" />
<rect id="block-mid" x="59.57" y="68.79" width="24.90" height="23.46" rx="2.5" fill="#334155" />
<rect id="block-bottom-right" x="96.08" y="103.32" width="40.59" height="31.68" rx="2.5" fill="#334155" />
<path id="block-bottom-left-L" d="M 5.24,71.29 Q 5.24,68.79 7.74,68.79 L 45.03,68.79 Q 47.53,68.79 47.53,71.29 L 47.53,103.32 Q 47.53,103.32 47.53,103.32 L 81.97,103.32 Q 84.47,103.32 84.47,105.82 L 84.47,132.50 Q 84.47,135.00 81.97,135.00 L 7.74,135.00 Q 5.24,135.00 5.24,132.50 Z" fill="#334155" />
<path id="arrow-tile" d="M 105.68,6.94 Q 104.67,5.88 106.13,5.88 L 145.89,5.88 Q 148.39,5.88 148.39,8.38 L 148.39,44.14 Q 148.39,46.64 146.42,45.11 L 133.00,34.67 Q 133.00,34.67 133.00,34.67 L 112.50,56.87 Q 112.50,56.87 112.50,56.87 L 134.08,56.87 Q 136.58,56.87 136.58,59.37 L 136.58,89.50 Q 136.58,92.00 134.08,92.00 L 98.63,92.00 Q 96.13,92.00 96.13,89.50 L 96.13,56.87 Q 96.13,56.87 96.13,56.87 L 62.10,56.87 Q 59.60,56.87 59.60,54.37 L 59.60,19.60 Q 59.60,17.10 62.10,17.10 L 93.63,17.10 Q 96.13,17.10 96.13,19.60 L 96.13,41.00 Q 96.13,41.00 96.13,41.00 L 97.00,42.17 Q 97.00,42.17 97.00,42.17 L 117.19,22.73 Q 118.99,21.00 117.27,19.18 L 107.68,9.06 Q 106.68,8.00 105.68,6.94 Z" fill="#2563EB" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 151.74 151.74" role="img" aria-labelledby="qfi-title qfi-desc">
<title id="qfi-title">QRForge icon</title>
<desc id="qfi-desc">QR-block mark with outbound arrow tile.</desc>
<g transform="translate(-8.95,-2.57)">
<rect id="block-top-left" x="5.24" y="16.81" width="42.29" height="40.70" rx="2.5" fill="#FFFFFF" />
<rect id="block-mid" x="59.57" y="68.79" width="24.90" height="23.46" rx="2.5" fill="#FFFFFF" />
<rect id="block-bottom-right" x="96.08" y="103.32" width="40.59" height="31.68" rx="2.5" fill="#FFFFFF" />
<path id="block-bottom-left-L" d="M 5.24,71.29 Q 5.24,68.79 7.74,68.79 L 45.03,68.79 Q 47.53,68.79 47.53,71.29 L 47.53,103.32 Q 47.53,103.32 47.53,103.32 L 81.97,103.32 Q 84.47,103.32 84.47,105.82 L 84.47,132.50 Q 84.47,135.00 81.97,135.00 L 7.74,135.00 Q 5.24,135.00 5.24,132.50 Z" fill="#FFFFFF" />
<path id="arrow-tile" d="M 105.68,6.94 Q 104.67,5.88 106.13,5.88 L 145.89,5.88 Q 148.39,5.88 148.39,8.38 L 148.39,44.14 Q 148.39,46.64 146.42,45.11 L 133.00,34.67 Q 133.00,34.67 133.00,34.67 L 112.50,56.87 Q 112.50,56.87 112.50,56.87 L 134.08,56.87 Q 136.58,56.87 136.58,59.37 L 136.58,89.50 Q 136.58,92.00 134.08,92.00 L 98.63,92.00 Q 96.13,92.00 96.13,89.50 L 96.13,56.87 Q 96.13,56.87 96.13,56.87 L 62.10,56.87 Q 59.60,56.87 59.60,54.37 L 59.60,19.60 Q 59.60,17.10 62.10,17.10 L 93.63,17.10 Q 96.13,17.10 96.13,19.60 L 96.13,41.00 Q 96.13,41.00 96.13,41.00 L 97.00,42.17 Q 97.00,42.17 97.00,42.17 L 117.19,22.73 Q 118.99,21.00 117.27,19.18 L 107.68,9.06 Q 106.68,8.00 105.68,6.94 Z" fill="#2563EB" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 151.74 151.74" role="img" aria-labelledby="qfi-title qfi-desc">
<title id="qfi-title">QRForge icon</title>
<desc id="qfi-desc">QR-block mark with outbound arrow tile.</desc>
<g transform="translate(-8.95,-2.57)">
<rect id="block-top-left" x="5.24" y="16.81" width="42.29" height="40.70" rx="2.5" fill="#334155" />
<rect id="block-mid" x="59.57" y="68.79" width="24.90" height="23.46" rx="2.5" fill="#334155" />
<rect id="block-bottom-right" x="96.08" y="103.32" width="40.59" height="31.68" rx="2.5" fill="#334155" />
<path id="block-bottom-left-L" d="M 5.24,71.29 Q 5.24,68.79 7.74,68.79 L 45.03,68.79 Q 47.53,68.79 47.53,71.29 L 47.53,103.32 Q 47.53,103.32 47.53,103.32 L 81.97,103.32 Q 84.47,103.32 84.47,105.82 L 84.47,132.50 Q 84.47,135.00 81.97,135.00 L 7.74,135.00 Q 5.24,135.00 5.24,132.50 Z" fill="#334155" />
<path id="arrow-tile" d="M 105.68,6.94 Q 104.67,5.88 106.13,5.88 L 145.89,5.88 Q 148.39,5.88 148.39,8.38 L 148.39,44.14 Q 148.39,46.64 146.42,45.11 L 133.00,34.67 Q 133.00,34.67 133.00,34.67 L 112.50,56.87 Q 112.50,56.87 112.50,56.87 L 134.08,56.87 Q 136.58,56.87 136.58,59.37 L 136.58,89.50 Q 136.58,92.00 134.08,92.00 L 98.63,92.00 Q 96.13,92.00 96.13,89.50 L 96.13,56.87 Q 96.13,56.87 96.13,56.87 L 62.10,56.87 Q 59.60,56.87 59.60,54.37 L 59.60,19.60 Q 59.60,17.10 62.10,17.10 L 93.63,17.10 Q 96.13,17.10 96.13,19.60 L 96.13,41.00 Q 96.13,41.00 96.13,41.00 L 97.00,42.17 Q 97.00,42.17 97.00,42.17 L 117.19,22.73 Q 118.99,21.00 117.27,19.18 L 107.68,9.06 Q 106.68,8.00 105.68,6.94 Z" fill="#2563EB" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 236.20 246.50" role="img" aria-labelledby="qfs-title qfs-desc">
<title id="qfs-title">QRForge stacked logo</title>
<desc id="qfs-desc">Stacked QRForge logo.</desc>
<g transform="translate(33.28,14.75)">
<rect id="block-top-left" x="5.24" y="16.81" width="42.29" height="40.70" rx="2.5" fill="#334155" />
<rect id="block-mid" x="59.57" y="68.79" width="24.90" height="23.46" rx="2.5" fill="#334155" />
<rect id="block-bottom-right" x="96.08" y="103.32" width="40.59" height="31.68" rx="2.5" fill="#334155" />
<path id="block-bottom-left-L" d="M 5.24,71.29 Q 5.24,68.79 7.74,68.79 L 45.03,68.79 Q 47.53,68.79 47.53,71.29 L 47.53,103.32 Q 47.53,103.32 47.53,103.32 L 81.97,103.32 Q 84.47,103.32 84.47,105.82 L 84.47,132.50 Q 84.47,135.00 81.97,135.00 L 7.74,135.00 Q 5.24,135.00 5.24,132.50 Z" fill="#334155" />
<path id="arrow-tile" d="M 105.68,6.94 Q 104.67,5.88 106.13,5.88 L 145.89,5.88 Q 148.39,5.88 148.39,8.38 L 148.39,44.14 Q 148.39,46.64 146.42,45.11 L 133.00,34.67 Q 133.00,34.67 133.00,34.67 L 112.50,56.87 Q 112.50,56.87 112.50,56.87 L 134.08,56.87 Q 136.58,56.87 136.58,59.37 L 136.58,89.50 Q 136.58,92.00 134.08,92.00 L 98.63,92.00 Q 96.13,92.00 96.13,89.50 L 96.13,56.87 Q 96.13,56.87 96.13,56.87 L 62.10,56.87 Q 59.60,56.87 59.60,54.37 L 59.60,19.60 Q 59.60,17.10 62.10,17.10 L 93.63,17.10 Q 96.13,17.10 96.13,19.60 L 96.13,41.00 Q 96.13,41.00 96.13,41.00 L 97.00,42.17 Q 97.00,42.17 97.00,42.17 L 117.19,22.73 Q 118.99,21.00 117.27,19.18 L 107.68,9.06 Q 106.68,8.00 105.68,6.94 Z" fill="#2563EB" />
</g>
<text x="118.10" y="203.67" text-anchor="middle" font-family="Inter, Manrope, Geist, Arial, sans-serif" font-size="48.39" font-weight="700" letter-spacing="-3.43" fill="#334155">QRForge</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.24 5.88 573.81 145.12" role="img" aria-labelledby="qf-title qf-desc">
<title id="qf-title">QRForge logo</title>
<desc id="qf-desc">QRForge wordmark with a QR-block mark and outbound arrow tile.</desc>
<g id="icon">
<rect id="block-top-left" x="5.24" y="16.81" width="42.29" height="40.70" rx="2.5" fill="#334155" />
<rect id="block-mid" x="59.57" y="68.79" width="24.90" height="23.46" rx="2.5" fill="#334155" />
<rect id="block-bottom-right" x="96.08" y="103.32" width="40.59" height="31.68" rx="2.5" fill="#334155" />
<path id="block-bottom-left-L" d="M 5.24,71.29 Q 5.24,68.79 7.74,68.79 L 45.03,68.79 Q 47.53,68.79 47.53,71.29 L 47.53,103.32 Q 47.53,103.32 47.53,103.32 L 81.97,103.32 Q 84.47,103.32 84.47,105.82 L 84.47,132.50 Q 84.47,135.00 81.97,135.00 L 7.74,135.00 Q 5.24,135.00 5.24,132.50 Z" fill="#334155" />
<path id="arrow-tile" d="M 105.68,6.94 Q 104.67,5.88 106.13,5.88 L 145.89,5.88 Q 148.39,5.88 148.39,8.38 L 148.39,44.14 Q 148.39,46.64 146.42,45.11 L 133.00,34.67 Q 133.00,34.67 133.00,34.67 L 112.50,56.87 Q 112.50,56.87 112.50,56.87 L 134.08,56.87 Q 136.58,56.87 136.58,59.37 L 136.58,89.50 Q 136.58,92.00 134.08,92.00 L 98.63,92.00 Q 96.13,92.00 96.13,89.50 L 96.13,56.87 Q 96.13,56.87 96.13,56.87 L 62.10,56.87 Q 59.60,56.87 59.60,54.37 L 59.60,19.60 Q 59.60,17.10 62.10,17.10 L 93.63,17.10 Q 96.13,17.10 96.13,19.60 L 96.13,41.00 Q 96.13,41.00 96.13,41.00 L 97.00,42.17 Q 97.00,42.17 97.00,42.17 L 117.19,22.73 Q 118.99,21.00 117.27,19.18 L 107.68,9.06 Q 106.68,8.00 105.68,6.94 Z" fill="#2563EB" />
</g>
<text id="wordmark" x="177" y="101" font-family="Inter, Manrope, Geist, Arial, sans-serif" font-size="103" font-weight="700" letter-spacing="-7.3" fill="#334155">QRForge</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+20
View File
@@ -500,3 +500,23 @@
return false;
});
})(jQuery)
// Copy a qr code image straight to the clipboard (Fase 3 UX feature).
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.copy-qr-btn').forEach(function (btn) {
btn.addEventListener('click', async function () {
const icon = btn.querySelector('i');
const originalClass = icon.className;
try {
const response = await fetch(btn.getAttribute('data-qr-src'));
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
icon.className = 'fa fa-check';
setTimeout(function () { icon.className = originalClass; }, 1500);
} catch (err) {
alert('Could not copy this image to the clipboard (your browser may not support this image format for clipboard access): ' + err.message);
}
});
});
});
+69
View File
@@ -0,0 +1,69 @@
(function () {
document.addEventListener('DOMContentLoaded', function () {
var searchInput = document.getElementById('location_search');
var searchBtn = document.getElementById('location_search_btn');
var resultsBox = document.getElementById('location_search_results');
var latInput = document.getElementById('latitude');
var lngInput = document.getElementById('longitude');
if (!searchInput || !searchBtn || !resultsBox || !latInput || !lngInput) {
return;
}
function clearResults() {
resultsBox.innerHTML = '';
}
function doSearch() {
var query = searchInput.value.trim();
if (!query) {
return;
}
clearResults();
resultsBox.innerHTML = '<div class="list-group-item">Searching...</div>';
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q=' + encodeURIComponent(query))
.then(function (response) { return response.json(); })
.then(function (results) {
clearResults();
if (!results.length) {
resultsBox.innerHTML = '<div class="list-group-item">No results found.</div>';
return;
}
results.forEach(function (place) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'list-group-item list-group-item-action';
item.textContent = place.display_name;
item.addEventListener('click', function () {
latInput.value = place.lat;
lngInput.value = place.lon;
searchInput.value = place.display_name;
clearResults();
});
resultsBox.appendChild(item);
});
})
.catch(function () {
resultsBox.innerHTML = '<div class="list-group-item text-danger">Search failed (network error).</div>';
});
}
searchBtn.addEventListener('click', doSearch);
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doSearch();
}
});
document.addEventListener('click', function (e) {
if (e.target !== searchInput && !resultsBox.contains(e.target)) {
clearResults();
}
});
});
})();
+258
View File
@@ -0,0 +1,258 @@
// Preset system + random style button for the qr code generation forms (Fase 3, priority 2).
//
// The static qr "add" page stacks all qr-type forms (text/email/.../wifi/bitcoin/...) into
// the DOM at once as Bootstrap tab-panes, and every one of them repeats the same element ids
// (foreground, background, size, random_style_btn, ...). getElementById/getElementsBy* only
// ever finds the *first* of those (the "Text" tab), so every helper below is scoped to the
// specific tab-pane/form the triggering element lives in, and wired up via querySelectorAll
// so every tab gets working listeners, not just the first one.
(function () {
function csrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function scopeOf(el) {
return el.closest('.tab-pane') || el.closest('form') || document;
}
function setColor(scope, id, hex) {
var input = scope.querySelector('#' + id);
if (!input) {
return;
}
input.value = hex;
input.dispatchEvent(new Event('change'));
try {
// Sync the bootstrap-colorpicker widget/swatch if it was initialized on this input.
if (window.jQuery) {
jQuery(input).colorpicker('setValue', hex);
}
} catch (e) {
// Colorpicker not initialized on this page - the raw value above is still correct.
}
}
function randomHexColor() {
var value = Math.floor(Math.random() * 0xFFFFFF).toString(16);
return '#' + ('000000' + value).slice(-6);
}
function updateStylePreview(scope) {
var swatch = scope.querySelector('#style_preview_swatch');
var text = scope.querySelector('#style_preview_text');
if (!swatch || !text) {
return;
}
var foreground = scope.querySelector('#foreground');
var background = scope.querySelector('#background');
var levelSelect = scope.querySelector('select[name="level"]');
var sizeSelect = scope.querySelector('#size');
var fg = foreground ? foreground.value : '#000000';
var bg = background ? background.value : '#ffffff';
swatch.style.background = bg;
swatch.style.borderColor = fg;
var parts = [];
if (levelSelect) {
parts.push('Precision: ' + levelSelect.value);
}
if (sizeSelect) {
parts.push('Size: ' + sizeSelect.value + 'px');
}
text.textContent = parts.join(' · ');
}
function loadPresetsInto(select) {
fetch('presets.php?action=list')
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status !== 200) {
return;
}
json.data.forEach(function (preset) {
var option = document.createElement('option');
option.value = preset.id;
option.textContent = preset.name;
option.dataset.foreground = preset.foreground;
option.dataset.background = preset.background;
option.dataset.level = preset.level;
option.dataset.size = preset.size;
select.appendChild(option);
});
})
.catch(function () { /* presets are a nice-to-have, fail silently */ });
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('#preset_select').forEach(loadPresetsInto);
document.querySelectorAll('#style_preview').forEach(function (row) {
updateStylePreview(scopeOf(row));
});
document.querySelectorAll('#foreground, #background').forEach(function (input) {
// bootstrap-colorpicker sets the value and fires 'change' via jQuery's
// synthetic .trigger(), which a native addEventListener never sees - so the
// preview only updates via jQuery's event binding, not the native one below.
if (window.jQuery) {
jQuery(input).on('change', function () {
updateStylePreview(scopeOf(input));
});
} else {
input.addEventListener('change', function () {
updateStylePreview(scopeOf(input));
});
}
});
document.querySelectorAll('select[name="level"]').forEach(function (select) {
select.addEventListener('change', function () {
updateStylePreview(scopeOf(select));
});
});
document.querySelectorAll('#size').forEach(function (select) {
select.addEventListener('change', function () {
updateStylePreview(scopeOf(select));
});
});
document.querySelectorAll('#random_style_btn').forEach(function (randomBtn) {
randomBtn.addEventListener('click', function () {
var scope = scopeOf(randomBtn);
setColor(scope, 'foreground', randomHexColor());
setColor(scope, 'background', randomHexColor());
updateStylePreview(scope);
});
});
document.querySelectorAll('#preset_select').forEach(function (presetSelect) {
presetSelect.addEventListener('change', function () {
var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) {
return;
}
var scope = scopeOf(presetSelect);
setColor(scope, 'foreground', option.dataset.foreground);
setColor(scope, 'background', option.dataset.background);
var levelSelect = scope.querySelector('select[name="level"]');
if (levelSelect) {
levelSelect.value = option.dataset.level;
}
var sizeSelect = scope.querySelector('#size');
if (sizeSelect) {
sizeSelect.value = option.dataset.size;
}
updateStylePreview(scope);
});
});
document.querySelectorAll('#preset_save_btn').forEach(function (saveBtn) {
saveBtn.addEventListener('click', function () {
var scope = scopeOf(saveBtn);
var nameInput = scope.querySelector('#preset_name');
var name = nameInput.value.trim();
if (!name) {
alert('Enter a name for this preset first.');
return;
}
var foreground = scope.querySelector('#foreground').value;
var background = scope.querySelector('#background').value;
var level = scope.querySelector('select[name="level"]').value;
var size = scope.querySelector('#size').value;
var body = new URLSearchParams();
body.set('action', 'save');
body.set('name', name);
body.set('foreground', foreground);
body.set('background', background);
body.set('level', level);
body.set('size', size);
fetch('presets.php', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
body: body
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status !== 200) {
alert('Could not save preset: ' + json.data);
return;
}
// The preset list is shared across every tab, so mirror the new
// option into every preset_select on the page, not just this one.
document.querySelectorAll('#preset_select').forEach(function (select) {
var option = document.createElement('option');
option.value = json.data.id;
option.textContent = json.data.name;
option.dataset.foreground = foreground;
option.dataset.background = background;
option.dataset.level = level;
option.dataset.size = size;
select.appendChild(option);
if (select === scope.querySelector('#preset_select')) {
select.value = option.value;
}
});
nameInput.value = '';
})
.catch(function () { alert('Could not save preset (network error).'); });
});
});
document.querySelectorAll('#preset_delete_btn').forEach(function (deleteBtn) {
deleteBtn.addEventListener('click', function () {
var scope = scopeOf(deleteBtn);
var presetSelect = scope.querySelector('#preset_select');
var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) {
return;
}
if (!confirm('Delete preset "' + option.textContent + '"?')) {
return;
}
var body = new URLSearchParams();
body.set('action', 'delete');
body.set('id', option.value);
fetch('presets.php', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
body: body
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status === 200) {
// Remove the matching option from every preset_select on the page.
document.querySelectorAll('#preset_select').forEach(function (select) {
var match = select.querySelector('option[value="' + option.value + '"]');
if (match) {
match.remove();
}
});
} else {
alert('Could not delete preset: ' + json.data);
}
})
.catch(function () { alert('Could not delete preset (network error).'); });
});
});
});
})();
+18 -3
View File
@@ -3,6 +3,12 @@ require_once 'includes/bootstrap.php';
require_once BASE_PATH.'/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
if ($_SESSION['type'] === 'user') {
$_SESSION['failure'] = 'The "user" role is read-only and cannot create, edit or delete qr codes.';
header('Location: index.php');
exit;
}
$dynamic_qrcode_instance = new DynamicQrcode();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -31,7 +37,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && isset($_POST["edit"])) {
$dynamic_qrcode_instance->editQrcode($_POST);
}
if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"]) && !isset($_POST["del_id"])) {
if(
isset($_POST["foreground"]) &&
isset($_POST["background"]) &&
@@ -39,13 +45,22 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
isset($_POST["filename"]) &&
isset($_POST["format"]) &&
isset($_POST["id_owner"])
)
) {
$icon_upload = qr_handle_icon_upload('icon');
if (!$icon_upload['ok']) {
$_SESSION['failure'] = $icon_upload['error'];
header('Location: ' . basename(__FILE__));
exit;
}
$_POST['icon_tmp_path'] = $icon_upload['path'];
$dynamic_qrcode_instance->addQrcode($_POST);
}
}
?>
<!DOCTYPE html>
<html lang="en">
<title>Qrcode Generator</title>
<title>QRForge</title>
<head>
<?php include './includes/head.php'; ?>
</head>
+12 -5
View File
@@ -3,6 +3,12 @@ require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
if ($_SESSION['type'] === 'user' && empty($_SESSION['can_view_dynamic'] ?? null)) {
$_SESSION['failure'] = 'You are not allowed to view dynamic qr codes.';
header('Location: index.php');
exit;
}
$db = getDbInstance();
$dynamic_qrcode = new DynamicQrcode();
@@ -12,10 +18,9 @@ require_once BASE_PATH . '/includes/search_order.php';
$page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? 1;
$db->pageLimit = 15;
if($_SESSION['type'] !== 'super') {
$db->where("id_owner", $_SESSION['user_id']);
$db->orWhere ("id_owner", NULL, 'IS');
}
// Scoped to one admin's own codes for an admin (or a 'user' created by that admin);
// full visibility for super and company-wide 'user' accounts.
qr_apply_owner_scope($db);
$rows = $db->arraybuilder()->paginate('dynamic_qrcodes', $page, $select);
$total_pages = $db->totalPages;
@@ -24,7 +29,7 @@ $total_pages = $db->totalPages;
<!DOCTYPE html>
<html lang="en">
<title>Qrcode Generator</title>
<title>QRForge</title>
<head>
<?php include './includes/head.php'; ?>
</head>
@@ -49,6 +54,7 @@ $total_pages = $db->totalPages;
<h1 class="m-0 text-dark">Dynamic Qr codes</h1>
</div><!-- /.col -->
<?php if ($_SESSION['type'] !== 'user'): ?>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item">
@@ -56,6 +62,7 @@ $total_pages = $db->totalPages;
</li>
</ol>
</div><!-- /.col -->
<?php endif; ?>
</div><!-- /.row -->
</div><!-- /.container-fluid -->
</div><!-- /.content-header -->
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+101 -21
View File
@@ -35,7 +35,7 @@
<div class="col-6 col-md-3">
<label for="size">Size (px)</label>
<select name="size" class="form-control">
<select name="size" id="size" class="form-control">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
@@ -51,6 +51,49 @@
</div>
</div>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
<label>&nbsp;</label>
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style
</button>
</div>
<div class="col-6 col-md-3">
<label for="preset_select">Load preset</label>
<div class="input-group">
<select id="preset_select" class="form-control">
<option value="">-- Select --</option>
</select>
<div class="input-group-append">
<button type="button" id="preset_delete_btn" class="btn btn-outline-secondary" title="Delete selected preset"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label for="preset_name">Save as preset</label>
<div class="input-group">
<input type="text" id="preset_name" class="form-control" placeholder="Preset name" maxlength="50">
<div class="input-group-append">
<button type="button" id="preset_save_btn" class="btn btn-outline-secondary"><i class="fa fa-save"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label>Style preview</label>
<div id="style_preview" class="d-flex align-items-center">
<span id="style_preview_swatch" style="display:inline-block;width:38px;height:38px;border:3px solid #000;background:#fff;border-radius:4px;"></span>
<small id="style_preview_text" class="ml-2 text-muted"></small>
</div>
</div>
</div>
</div>
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script>
<!-- Its use is not recommended. Read the documentation
<div class="form-group">
<label for="logo">Logo</label>
@@ -77,34 +120,70 @@
<div class="row">
<div class="col-sm-4">
<div class="form-group">
<label for="filename">Filename *</label>
<input type="text" name="filename" value="" placeholder="My first Qrcode" class="form-control error" required="required" id="filename">
<label for="frame_text">Frame text</label>
<input type="text" name="frame_text" value="" placeholder="e.g. Scan me" maxlength="60" class="form-control" id="frame_text">
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
</div>
</div>
<div class="col-6 col-md-1">
<label for="format">Format *</label>
<select name="format" class="form-control" required="required">
<option value="png" selected>PNG</option>
<option value="gif">GIF</option>
<option value="jpeg">JPEG</option>
<option value="jpg">JPG</option>
<option value="svg">SVG</option>
<option value="eps">EPS</option>
</select>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font">Frame font</label>
<select name="frame_font" id="frame_font" class="form-control">
<option value="sans" selected>Sans</option>
<option value="sans-bold">Sans Bold</option>
<option value="serif">Serif</option>
<option value="serif-bold">Serif Bold</option>
<option value="mono">Monospace</option>
<option value="mono-bold">Monospace Bold</option>
</select>
</div>
</div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font_size">Frame font size</label>
<input type="number" name="frame_font_size" id="frame_font_size" value="16" min="8" max="60" class="form-control">
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="icon">Icon above QR code</label>
<input type="file" name="icon" id="icon" accept="image/png,image/jpeg,image/gif" class="form-control-file">
<small class="form-text text-muted">Optional. PNG/JPEG/GIF, max 1MB. Shown above the code (not embedded in it). Only applies to PNG/JPEG/GIF output.</small>
</div>
</div>
</div>
</div>
<?php if($_SESSION['type'] === 'super') { ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-sm-4">
<div class="form-group">
<label for="filename">Filename *</label>
<input type="text" name="filename" value="" placeholder="My first Qrcode" class="form-control error" required="required" id="filename">
</div>
</div>
<div class="col-6 col-md-1">
<label for="format">Format *</label>
<select name="format" class="form-control" required="required">
<option value="png" selected>PNG</option>
<option value="gif">GIF</option>
<option value="jpeg">JPEG</option>
<option value="jpg">JPG</option>
<option value="svg">SVG</option>
<option value="eps">EPS</option>
</select>
</div>
<?php if ($_SESSION['type'] === 'super') { ?>
<div class="col-sm-4">
<div class="form-group">
<label for="id_owner">Owner *</label>
<select name="id_owner" class="form-control">
<option value="" selected>All</option>
<select name="id_owner" id="id_owner" class="form-control">
<option value="">All (shared with every admin)</option>
<?php
require_once BASE_PATH . '/lib/Users/Users.php';
@@ -112,15 +191,16 @@
$users = $users_instance->getAllUsers();
foreach ($users as $user) {
$is_self = (int) $user["id"] === (int) $_SESSION["user_id"];
?>
<option value="<?php echo $user["id"];?>"><?php echo $user["username"];?></option>
<option value="<?php echo $user["id"];?>" <?php echo $is_self ? 'selected' : ''; ?>><?php echo $user["username"];?></option>
<?php } ?>
</select>
</div>
</div>
<?php } else { ?>
<input type="hidden" name="id_owner" value="<?php echo $_SESSION["user_id"];?>"/>
<?php } ?>
</div>
</div>
<?php } else { ?>
<input type="hidden" name="id_owner" value="<?php echo $_SESSION["user_id"];?>"/>
<?php } ?>
</fieldset>
+14
View File
@@ -44,6 +44,12 @@
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#twofa" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">2FA <i class="fa fa-key"></i></a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#applink" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">App Link <i class="fas fa-mobile-alt"></i></a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#bluetooth" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">Bluetooth <i class="fab fa-bluetooth-b"></i></a>
</li>
</ul>
</div>
<div class="card-body">
@@ -90,7 +96,15 @@
<div class="tab-pane fade" id="twofa" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
<?php include BASE_PATH . '/forms/static/2fa.php'; ?>
</div>
<div class="tab-pane fade" id="applink" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
<?php include BASE_PATH . '/forms/static/applink.php'; ?>
</div>
<div class="tab-pane fade" id="bluetooth" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
<?php include BASE_PATH . '/forms/static/bluetooth.php'; ?>
</div>
</div>
</div>
</div><!-- /.card -->
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script>
</fieldset>
+79 -2
View File
@@ -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>
@@ -25,21 +39,84 @@
</div>
</div>
<?php if ($_SESSION['type'] === 'super'): ?>
<?php $editing_self = $edit && (int) $user['id'] === (int) $_SESSION['user_id']; ?>
<div class="col-sm-4">
<label for="user-type">User type *</label>
<div class="form-group">
<div 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 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 class="radio">
<label class="radio">
<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>
<?php if ($editing_self): ?>
<small class="form-text text-muted">You can't change your own access level.</small>
<?php endif; ?>
</div>
<div class="col-sm-12 mt-2" id="user-view-toggles">
<label>Visibility for the 'User' role</label>
<div class="form-group">
<div class="icheck-primary d-inline-block mr-4">
<input type="checkbox" name="can_view_static" id="can_view_static" value="1" <?php echo ($edit && !empty($user['can_view_static'])) ? "checked": "" ; ?>>
<label for="can_view_static">Can view static qr codes</label>
</div>
<div class="icheck-primary d-inline-block">
<input type="checkbox" name="can_view_dynamic" id="can_view_dynamic" value="1" <?php echo ($edit && !empty($user['can_view_dynamic'])) ? "checked": "" ; ?>>
<label for="can_view_dynamic">Can view dynamic qr codes</label>
</div>
<small class="form-text text-muted">Only applies to the 'User' type. Reports/statistics are always visible for 'User'.</small>
</div>
</div>
<script>
(function () {
var typeRadios = document.querySelectorAll('input[name="type"]');
var toggles = document.getElementById('user-view-toggles');
function updateToggleVisibility() {
var userSelected = document.getElementById('type-user').checked;
toggles.style.display = userSelected ? '' : 'none';
}
typeRadios.forEach(function (radio) {
radio.addEventListener('change', updateToggleVisibility);
});
updateToggleVisibility();
})();
</script>
<?php else: ?>
<!-- An 'admin' can only create/manage their own read-only 'user' accounts. -->
<input type="hidden" name="type" value="user">
<div class="col-sm-12 mt-2">
<label>Visibility for this user</label>
<div class="form-group">
<div class="icheck-primary d-inline-block mr-4">
<input type="checkbox" name="can_view_static" id="can_view_static" value="1" <?php echo ($edit && !empty($user['can_view_static'])) ? "checked": "" ; ?>>
<label for="can_view_static">Can view static qr codes</label>
</div>
<div class="icheck-primary d-inline-block">
<input type="checkbox" name="can_view_dynamic" id="can_view_dynamic" value="1" <?php echo ($edit && !empty($user['can_view_dynamic'])) ? "checked": "" ; ?>>
<label for="can_view_dynamic">Can view dynamic qr codes</label>
</div>
<small class="form-text text-muted">Reports/statistics are always visible for this account.</small>
</div>
</div>
<?php endif; ?>
<?php if($edit) { ?>
<input type="hidden" name="id" value="<?php echo $user['id'];?>"/>
<input type="hidden" name="edit" value="true"/>
+116 -29
View File
@@ -34,7 +34,7 @@
<div class="col-6 col-md-3">
<label for="size">Size (px)</label>
<select name="size" class="form-control">
<select name="size" id="size" class="form-control">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
@@ -55,6 +55,56 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
</div>
</div>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
<label>Randomize</label>
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style
</button>
</div>
<div class="col-6 col-md-3">
<label for="preset_select">Load preset</label>
<div class="input-group">
<select id="preset_select" class="form-control">
<option value="">-- Select --</option>
</select>
<div class="input-group-append">
<button type="button" id="preset_delete_btn" class="btn btn-outline-secondary" title="Delete selected preset"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label for="preset_name">Save as preset</label>
<div class="input-group">
<input type="text" id="preset_name" class="form-control" placeholder="Preset name" maxlength="50">
<div class="input-group-append">
<button type="button" id="preset_save_btn" class="btn btn-outline-secondary"><i class="fa fa-save"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label>Style preview</label>
<div id="style_preview" class="d-flex align-items-center">
<span id="style_preview_swatch" style="display:inline-block;width:38px;height:38px;border:3px solid #000;background:#fff;border-radius:4px;"></span>
<small id="style_preview_text" class="ml-2 text-muted"></small>
</div>
</div>
</div>
</div>
<!--
Note: no <script src="dist/js/qrcode-style-tools.js"> tag here on purpose. This
partial is included once per qr type on the static "add" page (form_static_add.php),
which stacks all types into the DOM as tab-panes; including the script here would load
and execute it once per type, each execution re-attaching its own listeners to every
button on the page. The script is included exactly once by the pages that use this
partial (form_static_add.php and form_dynamic_add.php).
-->
<!-- Its use is not recommended. Read the documentation
<div class="form-group">
<label for="logo">Logo</label>
@@ -66,13 +116,53 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
<div class="row">
<div class="col-sm-4">
<div class="form-group">
<label for="filename">Filename *</label>
<input type="text" name="filename" value="" placeholder="My first Qrcode" class="form-control error" required="required" id = "filename">
<label for="frame_text">Frame text</label>
<input type="text" name="frame_text" value="" placeholder="e.g. Scan me" maxlength="60" class="form-control" id="frame_text">
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
</div>
</div>
<div class="col-6 col-md-1">
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font">Frame font</label>
<select name="frame_font" id="frame_font" class="form-control">
<option value="sans" selected>Sans</option>
<option value="sans-bold">Sans Bold</option>
<option value="serif">Serif</option>
<option value="serif-bold">Serif Bold</option>
<option value="mono">Monospace</option>
<option value="mono-bold">Monospace Bold</option>
</select>
</div>
</div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font_size">Frame font size</label>
<input type="number" name="frame_font_size" id="frame_font_size" value="16" min="8" max="60" class="form-control">
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="icon">Icon above QR code</label>
<input type="file" name="icon" id="icon" accept="image/png,image/jpeg,image/gif" class="form-control-file">
<small class="form-text text-muted">Optional. PNG/JPEG/GIF, max 1MB. Shown above the code (not embedded in it). Only applies to PNG/JPEG/GIF output.</small>
</div>
</div>
</div>
</div>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-sm-4">
<div class="form-group">
<label for="filename">Filename *</label>
<input type="text" name="filename" value="" placeholder="My first Qrcode" class="form-control error" required="required" id="filename">
</div>
</div>
<div class="col-6 col-md-1">
<label for="format">Format</label>
<select name="format" class="form-control">
<option value="png">PNG</option>
@@ -87,34 +177,31 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
?>
<option value="eps">EPS</option>
</select>
</div>
</div>
</div>
</div>
<?php if($_SESSION['type'] === 'super') { ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-sm-4">
<div class="form-group">
<label for="id_owner">Owner *</label>
<select name="id_owner" class="form-control">
<option value="" selected>All</option>
<?php
<?php if ($_SESSION['type'] === 'super') { ?>
<div class="col-sm-4">
<div class="form-group">
<label for="id_owner">Owner *</label>
<select name="id_owner" id="id_owner" class="form-control">
<option value="">All (shared with every admin)</option>
<?php
require_once BASE_PATH . '/lib/Users/Users.php';
$users_instance = new Users();
$users = $users_instance->getAllUsers();
require_once BASE_PATH . '/lib/Users/Users.php';
$users_instance = new Users();
$users = $users_instance->getAllUsers();
foreach ($users as $user) {
?>
<option value="<?php echo $user["id"];?>"><?php echo $user["username"];?></option>
<?php } ?>
</select>
</div>
foreach ($users as $user) {
$is_self = (int) $user["id"] === (int) $_SESSION["user_id"];
?>
<option value="<?php echo $user["id"];?>" <?php echo $is_self ? 'selected' : ''; ?>><?php echo $user["username"];?></option>
<?php } ?>
</select>
</div>
</div>
<?php } else { ?>
<input type="hidden" name="id_owner" value="<?php echo $_SESSION["user_id"];?>"/>
<?php } ?>
</div>
<?php } else { ?>
<input type="hidden" name="id_owner" value="<?php echo $_SESSION["user_id"];?>"/>
<?php } ?>
</div>
<br>
+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">
<?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">
+73
View File
@@ -0,0 +1,73 @@
<form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
<div class="form-group">
<label>Platform *</label>
<select name="platform" id="applink-platform" class="form-control">
<option value="android" selected>Android (intent link)</option>
<option value="generic">Generic (custom scheme)</option>
</select>
</div>
</div>
<div class="col-6 col-md-3">
<div class="form-group">
<label>Scheme *</label>
<input type="text" name="scheme" value="" placeholder="myapp" class="form-control">
</div>
</div>
<div class="col-6 col-md-3">
<div class="form-group">
<label>Path *</label>
<input type="text" name="path" value="" placeholder="open?ref=123" class="form-control">
</div>
</div>
<div class="col-6 col-md-3" id="applink-package-group">
<div class="form-group">
<label>Android package *</label>
<input type="text" name="package" value="" placeholder="com.example.app" class="form-control">
</div>
</div>
<div class="col-6 col-md-3" id="applink-fallback-group">
<div class="form-group">
<label>Fallback URL</label>
<input type="text" name="fallback_url" value="" placeholder="https://play.google.com/store/apps/details?id=..." class="form-control">
</div>
</div>
</div>
</div>
<script>
(function () {
var platformSelect = document.getElementById('applink-platform');
var packageGroup = document.getElementById('applink-package-group');
var fallbackGroup = document.getElementById('applink-fallback-group');
function updateVisibility() {
var isAndroid = platformSelect.value === 'android';
packageGroup.style.display = isAndroid ? '' : 'none';
fallbackGroup.style.display = isAndroid ? '' : 'none';
}
platformSelect.addEventListener('change', updateVisibility);
updateVisibility();
})();
</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">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
</form>
+2 -2
View File
@@ -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">
@@ -14,7 +13,7 @@
<div class="col-6 col-md-3">
<div class="form-group">
<label>Amount *</label>
<label>Amount</label>
<div class="input-group">
<input type="number" name="amount" value="" placeholder="" class="form-control" step="0.0001">
<div class="input-group-append">
@@ -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">
+37
View File
@@ -0,0 +1,37 @@
<form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<small class="form-text text-muted mb-2">
There is no OS-native "scan to pair" standard for Bluetooth like there is for Wifi, so this
just encodes the device name and address for reference - whoever scans it still pairs
manually via their Bluetooth settings.
</small>
<div class="row">
<div class="col-6 col-md-3">
<div class="form-group">
<label>Device name *</label>
<input type="text" name="device_name" value="" placeholder="" class="form-control">
</div>
</div>
<div class="col-6 col-md-3">
<div class="form-group">
<label>MAC address *</label>
<input type="text" name="mac_address" value="" placeholder="AA:BB:CC:DD:EE:FF" class="form-control">
</div>
</div>
</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">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
</form>
+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">
<?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 -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">
<?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 -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">
<?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">
+22 -7
View File
@@ -1,21 +1,35 @@
<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-4">
<div class="form-group">
<label>Latitude *</label>
<input type="text" name="latitude" value="" placeholder="40.7127753" class="form-control">
<div class="col-sm-8">
<div class="form-group" style="position: relative;">
<label for="location_search">Search address</label>
<div class="input-group">
<input type="text" id="location_search" class="form-control" placeholder="Search for an address or place...">
<div class="input-group-append">
<button type="button" id="location_search_btn" class="btn btn-outline-secondary"><i class="fa fa-search"></i></button>
</div>
</div>
<div id="location_search_results" class="list-group" style="position:absolute;z-index:1000;width:100%;"></div>
<small class="form-text text-muted">Looks up coordinates via OpenStreetMap Nominatim (your search leaves this server and goes to nominatim.openstreetmap.org). Or enter coordinates directly below.</small>
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label>Longitude *</label>
<input type="text" name="longitude" value="" placeholder="-74.0059728" class="form-control">
<label for="latitude">Latitude *</label>
<input type="text" name="latitude" id="latitude" value="" placeholder="40.7127753" class="form-control">
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="longitude">Longitude *</label>
<input type="text" name="longitude" id="longitude" value="" placeholder="-74.0059728" class="form-control">
</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">
@@ -24,4 +38,5 @@
</div>
</div>
<script src="dist/js/location-search.js"></script>
</form>
+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">
<?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 -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">
<?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 -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">
<?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 -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">
<?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 -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">
<?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">
+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">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<!-- First row -->
<div class="col-sm-12 mb-2">
@@ -139,9 +138,17 @@
</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>
<?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 -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">
<?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">
+2 -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">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<div class="row">
@@ -10,6 +9,7 @@
<label>Encryption *</label>
<select name="encryption" class="form-control">
<option value="WPA" Selected>WPA/WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option>
<option value="">None</option>
</select>
@@ -32,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">
+46 -4
View File
@@ -1,4 +1,6 @@
<?php $is_readonly_user = $_SESSION['type'] === 'user'; ?>
<div class="row">
<?php if (!$is_readonly_user): ?>
<div class="col-12" id="bulk-action-div" style="display: none;">
<div id="err-msg"></div>
<div class="bulk-action-wrapper">
@@ -21,13 +23,16 @@
</form>
</div>
</div>
<?php endif; ?>
<div class="col-12">
<div class="card">
<div class="card-body table-responsive p-0">
<table class="table table-striped table-bordered">
<thead>
<tr>
<?php if (!$is_readonly_user): ?>
<th><input type="checkbox" name="bulk-select" value="1"></th>
<?php endif; ?>
<th>ID</th>
<th>Owner</th>
<th>Filename</th>
@@ -42,7 +47,9 @@
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<?php if (!$is_readonly_user): ?>
<td><input type="checkbox" name="action[]" value="<?=$row['id']?>" onchange="updateBulkActionVisibility()"></td>
<?php endif; ?>
<td><?php echo $row['id']; ?></td>
<td>
<?php
@@ -63,12 +70,16 @@
<td><?php echo htmlspecialchars($row['identifier']); ?></td>
<td><?php echo htmlspecialchars($row['link']); ?></td>
<td>
<?php echo '<img src="'.SAVED_QRCODE_FOLDER.htmlspecialchars($row['qrcode']).'" width="100" height="100">'; ?>
<a href="#" class="qr-thumb-link" data-toggle="modal" data-target="#preview-modal"
data-qr-src="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>"
data-qr-name="<?php echo htmlspecialchars($row['filename']); ?>">
<img src="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>" class="qr-thumb-img" alt="QR code for <?php echo htmlspecialchars($row['filename']); ?>">
</a>
</td>
<td><?php echo htmlspecialchars($row['scan']); ?></td>
<td><?php echo htmlspecialchars($row['state']); ?></td>
<td>
<?php if (!$is_readonly_user): ?>
<!-- EDIT -->
<a href="dynamic_qrcode.php?edit=true&id=<?php echo $row['id']; ?>" class="btn btn-primary"><i class="fas fa-edit"></i></a>
@@ -79,9 +90,12 @@
data-target="#delete-modal"
data-del_id="<?php echo $row["id"];?>"
><i class="fas fa-trash"></i></a>
<?php endif; ?>
<!-- DOWNLOAD -->
<a href="<?php echo SAVED_QRCODE_FOLDER.htmlspecialchars($row['qrcode']); ?>" class="btn btn-primary" download><i class="fa fa-download"></i></a>
<a href="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>&download=1" class="btn btn-primary"><i class="fa fa-download"></i></a>
<!-- COPY TO CLIPBOARD -->
<button type="button" class="btn btn-secondary copy-qr-btn" data-qr-src="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>" title="Copy image to clipboard"><i class="fa fa-copy"></i></button>
</td>
</tr>
<?php endforeach; ?>
@@ -97,6 +111,7 @@
</div><!-- /.col -->
</div><!-- /.row -->
<?php if (!$is_readonly_user): ?>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="delete-modal" role="dialog">
<div class="modal-dialog">
@@ -122,6 +137,33 @@
</div>
</div>
<!-- /.Delete Confirmation Modal -->
<?php endif; ?>
<!-- Qr Code Preview Modal -->
<div class="modal fade" id="preview-modal" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="preview-modal-title">QR code preview</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body text-center">
<img id="preview-modal-img" src="" alt="" style="max-width:100%;max-height:70vh;">
</div>
</div>
</div>
</div>
<!-- /.Qr Code Preview Modal -->
<script>
document.querySelectorAll('.qr-thumb-link').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
document.getElementById('preview-modal-img').src = link.getAttribute('data-qr-src');
document.getElementById('preview-modal-title').textContent = link.getAttribute('data-qr-name');
});
});
</script>
<script>
const deleteButtons = document.querySelectorAll('.delete_btn');
+46 -4
View File
@@ -1,4 +1,6 @@
<?php $is_readonly_user = $_SESSION['type'] === 'user'; ?>
<div class="row">
<?php if (!$is_readonly_user): ?>
<div class="col-12" id="bulk-action-div" style="display: none;">
<div id="err-msg"></div>
<div class="bulk-action-wrapper">
@@ -21,13 +23,16 @@
</form>
</div>
</div>
<?php endif; ?>
<div class="col-12">
<div class="card">
<div class="card-body table-responsive p-0">
<table class="table table-striped table-bordered">
<thead>
<tr>
<?php if (!$is_readonly_user): ?>
<th><input type="checkbox" name="bulk-select" value="1"></th>
<?php endif; ?>
<th>ID</th>
<th>Owner</th>
<th>Filename</th>
@@ -40,7 +45,9 @@
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<?php if (!$is_readonly_user): ?>
<td><input type="checkbox" name="action[]" value="<?=$row['id']?>" onchange="updateBulkActionVisibility()"></td>
<?php endif; ?>
<td><?php echo $row['id']; ?></td>
<td>
<?php
@@ -61,10 +68,14 @@
<td><?php echo htmlspecialchars($row['type']); ?></td>
<td><?php echo htmlspecialchars_decode($row['content']); ?></td>
<td>
<?php echo '<img src="'.SAVED_QRCODE_FOLDER.htmlspecialchars($row['qrcode']).'" width="100" height="100">'; ?>
<a href="#" class="qr-thumb-link" data-toggle="modal" data-target="#preview-modal"
data-qr-src="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>"
data-qr-name="<?php echo htmlspecialchars($row['filename']); ?>">
<img src="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>" class="qr-thumb-img" alt="QR code for <?php echo htmlspecialchars($row['filename']); ?>">
</a>
</td>
<td>
<?php if (!$is_readonly_user): ?>
<!-- EDIT -->
<a href="static_qrcode.php?edit=true&id=<?php echo $row['id']; ?>" class="btn btn-primary"><i class="fas fa-edit"></i></a>
@@ -75,9 +86,12 @@
data-target="#delete-modal"
data-del_id="<?php echo $row["id"];?>"
><i class="fas fa-trash"></i></a>
<?php endif; ?>
<!-- DOWNLOAD -->
<a href="<?php echo SAVED_QRCODE_FOLDER.htmlspecialchars($row['qrcode']); ?>" class="btn btn-primary" download><i class="fa fa-download"></i></a>
<a href="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>&download=1" class="btn btn-primary"><i class="fa fa-download"></i></a>
<!-- COPY TO CLIPBOARD -->
<button type="button" class="btn btn-secondary copy-qr-btn" data-qr-src="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>" title="Copy image to clipboard"><i class="fa fa-copy"></i></button>
</td>
</tr>
<?php endforeach; ?>
@@ -93,6 +107,7 @@
</div><!-- /.col -->
</div><!-- /.row -->
<?php if (!$is_readonly_user): ?>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="delete-modal" role="dialog">
<div class="modal-dialog">
@@ -118,6 +133,33 @@
</div>
</div>
<!-- /.Delete Confirmation Modal -->
<?php endif; ?>
<!-- Qr Code Preview Modal -->
<div class="modal fade" id="preview-modal" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="preview-modal-title">QR code preview</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body text-center">
<img id="preview-modal-img" src="" alt="" style="max-width:100%;max-height:70vh;">
</div>
</div>
</div>
</div>
<!-- /.Qr Code Preview Modal -->
<script>
document.querySelectorAll('.qr-thumb-link').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
document.getElementById('preview-modal-img').src = link.getAttribute('data-qr-src');
document.getElementById('preview-modal-title').textContent = link.getAttribute('data-qr-name');
});
});
</script>
<script>
const deleteButtons = document.querySelectorAll('.delete_btn');
+4 -2
View File
@@ -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 -->
+39
View File
@@ -117,6 +117,45 @@ function paginationLinks($current_page, $total_pages, $base_url) {
return $html;
}
/**
* Handles the optional "icon above the qr code" upload. Validates the actual image
* type (not just the extension/mime the browser claims) and stores the file outside
* the document root, next to the generated qr codes.
*/
function qr_handle_icon_upload($fileKey = 'icon') {
if (!isset($_FILES[$fileKey]) || $_FILES[$fileKey]['error'] === UPLOAD_ERR_NO_FILE) {
return ['ok' => true, 'path' => null];
}
if ($_FILES[$fileKey]['error'] !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'Icon upload failed.'];
}
if ($_FILES[$fileKey]['size'] > 1024 * 1024) {
return ['ok' => false, 'error' => 'Icon must be smaller than 1MB.'];
}
$info = @getimagesize($_FILES[$fileKey]['tmp_name']);
$allowed = [IMAGETYPE_PNG => 'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_GIF => 'gif'];
if ($info === false || !isset($allowed[$info[2]])) {
return ['ok' => false, 'error' => 'Icon must be a PNG, JPEG or GIF image.'];
}
$dir = SAVED_QRCODE_DIRECTORY . 'icons/';
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
$destination = $dir . bin2hex(random_bytes(16)) . '.' . $allowed[$info[2]];
if (!move_uploaded_file($_FILES[$fileKey]['tmp_name'], $destination)) {
return ['ok' => false, 'error' => 'Could not store the uploaded icon.'];
}
return ['ok' => true, 'path' => $destination];
}
function base_url() {
require_once(__DIR__ . '/../config/environment.php');
if (defined('BASE_URL') && BASE_URL !== null) {
+2 -1
View File
@@ -3,8 +3,9 @@
//If User is logged in the session['user_logged_in'] will be set to true
//if user is Not Logged in, redirect to login.php page.
if (!isset($_SESSION['user_logged_in'])) {
if (empty($_SESSION['user_logged_in'])) {
header('Location:login.php');
exit;
}
?>
+1
View File
@@ -12,3 +12,4 @@ require_once __DIR__ . '/security.php';
qr_session_start();
qr_enforce_session_timeout();
qr_enforce_password_change();
qr_enforce_email_set();
+69 -2
View File
@@ -6,9 +6,10 @@
<!-- Main Footer -->
<footer class="main-footer text-sm">
<strong><a href="https://github.com/giandonatoinverso/PHP-Dynamic-Qr-code" target="_blank">PHP Qrcode Generator</a> by </strong> Giandonato Inverso
<strong><a href="https://www.qrforge.eu" target="_blank">QRForge</a></strong> -
<a href="./about.php">About</a> / credits
<div class="float-right d-none d-sm-inline-block">
<b>Version</b> 2.3.0
<b>Version</b> 3.0
</div>
</footer>
</div>
@@ -29,3 +30,69 @@
<script src="plugins/daterangepicker/daterangepicker.js"></script>
<!-- Overlay scrollbar -->
<script type="text/javascript" src="plugins/overlayScrollbars/js/OverlayScrollbars.js"></script>
<!-- PWA service worker -->
<script>
if ('serviceWorker' in navigator) {
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>
+6
View File
@@ -2,6 +2,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="csrf-token" content="<?php echo csrf_token(); ?>">
<meta name="theme-color" content="#2563EB">
<link rel="manifest" href="manifest.json">
<link rel="icon" href="dist/img/brand/favicon.svg" type="image/svg+xml">
<link rel="icon" href="dist/img/brand/favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="dist/img/brand/favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="dist/img/brand/apple-touch-icon.png">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="plugins/fontawesome-free/css/all.min.css">
+85 -1
View File
@@ -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
*/
@@ -143,6 +178,55 @@ function qr_is_login_locked_out($username) {
return $count !== null && $count >= LOGIN_MAX_ATTEMPTS;
}
/**
* Compute the owner-scope for a freshly authenticated user row (see qr_scope_owner_id()
* below for the meaning of the returned value). Call once at login and store the result
* in $_SESSION['scope_owner_id'].
*/
function qr_compute_scope_owner_id($user_row) {
if ($user_row['type'] === 'admin') {
return (int) $user_row['id'];
}
if ($user_row['type'] === 'user' && !empty($user_row['owner_admin_id'])) {
return (int) $user_row['owner_admin_id'];
}
return null;
}
/**
* Owner-scope for the qr code lists/reports, set at login time in $_SESSION['scope_owner_id']:
* - null: full visibility (super, or a company-wide 'user' account created by super)
* - int: restricted to codes owned by this admin id (an admin's own account, or a
* 'user' account created by that admin)
*/
function qr_scope_owner_id() {
return $_SESSION['scope_owner_id'] ?? null;
}
function qr_has_full_visibility() {
return qr_scope_owner_id() === null;
}
/**
* Apply the current session's owner scope to a MysqliDb query builder in place.
* No-op when the session has full visibility.
*
* Uses a single raw, parenthesized condition rather than where()+orWhere() -
* the previous two-call form produced "WHERE id = ? AND id_owner = ? OR id_owner
* IS NULL" whenever a caller had already added its own where('id', ...) (e.g.
* qrcode_image.php, bulk_action.php), and AND binds tighter than OR in SQL, so
* the OR silently detached from the id filter and matched *any* id_owner-NULL
* row instead of the one actually requested.
*/
function qr_apply_owner_scope($db) {
$scope_owner_id = qr_scope_owner_id();
if ($scope_owner_id !== null) {
$db->where('(id_owner = ' . (int) $scope_owner_id . ' OR id_owner IS NULL)');
}
}
/**
* Audit log
*/
+36 -6
View File
@@ -2,9 +2,9 @@
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<!-- Brand Logo -->
<a href="./index.php" class="brand-link">
<img src="dist/img/Symbol_WhiteBlue.png" alt="Logo" class="brand-image"
<img src="dist/img/brand/icon-inverse.svg" alt="QRForge" class="brand-image"
style="opacity: .8">
<span class="brand-text font-weight-light">Qrcode Generator</span>
<span class="brand-text font-weight-light">QRForge</span>
</a>
<!-- Sidebar -->
@@ -33,6 +33,15 @@
</p>
</a>
</li>
<li class="nav-item">
<a href="./scan_qrcode.php" <?php echo (CURRENT_PAGE == 'scan_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="nav-icon fas fa-camera"></i>
<p>
Scan qr code
</p>
</a>
</li>
<?php if ($_SESSION['type'] !== 'user' || !empty($_SESSION['can_view_dynamic'])): ?>
<li <?php echo ((substr(CURRENT_PAGE, 0, 19) == 'dynamic_qrcodes.php') || (substr(CURRENT_PAGE, 0, 18) == 'dynamic_qrcode.php')) ? ' class="nav-item has-treeview menu-open"' : ' class="nav-item has-treeview"'; ?>>
<a href="#" <?php echo ((substr(CURRENT_PAGE, 0, 19) == 'dynamic_qrcodes.php') || (substr(CURRENT_PAGE, 0, 18) == 'dynamic_qrcode.php')) ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="nav-icon fa fa-qrcode"></i>
@@ -44,18 +53,28 @@
<ul class="nav nav-treeview">
<li class="nav-item">
<a href="./dynamic_qrcodes.php" <?php echo ((substr(CURRENT_PAGE, 0, 19) == 'dynamic_qrcodes.php')) ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="far fa-circle nav-icon"></i>
<i class="fas fa-angle-right nav-icon"></i>
<p>List all</p>
</a>
</li>
<?php if ($_SESSION['type'] !== 'user'): ?>
<li class="nav-item">
<a href="./dynamic_qrcode.php" <?php echo (CURRENT_PAGE == 'dynamic_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="far fa-circle nav-icon"></i>
<i class="fas fa-angle-right nav-icon"></i>
<p>Add new</p>
</a>
</li>
<li class="nav-item">
<a href="./batch_qrcode.php" <?php echo (CURRENT_PAGE == 'batch_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="fas fa-angle-right nav-icon"></i>
<p>Batch create (CSV)</p>
</a>
</li>
<?php endif; ?>
</ul>
</li>
<?php endif; ?>
<?php if ($_SESSION['type'] !== 'user' || !empty($_SESSION['can_view_static'])): ?>
<li <?php echo ((substr(CURRENT_PAGE, 0, 18) == 'static_qrcodes.php') || (substr(CURRENT_PAGE, 0, 17) == 'static_qrcode.php')) ? ' class="nav-item has-treeview menu-open"' : ' class="nav-item has-treeview"'; ?>>
<a href="#" <?php echo ((substr(CURRENT_PAGE, 0, 18) == 'static_qrcodes.php') || (substr(CURRENT_PAGE, 0, 17) == 'static_qrcode.php')) ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="nav-icon fa fa-qrcode"></i>
@@ -67,24 +86,35 @@
<ul class="nav nav-treeview">
<li class="nav-item">
<a href="./static_qrcodes.php" <?php echo ((substr(CURRENT_PAGE, 0, 18) == 'static_qrcodes.php')) ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="far fa-circle nav-icon"></i>
<i class="fas fa-angle-right nav-icon"></i>
<p>List all</p>
</a>
</li>
<?php if ($_SESSION['type'] !== 'user'): ?>
<li class="nav-item">
<a href="./static_qrcode.php" <?php echo (CURRENT_PAGE == 'static_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="far fa-circle nav-icon"></i>
<i class="fas fa-angle-right nav-icon"></i>
<p>Add new</p>
</a>
</li>
<?php endif; ?>
</ul>
</li>
<?php endif; ?>
<?php if (in_array($_SESSION['type'], ['super', 'admin'], true)): ?>
<li class="nav-item">
<a href="./users.php" <?php echo ((substr(CURRENT_PAGE, 0, 15) == 'users.php') || (substr(CURRENT_PAGE, 0, 14) == 'user.php')) ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="fas fa-users nav-icon"></i>
<p>Users</p>
</a>
</li>
<?php endif; ?>
<li class="nav-item">
<a href="./about.php" <?php echo (CURRENT_PAGE == 'about.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="fas fa-info-circle nav-icon"></i>
<p>About</p>
</a>
</li>
</ul>
</nav>
<!-- /.sidebar-menu -->
+30 -31
View File
@@ -4,35 +4,30 @@ require_once 'includes/auth_validate.php';
$db = getDbInstance();
// Full visibility (super, or a company-wide 'user' account) vs. scoped to one admin's
// own codes (an admin, or a 'user' account created by that admin).
$is_full_visibility = qr_has_full_visibility();
//Get Dynamic qr code rows
if($_SESSION['type'] !== 'super') {
$db->where("id_owner", $_SESSION['user_id']);
$db->orWhere ("id_owner", NULL, 'IS');
}
qr_apply_owner_scope($db);
$numQrcode_dynamic = $db->getValue("dynamic_qrcodes", "count(*)");
//Get Static qr code rows
if($_SESSION['type'] !== 'super') {
$db->where("id_owner", $_SESSION['user_id']);
$db->orWhere ("id_owner", NULL, 'IS');
}
qr_apply_owner_scope($db);
$numQrcode_static = $db->getValue("static_qrcodes", "count(*)");
$total = $numQrcode_dynamic + $numQrcode_static;
//Get Total scan
if($_SESSION['type'] !== 'super') {
$db->where("id_owner", $_SESSION['user_id']);
$db->orWhere ("id_owner", NULL, 'IS');
}
qr_apply_owner_scope($db);
$numScan = $db->getOne("dynamic_qrcodes", "sum(scan) as numScan");
/* CREATED CHART */
//I initialize the variables that will contain the daily values to 0 otherwise in the foreach loop they will be reset every time
//Get the number of DYNAMIC qr code created in 7 days and total scan
if($_SESSION['type'] !== 'super')
$createdQrcode_dynamic = $db->query("select `created_at`, `scan` from " . DATABASE_PREFIX . "dynamic_qrcodes where `created_at` > curdate()-7 AND (`id_owner`= " . $_SESSION['user_id'] . " OR `id_owner` IS NULL);");
if(!$is_full_visibility)
$createdQrcode_dynamic = $db->query("select `created_at`, `scan` from " . DATABASE_PREFIX . "dynamic_qrcodes where `created_at` > curdate()-7 AND (`id_owner`= " . (int) qr_scope_owner_id() . " OR `id_owner` IS NULL);");
else
$createdQrcode_dynamic = $db->query("select `created_at`, `scan` from ".DATABASE_PREFIX."dynamic_qrcodes where `created_at` > curdate()-7;");
@@ -54,8 +49,8 @@ foreach ($createdQrcode_dynamic as $row) {
/* SCAN CHART */
//Get the number of STATIC qr code created in 7 days
if($_SESSION['type'] !== 'super')
$createdQrcode_static = $db->query("select `created_at` from " . DATABASE_PREFIX . "static_qrcodes where `created_at` > curdate()-7 AND (`id_owner`=" . $_SESSION['user_id'] . " OR `id_owner` IS NULL);");
if(!$is_full_visibility)
$createdQrcode_static = $db->query("select `created_at` from " . DATABASE_PREFIX . "static_qrcodes where `created_at` > curdate()-7 AND (`id_owner`=" . (int) qr_scope_owner_id() . " OR `id_owner` IS NULL);");
else
$createdQrcode_static = $db->query("select `created_at` from ".DATABASE_PREFIX."static_qrcodes where `created_at` > curdate()-7;");
@@ -75,7 +70,7 @@ foreach ($createdQrcode_static as $row) {
<!DOCTYPE html>
<html lang="en">
<title>Qr Code Generator</title>
<title>QRForge</title>
<head>
<?php include './includes/head.php'; ?>
</head>
@@ -125,29 +120,33 @@ foreach ($createdQrcode_static as $row) {
</div><!-- /.col -->
<div class="col-12 col-sm-6 col-md-3">
<div class="info-box mb-3 bg-success">
<span class="info-box-icon"><i class="fa fa-qrcode"></i></span>
<a href="dynamic_qrcodes.php" class="d-block">
<div class="info-box mb-3 bg-success">
<span class="info-box-icon"><i class="fa fa-qrcode"></i></span>
<div class="info-box-content">
<span class="info-box-text">Dynamic Qr codes</span>
<span class="info-box-number"><?php echo $numQrcode_dynamic; ?></span>
</div><!-- /.info-box-content -->
</div>
<div class="info-box-content">
<span class="info-box-text">Dynamic Qr codes</span>
<span class="info-box-number"><?php echo $numQrcode_dynamic; ?></span>
</div><!-- /.info-box-content -->
</div>
</a>
</div><!-- /.col -->
<!-- fix for small devices only -->
<div class="clearfix hidden-md-up"></div>
<div class="col-12 col-sm-6 col-md-3">
<div class="info-box mb-3 bg-danger">
<span class="info-box-icon"><i class="fa fa-qrcode"></i></span>
<a href="static_qrcodes.php" class="d-block">
<div class="info-box mb-3 bg-danger">
<span class="info-box-icon"><i class="fa fa-qrcode"></i></span>
<div class="info-box-content">
<span class="info-box-text">Static QR codes</span>
<span class="info-box-number"><?php echo $numQrcode_static; ?></span>
</div><!-- /.info-box-content -->
<div class="info-box-content">
<span class="info-box-text">Static QR codes</span>
<span class="info-box-number"><?php echo $numQrcode_static; ?></span>
</div><!-- /.info-box-content -->
</div>
</div>
</a>
</div><!-- /.col -->
<div class="col-12 col-sm-6 col-md-3">
+39
View File
@@ -76,6 +76,45 @@ class DynamicQrcode {
$this->qrcode_instance->addQrcode($input_data, $data_to_db, $data_to_qrcode);
}
/**
* Batch-safe variant used by batch_qrcode.php: creates one dynamic qr code from a
* CSV row (filename + link) with sane defaults, returning a result array
* (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting.
*/
public function addQrcodeBatchRow($filename, $link, $id_owner) {
$filename = trim((string) $filename);
$link = trim((string) $link);
if ($filename === '') {
return ['ok' => false, 'error' => 'Filename is required.'];
}
if ($link === '' || strlen($link) > 500) {
return ['ok' => false, 'error' => 'Link is required and must be at most 500 characters.'];
}
$data_to_db['id_owner'] = $id_owner !== '' ? $id_owner : NULL;
$data_to_db['filename'] = htmlspecialchars($filename, ENT_QUOTES, 'UTF-8');
$data_to_db['created_at'] = date('Y-m-d H:i:s');
$data_to_db['link'] = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
$data_to_db['created_by'] = $_SESSION['user_id'];
$data_to_db['format'] = 'png';
$data_to_db['identifier'] = randomString(rand(5, 8));
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$data_to_db['format'];
$data_to_qrcode = READ_PATH.$data_to_db['identifier'];
$input_data = [
'level' => 'L',
'size' => 200,
'foreground' => '#000000',
'background' => '#ffffff',
'frame_text' => '',
];
return $this->qrcode_instance->addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode);
}
/**
* Edit qr code
*
+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;
}
}
}
+1 -1
View File
@@ -830,7 +830,7 @@ class MysqliDb
* @return bool|array Boolean indicating the insertion failed (false), else return id-array ([int])
* @throws Exception
*/
public function insertMulti($tableName, array $multiInsertData, array $dataKeys = null)
public function insertMulti($tableName, array $multiInsertData, ?array $dataKeys = null)
{
// only auto-commit our inserts, if no transaction is currently running
$autoCommit = (isset($this->_transaction_in_progress) ? !$this->_transaction_in_progress : true);
+190 -12
View File
@@ -52,11 +52,11 @@ class Qrcode {
$filename = trim((string) $filename);
if ($filename === '' || strlen($filename) > 45) {
$this->failure('Filename must be between 1 and 45 characters.');
throw new \InvalidArgumentException('Filename must be between 1 and 45 characters.');
}
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
$this->failure('Filename cannot contain path separators.');
throw new \InvalidArgumentException('Filename cannot contain path separators.');
}
return $filename;
@@ -66,16 +66,153 @@ class Qrcode {
$format = strtolower((string) $format);
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
$this->failure('Invalid qr code format.');
throw new \InvalidArgumentException('Invalid qr code format.');
}
return $format;
}
const FRAME_FONT_DIR = '/usr/share/fonts/truetype/dejavu/';
const ALLOWED_FRAME_FONTS = [
'sans' => 'DejaVuSans.ttf',
'sans-bold' => 'DejaVuSans-Bold.ttf',
'serif' => 'DejaVuSerif.ttf',
'serif-bold' => 'DejaVuSerif-Bold.ttf',
'mono' => 'DejaVuSansMono.ttf',
'mono-bold' => 'DejaVuSansMono-Bold.ttf',
];
private static function resolveFrameFont($fontKey) {
$file = self::ALLOWED_FRAME_FONTS[$fontKey] ?? self::ALLOWED_FRAME_FONTS['sans'];
$path = self::FRAME_FONT_DIR . $file;
return is_file($path) ? $path : null;
}
/**
* Renders an optional text label below the qr code. Only supported for raster
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
*/
private function addFrameText($path, $format, $text, $fontKey = 'sans', $fontSize = 16) {
$text = trim((string) $text);
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if ($text === '' || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$fontFile = self::resolveFrameFont($fontKey);
$fontSize = min(max((int) $fontSize, 8), 60);
$source = @$loaders[$format]($path);
if ($source === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$padding = $fontFile !== null ? $fontSize + 20 : 30;
$canvas = imagecreatetruecolor($width, $height + $padding);
$white = imagecolorallocate($canvas, 255, 255, 255);
$black = imagecolorallocate($canvas, 0, 0, 0);
imagefill($canvas, 0, 0, $white);
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
if ($fontFile !== null && function_exists('imagettftext')) {
$bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$text_width = abs($bbox[2] - $bbox[0]);
$text_height = abs($bbox[1] - $bbox[7]);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding + $text_height) / 2);
imagettftext($canvas, $fontSize, 0, $x, $y, $black, $fontFile, $text);
} else {
$font = 5;
$text_width = imagefontwidth($font) * strlen($text);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
imagestring($canvas, $font, $x, $y, $text, $black);
}
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($canvas);
}
/**
* Renders an optional user-uploaded icon above the qr code (not embedded inside
* it, so scanability is unaffected). Only supported for raster formats via GD.
*/
private function addTopIcon($path, $format, $iconPath) {
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if (!$iconPath || !is_file($iconPath) || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$iconInfo = @getimagesize($iconPath);
$iconLoaders = [IMAGETYPE_PNG => 'imagecreatefrompng', IMAGETYPE_JPEG => 'imagecreatefromjpeg', IMAGETYPE_GIF => 'imagecreatefromgif'];
if ($iconInfo === false || !isset($iconLoaders[$iconInfo[2]])) {
return;
}
$source = @$loaders[$format]($path);
$icon = @$iconLoaders[$iconInfo[2]]($iconPath);
if ($source === false || $icon === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$iconWidth = imagesx($icon);
$iconHeight = imagesy($icon);
$maxIconHeight = (int) ($height * 0.625);
$scale = min($maxIconHeight / $iconHeight, ($width * 0.6) / $iconWidth, 1);
$targetWidth = max(1, (int) ($iconWidth * $scale));
$targetHeight = max(1, (int) ($iconHeight * $scale));
$margin = 15;
$topPadding = $targetHeight + ($margin * 2);
$canvas = imagecreatetruecolor($width, $height + $topPadding);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
$resizedIcon = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($resizedIcon, false);
imagesavealpha($resizedIcon, true);
$transparent = imagecolorallocatealpha($resizedIcon, 0, 0, 0, 127);
imagefill($resizedIcon, 0, 0, $transparent);
imagealphablending($icon, true);
imagecopyresampled($resizedIcon, $icon, 0, 0, 0, 0, $targetWidth, $targetHeight, $iconWidth, $iconHeight);
$x = (int) (($width - $targetWidth) / 2);
imagecopy($canvas, $resizedIcon, $x, $margin, 0, 0, $targetWidth, $targetHeight);
imagecopy($canvas, $source, 0, $topPadding, 0, 0, $width, $height);
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($icon);
imagedestroy($resizedIcon);
imagedestroy($canvas);
}
public function getQrcode($id) {
require_once BASE_PATH . '/includes/security.php';
$db = getDbInstance();
$db->where('id', $id);
qr_apply_owner_scope($db);
$result = $db->getOne($this->table);
if($result !== NULL)
@@ -121,6 +258,36 @@ class Qrcode {
* We save into db the url of qrcode image
*/
public function addQrcode($input_data, $data_to_db, $data_to_qrcode) {
try {
$last_id = $this->renderAndStore($input_data, $data_to_db, $data_to_qrcode);
} catch (\Throwable $e) {
$this->failure($e->getMessage());
}
audit_log('qrcode_created', $this->table, $last_id);
$this->success('Qr code added successfully!');
}
/**
* Batch-safe variant of addQrcode(): generates and stores the qr code but returns a
* result array (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting,
* so batch_qrcode.php can create many codes in one request.
*/
public function addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode) {
try {
$last_id = $this->renderAndStore($input_data, $data_to_db, $data_to_qrcode);
audit_log('qrcode_created', $this->table, $last_id);
return ['ok' => true, 'id' => $last_id];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
/**
* Core qr code rendering + storage, shared by addQrcode() and addQrcodeBatch().
* Throws instead of calling failure() so batch processing can catch and continue.
*/
private function renderAndStore($input_data, $data_to_db, $data_to_qrcode) {
$options = $this->setOptions($input_data);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
@@ -324,7 +491,14 @@ class Qrcode {
}
catch(Exception $e)
{
$this->failure($e->getMessage());
throw new \RuntimeException($e->getMessage());
}
$this->addTopIcon($filename, $fileExt, $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($filename, $fileExt, $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
// If you want you can customi<e qr code with logo
@@ -334,15 +508,13 @@ class Qrcode {
$last_id = $db->insert($this->table, $data_to_db);
}
else
$this->failure('You cannot create a new qr code with an existing name on the server!');
throw new \RuntimeException('You cannot create a new qr code with an existing name on the server!');
if ($last_id){
audit_log('qrcode_created', $this->table, $last_id);
$this->success('Qr code added successfully!');
}
else {
$this->failure('Insert failed: ' . $db->getLastError());
if (!$last_id) {
throw new \RuntimeException('Insert failed: ' . $db->getLastError());
}
return $last_id;
}
/**
@@ -353,11 +525,16 @@ class Qrcode {
$db = getDbInstance();
$old_qrcode = $this->getQrcode($input_data["id"]);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
try {
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
} catch (\InvalidArgumentException $e) {
$this->failure($e->getMessage());
}
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$old_qrcode["format"];
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$old_qrcode["format"]) || $data_to_db['filename'] == $input_data["old_filename"]){
$db->where('id', $input_data["id"]);
qr_apply_owner_scope($db);
$stat = $db->update($this->table, $data_to_db);
try{
@@ -390,6 +567,7 @@ class Qrcode {
$qrcode = $this->getQrcode($id);
$db->where('id', $id);
qr_apply_owner_scope($db);
$status = $db->delete($this->table);
if ($status) {
+217
View File
@@ -57,10 +57,147 @@ class Qrcode {
return $format;
}
const FRAME_FONT_DIR = '/usr/share/fonts/truetype/dejavu/';
const ALLOWED_FRAME_FONTS = [
'sans' => 'DejaVuSans.ttf',
'sans-bold' => 'DejaVuSans-Bold.ttf',
'serif' => 'DejaVuSerif.ttf',
'serif-bold' => 'DejaVuSerif-Bold.ttf',
'mono' => 'DejaVuSansMono.ttf',
'mono-bold' => 'DejaVuSansMono-Bold.ttf',
];
private static function resolveFrameFont($fontKey) {
$file = self::ALLOWED_FRAME_FONTS[$fontKey] ?? self::ALLOWED_FRAME_FONTS['sans'];
$path = self::FRAME_FONT_DIR . $file;
return is_file($path) ? $path : null;
}
/**
* Renders an optional text label below the qr code. Only supported for raster
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
*/
private function addFrameText($path, $format, $text, $fontKey = 'sans', $fontSize = 16) {
$text = trim((string) $text);
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if ($text === '' || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$fontFile = self::resolveFrameFont($fontKey);
$fontSize = min(max((int) $fontSize, 8), 60);
$source = @$loaders[$format]($path);
if ($source === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$padding = $fontFile !== null ? $fontSize + 20 : 30;
$canvas = imagecreatetruecolor($width, $height + $padding);
$white = imagecolorallocate($canvas, 255, 255, 255);
$black = imagecolorallocate($canvas, 0, 0, 0);
imagefill($canvas, 0, 0, $white);
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
if ($fontFile !== null && function_exists('imagettftext')) {
$bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$text_width = abs($bbox[2] - $bbox[0]);
$text_height = abs($bbox[1] - $bbox[7]);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding + $text_height) / 2);
imagettftext($canvas, $fontSize, 0, $x, $y, $black, $fontFile, $text);
} else {
$font = 5;
$text_width = imagefontwidth($font) * strlen($text);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
imagestring($canvas, $font, $x, $y, $text, $black);
}
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($canvas);
}
/**
* Renders an optional user-uploaded icon above the qr code (not embedded inside
* it, so scanability is unaffected). Only supported for raster formats via GD.
*/
private function addTopIcon($path, $format, $iconPath) {
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if (!$iconPath || !is_file($iconPath) || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$iconInfo = @getimagesize($iconPath);
$iconLoaders = [IMAGETYPE_PNG => 'imagecreatefrompng', IMAGETYPE_JPEG => 'imagecreatefromjpeg', IMAGETYPE_GIF => 'imagecreatefromgif'];
if ($iconInfo === false || !isset($iconLoaders[$iconInfo[2]])) {
return;
}
$source = @$loaders[$format]($path);
$icon = @$iconLoaders[$iconInfo[2]]($iconPath);
if ($source === false || $icon === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$iconWidth = imagesx($icon);
$iconHeight = imagesy($icon);
$maxIconHeight = (int) ($height * 0.625);
$scale = min($maxIconHeight / $iconHeight, ($width * 0.6) / $iconWidth, 1);
$targetWidth = max(1, (int) ($iconWidth * $scale));
$targetHeight = max(1, (int) ($iconHeight * $scale));
$margin = 15;
$topPadding = $targetHeight + ($margin * 2);
$canvas = imagecreatetruecolor($width, $height + $topPadding);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
$resizedIcon = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($resizedIcon, false);
imagesavealpha($resizedIcon, true);
$transparent = imagecolorallocatealpha($resizedIcon, 0, 0, 0, 127);
imagefill($resizedIcon, 0, 0, $transparent);
imagealphablending($icon, true);
imagecopyresampled($resizedIcon, $icon, 0, 0, 0, 0, $targetWidth, $targetHeight, $iconWidth, $iconHeight);
$x = (int) (($width - $targetWidth) / 2);
imagecopy($canvas, $resizedIcon, $x, $margin, 0, 0, $targetWidth, $targetHeight);
imagecopy($canvas, $source, 0, $topPadding, 0, 0, $width, $height);
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($icon);
imagedestroy($resizedIcon);
imagedestroy($canvas);
}
public function getQrcode($id) {
require_once BASE_PATH . '/includes/security.php';
$db = getDbInstance();
$db->where('id', $id);
qr_apply_owner_scope($db);
$result = $db->getOne($this->table);
if($result !== NULL)
@@ -133,6 +270,13 @@ class Qrcode {
$this->failure($e->getMessage());
}
$this->addTopIcon($filename, $data_to_db['format'], $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($filename, $data_to_db['format'], $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
// If you want you can customi<e qr code with logo
//$this->addLogo($data_to_db['qrcode'], $options['optionlogo']);
@@ -151,6 +295,77 @@ class Qrcode {
}
}
/**
* Batch-safe variant of addQrcode(): generates and stores the qr code but returns a
* result array (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting,
* so batch_qrcode.php can create many codes in one request. Deliberately does not
* reuse addQrcode()/sanitizeFilename()/validateFormat(), since those call failure()
* (redirect + exit) which would abort the whole batch after the first bad row.
*/
public function addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode) {
$filename = trim((string) $data_to_db['filename']);
$format = strtolower((string) $data_to_db['format']);
if ($filename === '' || strlen($filename) > 45) {
return ['ok' => false, 'error' => 'Filename must be between 1 and 45 characters.'];
}
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
return ['ok' => false, 'error' => 'Filename cannot contain path separators.'];
}
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
return ['ok' => false, 'error' => 'Invalid qr code format.'];
}
$data_to_db['filename'] = $filename;
$data_to_db['format'] = $format;
$path = SAVED_QRCODE_DIRECTORY.$filename.'.'.$format;
if (file_exists($path)) {
return ['ok' => false, 'error' => 'A qr code with this filename already exists.'];
}
$options = $this->setOptions($input_data);
$url =
'https://api.qrserver.com/v1/create-qr-code/?data='.
$data_to_qrcode.
'&amp;&size='.$options['size'].'x'.$options['size'].
'&ecc='.$options['errorCorrectionLevel'].
'&margin=0&color='.$options['foreground'].
'&bgcolor='.$options['background'].
'&qzone=2'.
'&format='.$format;
$content = @file_get_contents($url);
if ($content === false) {
return ['ok' => false, 'error' => 'Could not generate the qr code image.'];
}
if (@file_put_contents($path, $content) === false) {
return ['ok' => false, 'error' => 'Could not write the qr code file.'];
}
$this->addTopIcon($path, $format, $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($path, $format, $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
$db = getDbInstance();
$last_id = $db->insert($this->table, $data_to_db);
if (!$last_id) {
return ['ok' => false, 'error' => 'Insert failed: ' . $db->getLastError()];
}
audit_log('qrcode_created', $this->table, $last_id);
return ['ok' => true, 'id' => $last_id];
}
/**
* Edit qr code
*
@@ -164,6 +379,7 @@ class Qrcode {
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$old_qrcode["format"]) || $data_to_db['filename'] == $input_data["old_filename"]){
$db->where('id', $input_data["id"]);
qr_apply_owner_scope($db);
$stat = $db->update($this->table, $data_to_db);
try{
@@ -196,6 +412,7 @@ class Qrcode {
$qrcode = $this->getQrcode($id);
$db->where('id', $id);
qr_apply_owner_scope($db);
$status = $db->delete($this->table);
if ($status) {
+104 -11
View File
@@ -171,7 +171,7 @@ class StaticQrcode {
* 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){
@@ -188,7 +188,7 @@ class StaticQrcode {
$vcard->categories($categories);
$vcard->note($note);
$vcard->photo($photo);
$vcard->address($address, $city, $postcode, $state);
$vcard->address($address, $city, $state, $postcode, $country);
$vcard->create();
$this->sData = $vcard->get();
@@ -202,7 +202,7 @@ class StaticQrcode {
$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>';
@@ -330,21 +330,48 @@ class StaticQrcode {
/**
* create a qr code of type "bitcoin"
* @string address -> required
* @int amount -> required
* @string amount -> optional (a bitcoin address is useful as a standing QR code,
* not just for one specific payment amount)
* @string label
* @string message
*/
public function bitcoinQrcode($address, $amount, $label, $message)
{
if($address != NULL && $amount != NULL){
$this->sData = 'bitcoin:'.$address.'?amount='.$amount.'&label='.$label.'&message='.$message;
$this->sContent = '<strong>BTC address:</strong> '.$address.'<br>'.'<strong>Amount:</strong> '.$amount.'<br>';
$this->sContent .= '<strong>Label:</strong> '.$label.'<br>'.'<strong>Message:</strong> '.$message;
$address = trim((string) $address);
$amount = trim((string) $amount);
$label = trim((string) $label);
$message = trim((string) $message);
$this->addQrcode("bitcoin");
}
else
if ($address === '') {
$this->requiredFieldsError();
return;
}
$params = [];
if ($amount !== '') {
$params[] = 'amount=' . rawurlencode($amount);
}
if ($label !== '') {
$params[] = 'label=' . rawurlencode($label);
}
if ($message !== '') {
$params[] = 'message=' . rawurlencode($message);
}
$this->sData = 'bitcoin:' . $address . ($params ? '?' . implode('&', $params) : '');
$this->sContent = '<strong>BTC address:</strong> ' . $address . '<br>';
if ($amount !== '') {
$this->sContent .= '<strong>Amount:</strong> ' . $amount . '<br>';
}
if ($label !== '') {
$this->sContent .= '<strong>Label:</strong> ' . $label . '<br>';
}
if ($message !== '') {
$this->sContent .= '<strong>Message:</strong> ' . $message;
}
$this->addQrcode("bitcoin");
}
/**
@@ -378,6 +405,68 @@ class StaticQrcode {
$this->requiredFieldsError();
}
/**
* create a qr code of type "applink" (mobile app deep link)
* @string platform -> required, "android" (intent:// link with optional fallback) or "generic" (plain custom-scheme URI)
* @string scheme -> required, e.g. "myapp"
* @string path -> required, e.g. "open?ref=123" (without the scheme prefix)
* @string package -> required when platform is "android" (Android package name, e.g. com.example.app)
* @string fallback_url -> optional, Play Store/web fallback used by the Android intent link
*/
public function applinkQrcode($platform, $scheme, $path, $package, $fallback_url)
{
$is_android = $platform === 'android';
if ($scheme != NULL && $path != NULL && (!$is_android || $package != NULL)) {
if ($is_android) {
$this->sData = 'intent://' . $path . '#Intent;scheme=' . $scheme . ';package=' . $package;
if (!empty($fallback_url)) {
$this->sData .= ';S.browser_fallback_url=' . rawurlencode($fallback_url);
}
$this->sData .= ';end';
} else {
$this->sData = $scheme . '://' . $path;
}
$this->sContent = '<strong>Platform:</strong> ' . ($is_android ? 'Android (intent)' : 'Generic') . '<br>';
$this->sContent .= '<strong>Scheme:</strong> ' . $scheme . '<br>';
$this->sContent .= '<strong>Path:</strong> ' . $path;
if ($is_android) {
$this->sContent .= '<br><strong>Package:</strong> ' . $package;
}
if (!empty($fallback_url)) {
$this->sContent .= '<br><strong>Fallback URL:</strong> ' . $fallback_url;
}
$this->addQrcode("applink");
} else {
$this->requiredFieldsError();
}
}
/**
* create a qr code of type "bluetooth" (device pairing info)
* @string device_name -> required
* @string mac_address -> required
*
* Note: unlike WIFI:/vCard there is no OS-native "scan to pair" convention for
* Bluetooth, so this is purely informational - whoever scans it still has to pair
* the device manually via their Bluetooth settings using the name/address shown.
*/
public function bluetoothQrcode($device_name, $mac_address)
{
if ($device_name != NULL && $mac_address != NULL) {
$this->sData = 'BT:N:' . $device_name . ';M:' . $mac_address . ';';
$this->sContent = '<strong>Device name:</strong> ' . $device_name . '<br>' . '<strong>MAC address:</strong> ' . $mac_address;
$this->addQrcode("bluetooth");
} else {
$this->requiredFieldsError();
}
}
public function getQrcode($id) {
return $this->qrcode_instance->getQrcode($id);
}
@@ -410,6 +499,10 @@ class StaticQrcode {
$input_data["foreground"] = $_POST['foreground'];
$input_data["background"] = $_POST['background'];
$input_data["frame_text"] = $_POST['frame_text'] ?? '';
$input_data["frame_font"] = $_POST['frame_font'] ?? 'sans';
$input_data["frame_font_size"] = $_POST['frame_font_size'] ?? 16;
$input_data["icon_tmp_path"] = $_POST['icon_tmp_path'] ?? null;
$data_to_qrcode = urlencode($this->sData);
+219 -14
View File
@@ -1,9 +1,10 @@
<?php
require_once 'config/config.php';
require_once BASE_PATH . '/lib/Mailer/Mailer.php';
class Users
{
const ALLOWED_TYPES = ['super', 'admin'];
const ALLOWED_TYPES = ['super', 'admin', 'user'];
/**
*
@@ -13,7 +14,7 @@ class Users
}
/**
* Server-side validatie van username/type. Geeft een foutmelding terug (string) of null als geldig.
* Server-side validation of username/type. Returns an error message (string) or null if valid.
*/
private function validateUsernameAndType($username, $type) {
if (!is_string($username) || strlen($username) < 3 || strlen($username) > 50) {
@@ -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;
}
/**
*
*/
@@ -57,6 +75,24 @@ class Users
return $db->get(DATABASE_PREFIX.'users');
}
/**
* True if the logged-in admin is allowed to manage (edit/delete) this user record.
* Super can always manage everyone.
*/
private function canManage($target_user) {
if ($_SESSION['type'] === 'super') {
return true;
}
if ($_SESSION['type'] === 'admin') {
return $target_user !== null
&& $target_user['type'] === 'user'
&& (int) $target_user['owner_admin_id'] === (int) $_SESSION['user_id'];
}
return false;
}
public function getUser($id) {
$db = getDbInstance();
@@ -70,23 +106,49 @@ class Users
}
/**
* Add user
* Add user.
*
* A 'super' account can create any type freely (owner_admin_id stays NULL: company-wide).
* An 'admin' account can only create their own read-only 'user' accounts
* (type is forced to 'user', owner_admin_id is forced to their own id).
*/
public function addUser($input_data) {
$db = getDbInstance();
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $input_data['type'] ?? '');
$requested_type = $input_data['type'] ?? '';
$owner_admin_id = null;
if ($_SESSION['type'] === 'admin') {
$requested_type = 'user';
$owner_admin_id = $_SESSION['user_id'];
} elseif ($_SESSION['type'] !== 'super') {
header('HTTP/1.1 403 Forbidden', true, 403);
exit('403 Forbidden');
}
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $requested_type);
if ($validation_error !== null) {
$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"] = $input_data["type"];
$data_to_db["type"] = $requested_type;
$data_to_db['owner_admin_id'] = $owner_admin_id;
$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;
$db->where('username', $data_to_db['username']);
$db->get('users');
@@ -94,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) {
@@ -103,26 +175,135 @@ class Users
}
/**
* Edit user
* 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.
*
* An 'admin' may only edit their own 'user' accounts (checked via canManage()) and
* cannot change the type away from 'user'. A 'super' account can edit anyone freely.
*/
public function editUser($input_data) {
$db = getDbInstance();
$db->where('id', $input_data['id']);
$target = $db->getOne('users');
if (!$this->canManage($target)) {
header('HTTP/1.1 403 Forbidden', true, 403);
exit('403 Forbidden');
}
$query_string = http_build_query(array(
'id' => $input_data["id"],
'edit' => "true",
));
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $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);
if ($validation_error !== null) {
$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);
}
$db = getDbInstance();
$db->where('username', $input_data['username']);
$db->where('id', $input_data["id"], '!=');
$row = $db->getOne('users');
@@ -131,10 +312,29 @@ class Users
$this->failure('Username already exists', 'Location: user.php?'.$query_string);
}
$data_to_db["username"] = $input_data["username"];
$data_to_db["type"] = $input_data["type"];
if ($email !== '') {
$db = getDbInstance();
$db->where('email', $email);
$db->where('id', $input_data["id"], '!=');
$row = $db->getOne('users');
// Alleen wachtwoord overschrijven als er een nieuwe waarde is opgegeven.
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;
// Only overwrite the password if a new value was submitted.
if (!empty($input_data['password'])) {
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
}
@@ -150,13 +350,18 @@ class Users
}
/**
* Delete user
* Delete user.
*
* An 'admin' may only delete their own 'user' accounts; 'super' can delete anyone.
*/
public function deleteUser($id) {
if($_SESSION['type']!='super'){
header('HTTP/1.1 401 Unauthorized', true, 401);
exit("401 Unauthorized");
$db = getDbInstance();
$db->where('id', $id);
$target = $db->getOne('users');
if (!$this->canManage($target)) {
header('HTTP/1.1 403 Forbidden', true, 403);
exit('403 Forbidden');
}
$db = getDbInstance();
+3 -2
View File
@@ -43,10 +43,11 @@ class vCard
*
* @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 .= $sCity.';'.$sPostcode.';'.$sState."\n";
$this->sData .= $sCity.';'.$sState.';'.$sPostcode.';'.$sCountry."\n";
return $this;
}
+12 -3
View File
@@ -40,6 +40,10 @@ 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);
$_SESSION['last_activity'] = time();
audit_log('login_success_remember');
@@ -65,23 +69,25 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
<!DOCTYPE html>
<html lang="en">
<title>Login - Qrcode Generator</title>
<title>Login - 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/DynamicQRCode_Original.png" style="width: 95%; height: 95%">
<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">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>
@@ -130,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 -->
+14
View File
@@ -0,0 +1,14 @@
{
"name": "QRForge",
"short_name": "QRForge",
"description": "Self-hosted static and dynamic qr code generator",
"start_url": "index.php",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563EB",
"icons": [
{ "src": "dist/img/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "dist/img/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* AJAX endpoint for saved color/style presets (Fase 3, priority 2). Presets are
* personal: scoped to the logged-in user's own id, never shared across accounts.
*/
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
header('Content-Type: application/json');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
if ($action === 'list') {
$db = getDbInstance();
$db->where('user_id', $_SESSION['user_id']);
$db->orderBy('name', 'ASC');
$presets = $db->get('qr_presets', null, ['id', 'name', 'foreground', 'background', 'level', 'size']);
echo json_encode(['status' => 200, 'data' => $presets]);
exit;
}
if ($action === 'save') {
csrf_verify_header_or_die();
$name = trim((string) ($_POST['name'] ?? ''));
$foreground = trim((string) ($_POST['foreground'] ?? ''));
$background = trim((string) ($_POST['background'] ?? ''));
$level = $_POST['level'] ?? 'L';
$size = filter_var($_POST['size'] ?? 200, FILTER_VALIDATE_INT);
if ($name === '' || strlen($name) > 50) {
echo json_encode(['status' => 400, 'data' => 'Preset name must be between 1 and 50 characters.']);
exit;
}
if (!preg_match('/^#?[0-9a-fA-F]{6}$/', $foreground) || !preg_match('/^#?[0-9a-fA-F]{6}$/', $background)) {
echo json_encode(['status' => 400, 'data' => 'Foreground/background must be valid hex colors.']);
exit;
}
if (!in_array($level, ['L', 'M', 'Q', 'H'], true)) {
$level = 'L';
}
if ($size === false) {
$size = 200;
}
$db = getDbInstance();
$last_id = $db->insert('qr_presets', [
'user_id' => $_SESSION['user_id'],
'name' => $name,
'foreground' => $foreground,
'background' => $background,
'level' => $level,
'size' => $size,
'created_at' => date('Y-m-d H:i:s'),
]);
if (!$last_id) {
echo json_encode(['status' => 500, 'data' => 'Could not save preset.']);
exit;
}
echo json_encode(['status' => 200, 'data' => ['id' => $last_id, 'name' => $name]]);
exit;
}
if ($action === 'delete') {
csrf_verify_header_or_die();
$id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);
if (!$id) {
echo json_encode(['status' => 400, 'data' => 'Invalid preset id.']);
exit;
}
$db = getDbInstance();
$db->where('id', $id);
$db->where('user_id', $_SESSION['user_id']);
$deleted = $db->delete('qr_presets');
echo json_encode(['status' => $deleted ? 200 : 404, 'data' => $deleted ? 'Deleted' : 'Preset not found']);
exit;
}
echo json_encode(['status' => 400, 'data' => 'Unknown action']);
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* Authenticated view/download endpoint for a generated qr code image.
* Replaces the previous direct static URL under saved_qrcode/, which any visitor
* could reach without logging in. Enforces the same visibility rules as the list
* pages (dynamic_qrcodes.php / static_qrcodes.php).
*/
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
$type = $_GET['type'] ?? '';
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!in_array($type, ['static', 'dynamic'], true) || !$id) {
http_response_code(404);
exit('Not found');
}
if ($_SESSION['type'] === 'user') {
$view_flag = $type === 'dynamic' ? 'can_view_dynamic' : 'can_view_static';
if (empty($_SESSION[$view_flag] ?? null)) {
http_response_code(403);
exit('Forbidden');
}
}
$db = getDbInstance();
$db->where('id', $id);
qr_apply_owner_scope($db);
$row = $db->getOne("{$type}_qrcodes");
if ($row === null) {
http_response_code(404);
exit('Not found');
}
$path = SAVED_QRCODE_DIRECTORY . $row['qrcode'];
if (!is_file($path)) {
http_response_code(404);
exit('Not found');
}
$mime_types = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'eps' => 'application/postscript',
];
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = $mime_types[$extension] ?? 'application/octet-stream';
$is_download = isset($_GET['download']) && $_GET['download'] === '1';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, max-age=0, no-cache');
header('Content-Disposition: ' . ($is_download ? 'attachment' : 'inline') . '; filename="' . basename($path) . '"');
readfile($path);
exit;
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Authenticated download endpoint for a bulk-export zip generated by bulk_action.php.
* The zip's contents were already permission-filtered when it was built, so this only
* requires a valid session plus proof that this specific file was generated for it.
*/
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
$file = basename($_GET['file'] ?? '');
if (!preg_match('/^qrcodes_[0-9a-f]+\.zip$/', $file)) {
http_response_code(404);
exit('Not found');
}
if (empty($_SESSION['generated_zips']) || !in_array($file, $_SESSION['generated_zips'], true)) {
http_response_code(403);
exit('Forbidden');
}
$path = SAVED_QRCODE_DIRECTORY . 'zip/' . $file;
if (!is_file($path)) {
http_response_code(404);
exit('Not found');
}
header('Content-Type: application/zip');
header('Content-Length: ' . filesize($path));
header('Content-Disposition: attachment; filename="' . $file . '"');
readfile($path);
exit;
+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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 B

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