195 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
dillard 3afe3b7698 Security hardening: CSRF, rate limiting, session/password policy, audit log
Fixes critical pre-existing issues found during review: bulk_action.php had no
auth check at all (unauthenticated download/delete of any qrcode) and built a
table name from unwhitelisted user input (SQL injection); the QR generator
classes wrote files from unvalidated filename/format, allowing path traversal
and arbitrary file writes. Also pins chillerlan/php-qrcode to 5.0.5 since
master now requires PHP 8.4, breaking the PHP 8.3 build.

- CSRF tokens on all POST forms and the bulk_action.php JSON endpoint
- Login rate limiting (5 attempts / 15 min) via new login_attempts table
- Hardened sessions: httponly/samesite cookies, 30 min idle timeout,
  session regeneration on login
- Forced password change for the default superadmin/superadmin account
- Server-side validation in Users/DynamicQrcode/Qrcode classes
- Audit log table for auth, user, and qrcode actions
- Checked-in db schema (db/init.sql, migrations/) instead of relying on an
  opaque prebuilt db image
- Production docker-compose with Nginx + php-fpm instead of the PHP dev server
2026-07-08 15:00:33 +02:00
Giandonato Inverso 8db3158239 Remove unnecessary extra information from documentation 2025-09-02 21:56:03 +02:00
Giandonato Inverso b3b799819e Delete support section from index.html 2025-09-02 21:53:41 +02:00
Giandonato Inverso f89b39bde3 Remove donation button from README 2025-09-02 21:52:03 +02:00
Giandonato Inverso acb154502b Merge pull request #144 from CLAlberto/master
fix(read.php): replace deprecated FILTER_SANITIZE_STRING for PHP 8.3 …
2025-05-08 08:56:07 +02:00
CLAlberto f24a7086ab fix(read.php): replace deprecated FILTER_SANITIZE_STRING for PHP 8.3 compatibility
### Problem

The usage of `FILTER_SANITIZE_STRING` in `read.php` causes a deprecation warning in PHP 8.1 and breaks functionality entirely in PHP 8.3, as the constant was removed.

### Solution

This commit replaces:
```php
filter_input(INPUT_GET, 'id', FILTER_SANITIZE_STRING);



with a safer and future-proof alternative:

$id = filter_input(INPUT_GET, 'id', FILTER_UNSAFE_RAW);
$id = trim(strip_tags($id));


> _Thanks for maintaining this project! Happy to contribute._ 😊
2025-05-06 16:16:22 +02:00
Giandonato Inverso 2f0c879158 Merge pull request #143 from angelosleebos/patch-1
Make environment variables compatible for other platforms
2025-04-14 12:19:34 +02:00
Giandonato Inverso 2ce6a03921 Merge pull request #142 from rafinou62/patch-1
Update read.php prevent SQL Injection & XSS attacks
2025-04-14 12:18:32 +02:00
Angelo Sleebos 40ec07fbb1 Make environment variables compatible for other platforms
Make environment variables compatible for other platforms
2025-04-07 01:11:17 +02:00
Raphaël Wanecque 6cfbc4a759 Update read.php prevent SQL Injection & XSS attacks 2025-03-26 17:04:33 +01:00
giandonato.inverso@edempg.it 3ee79bfab7 edit demo url 2025-03-22 15:51:02 +01:00
giandonato.inverso@edempg.it a2fdf79491 config rollback 2025-03-20 23:38:55 +01:00
giandonato.inverso@edempg.it 236d7342d3 Merge remote-tracking branch 'origin/master' 2025-03-19 23:40:50 +01:00
giandonato.inverso@edempg.it 1f3110b26e bug fix Class Qrcode not found 2025-03-19 23:40:36 +01:00
Giandonato Inverso 8b24fd839d Merge pull request #136 from MickGe/patch-1
add cookies secure flags
2025-02-09 16:16:53 +01:00
MickGe 011e91e3d0 add cookies secure flags 2025-02-07 11:35:24 +01:00
giandonato.inverso@edempg.it 737ceca97b updated readme and docs 2025-01-21 22:41:14 +01:00
giandonato.inverso@edempg.it b4b42ddb29 updated readme 2025-01-12 23:50:15 +01:00
giandonato.inverso@edempg.it 3ca4163715 Local setup eliminated and documentation updated 2025-01-12 23:48:49 +01:00
giandonato.inverso@edempg.it c5f1b0d74f Merge remote-tracking branch 'origin/master' 2025-01-12 19:48:31 +01:00
Giandonato Inverso d825f597b8 Merge pull request #130 from Shineson1001/feature/129-QRCodeGeneratorSwitch
🐛 Global switch for the QR code generator (#129)
2025-01-02 12:04:55 +01:00
Shine 850f5e5c52 🐛 Global switch for the QR code generator (#129) 2024-12-31 20:48:56 +01:00
giandonato.inverso@edempg.it c46dae55ce Revert "Revert "Instead using external API (api.qrserver.com) .. use chillerlan/php-qrcode""
This reverts commit 4d33cf5379.
2024-12-23 18:52:11 +01:00
giandonato.inverso@edempg.it 4d33cf5379 Revert "Instead using external API (api.qrserver.com) .. use chillerlan/php-qrcode"
This reverts commit 6d42677732.
2024-12-23 18:49:05 +01:00
Giandonato Inverso a1bd583ad3 Merge pull request #128 from Shineson1001/feature/116-EventIncorrectTimeZone
🐛 Event: Incorrect Time Zone
2024-12-20 20:02:32 +01:00
Shine 597afbb1fe 🐛 Event: Incorrect Time Zone
- Add "Time zone" input field.
- 24-Hour time format.
- Set Min-Year and Max-Year dynamically
2024-12-19 22:34:42 +01:00
Giandonato Inverso 5028541bfd Merge pull request #127 from Shineson1001/feature/88-SelfHostedQRCodeGenerator
Instead using external API (api.qrserver.com) .. use chillerlan/php-qrcode
2024-12-15 16:35:29 +01:00
Shine 6d42677732 Instead using external API (api.qrserver.com) .. use chillerlan/php-qrcode 2024-12-15 14:36:39 +01:00
Giandonato Inverso 1499f06c71 Merge pull request #126 from Shineson1001/feature/2FA-QRCodes
Add 2FA QR Code
2024-12-14 09:17:50 +01:00
Shine 058277c0fc Add 2FA QR Code
Save your 2FA secrets to QR-Code.
2024-12-13 23:42:57 +01:00
Giandonato Inverso a426d45be1 available plugins 2024-10-20 16:32:58 +02:00
Giandonato Inverso 3f7ac0bce1 Update README.md 2024-10-20 11:07:12 +02:00
giandonato.inverso@edempg.it 9548a6d73a bug fix in helpers.php 2024-04-21 12:55:03 +02:00
giandonato.inverso@edempg.it 2715f2106c edit readme 2024-04-21 12:38:35 +02:00
giandonato.inverso@edempg.it e43e8f774e bug fix 2024-04-18 20:54:04 +02:00
giandonato.inverso@edempg.it acebfdd708 fix in bulk action 2024-04-18 20:44:22 +02:00
giandonato.inverso@edempg.it 005c67d721 readme 2024-04-18 20:35:04 +02:00
giandonato.inverso@edempg.it 9f2d14abf8 bug fix in read.php, bump version in footer, NEW: bulk delete 2024-04-18 20:33:20 +02:00
giandonato.inverso@edempg.it eba0ebbba7 bug fix database prefix in Qrcode class 2024-03-11 15:43:16 +01:00
giandonato.inverso@edempg.it 068ddebd35 bug fix bulk download 2024-03-04 14:36:18 +01:00
Giandonato Inverso d0d3daf7db Merge pull request #94 from tranmh/xss_static_qrcode
Fix Security: Stored Cross Site Scripting for static QR code
2024-03-01 09:58:29 +01:00
Minh Cuong Tran 92eb66fb35 Fix Security: Stored Cross Site Scripting for static QR code, see https://github.com/giandonatoinverso/PHP-Dynamic-Qr-code/issues/93 2024-03-01 08:03:31 +01:00
Giandonato Inverso b85f98e16d Merge pull request #92 from tranmh/fix_remove_DATABASE_PREFIX_for_getOne
Inconsistency of using DATABASE_PREFIX with getOne()
2024-02-29 00:25:17 +01:00
Minh Cuong Tran a7953f05bb Inconsistency of using DATABASE_PREFIX with getOne() 2024-02-28 16:56:39 +01:00
Giandonato Inverso 06ddd19378 Merge pull request #91 from tranmh/mixed_content_blocked
fix: mixed content blocked for http and https
2024-02-28 14:16:21 +01:00
Minh Cuong Tran 2b6c802659 fix mixed content blocked for http and https: Mixed Content: The page at 'https://localhost/qrcode/dynamic_qrcodes.php' was loaded over HTTPS, but requested an insecure stylesheet 'http://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css'. This request has been blocked; the content must be served over HTTPS. 2024-02-28 13:55:44 +01:00
Giandonato Inverso 33651fcabb Merge pull request #90 from tranmh/fix_case_sensitive_filename
Fix case sensitive filename
2024-02-27 13:25:17 +01:00
Minh Cuong Tran b496e51ace fix: case sensitive for filename 2024-02-27 13:18:52 +01:00
Minh Cuong Tran 1217e32856 fix: case sensitive for filename 2024-02-27 13:18:01 +01:00
Giandonato Inverso 6646cc9545 increased size of column link - dynamic qrcode
increased size of column link - dynamic qrcode
2024-02-14 23:58:00 +01:00
Giandonato Inverso 915ea383b7 Update README.md
updated php version requirement
2024-01-03 00:43:50 +01:00
Giandonato Inverso 93980efdb7 Update README.md 2023-10-30 17:02:13 +01:00
Giandonato Inverso b52a61ca40 bug fix redirect url with docker installation 2023-10-17 23:24:34 +02:00
Giandonato Inverso c9fa6abf06 bug fix 2023-10-17 00:41:15 +02:00
Giandonato Inverso 15c84c0863 bug fix and documentation 2023-10-16 22:57:16 +02:00
Giandonato Inverso 90a7b2890b updated documentation 2023-10-16 22:24:15 +02:00
Giandonato Inverso 03da2d0432 updated documentation 2023-10-16 20:56:06 +02:00
Giandonato Inverso 68a2b9f7f3 Refactoring docker image building, NEW: added docker compose support
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 19:49:29 +02:00
Giandonato Inverso 5335b6fb7e readme updated
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 13:15:53 +02:00
giandonato.inverso@edempg.it fadde16882 readme updated
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 13:15:07 +02:00
Giandonato Inverso 575a6b3caa refactoring of table database, added script for upgrading to versions >= 2.0, added multi-user support
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 13:10:57 +02:00
Giandonato Inverso cdab7df35d readme updated
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 01:43:56 +02:00
Giandonato Inverso 9a0a2c7928 eliminazione file superflui, spostamento file read.php all'interno del progetto, aggiunta astrazione classe Qrcode, miglioramento download bulk, refactoring generale
Signed-off-by: giandonato.inverso@edempg.it <giandonato.inverso@studenti.unimi.it>
2023-10-16 01:41:27 +02:00
Giandonato Inverso 995272fa8d Bug fix login.php
removed .min extension adminlte js file
2023-09-16 14:51:04 +02:00
Giandonato Inverso ef6410253c paypal donations 2023-09-05 23:31:38 +02:00
Giandonato Inverso f9ad48f65a Update README.md 2023-09-05 23:29:51 +02:00
giandonato.inverso@edempg.it 4b28c09d49 Merge remote-tracking branch 'origin/master' 2023-08-22 17:15:09 +02:00
giandonato.inverso@edempg.it d14ec02e4e doc update
Signed-off-by: giandonato.inverso@edempg.it <Zannabianca20>
2023-08-22 17:14:52 +02:00
Giandonato Inverso c87a99a067 Update Dockerfile 2023-01-25 18:51:10 +01:00
Giandonato Inverso eb3c275035 Merge pull request #52 from AyhamAl-Ali/fix/db_prefix
🚀 Fix DB Prefix in `read.php`
2023-01-25 18:50:15 +01:00
Ayham Al-Ali e448066176 Fix DB Prefix 2023-01-25 20:47:52 +03:00
Giandonato Inverso abd55062f3 Merge pull request #45 from chilluniverse/patch-1
DATABASE_HOST
2022-10-14 10:36:59 +02:00
Pascal e99e97cae2 DATABASE_HOST
In the docker-compose.yml is the Database_Host defined as "mariadb". If Host is not changed in the environment.php to "mariadb" as well the setup will fail
2022-10-14 01:26:06 +02:00
giandonato.inverso@edempg.it 6d1c3ac7c4 new version dockerfile 2022-09-28 16:23:44 +02:00
giandonato.inverso@edempg.it e2d9aad91f new version dockerfile 2022-09-28 16:22:18 +02:00
Giandonato Inverso fa709c95db Merge pull request #37 from 0xRenegade/feature/download-multiple-qr-img-at-once
Error Message for no qrcodes selected.
2022-09-24 22:43:16 +02:00
0xRenegade 8cb8e36114 Error Message for no qrcodes selected. 2022-09-24 15:40:56 -05:00
Giandonato Inverso c3225c6f3f Merge pull request #36 from 0xRenegade/feature/download-multiple-qr-img-at-once
Feature/download multiple qr img at once
2022-09-24 22:26:36 +02:00
0xRenegade 9cf354910b download multiple qrcodes at once feature 2022-09-24 15:14:20 -05:00
0xRenegade a18657994e add base_url() function in helpers, works with https and port number 2022-09-24 14:32:04 -05:00
0xRenegade 4b5c9a800b adds in custom.css for, well, custom styles. 2022-09-24 13:09:17 -05:00
Giandonato Inverso a85cee4fda Merge pull request #35 from 0xRenegade/issue-30/update-docker-yml-for-db-prefix
Issue 30/update docker yml for db prefix
2022-09-24 19:44:31 +02:00
Giandonato Inverso 327cadaddb Merge pull request #33 from 0xRenegade/QOL/ignore-env-and-use-example-instead
QOL/ignore env and use example instead
2022-09-24 19:44:23 +02:00
0xRenegade 4da9be3e3e update docker-compose.yml for database prefix option 2022-09-24 12:19:40 -05:00
0xRenegade b26c905b2e update docs for this change 2022-09-24 11:52:34 -05:00
0xRenegade f458094e0d update main gitignore to add in environment.php 2022-09-24 11:44:02 -05:00
0xRenegade b16f30b5a3 move env to example, so we don't run into merge conflicts constantly 2022-09-24 11:42:17 -05:00
0xRenegade f072f151fd Merge pull request #1 from giandonatoinverso/master
sync master branch with remote
2022-09-24 11:28:55 -05:00
Giandonato Inverso 38ddbe43c7 Merge pull request #31 from 0xRenegade/renegade/general-fixes
general fixes, fixed database prefix during install
2022-09-24 11:53:34 +02:00
0xRenegade 9799509ff6 set static attribute of class rather than non-DRY code 2022-09-24 00:24:03 -05:00
0xRenegade f0d43fd18b remove error_log debugging 2022-09-24 00:05:50 -05:00
0xRenegade 5e531f4e15 updated MysqliDb class functions to handle prefix properly. 2022-09-23 23:44:01 -05:00
0xRenegade a0944afd3c if this feature is accepted in Pull Request, will need to add this back 2022-09-23 23:02:05 -05:00
0xRenegade 9b61284a1e Merge branch 'renegade/general-fixes' of github.com:0xRenegade/PHP-Dynamic-Qr-code into renegade/general-fixes 2022-09-23 23:01:05 -05:00
0xRenegade 70ec7afc90 some queries are manually entered, which aren't picked up by 'prefix' class attribute in database class 2022-09-23 22:59:53 -05:00
0xRenegade 1934c78251 Merge branch 'master' into renegade/general-fixes 2022-09-23 21:05:16 -05:00
0xRenegade 8f053474e7 added in prefix by default, oops. 2022-09-23 21:00:41 -05:00
0xRenegade b80636049f Issue #30: Fixes database_prefix option during install 2022-09-23 20:55:55 -05:00
Giandonato Inverso a8e4cf18ff Update config.php 2022-09-24 03:24:15 +02:00
Giandonato Inverso 418215cd23 Update docker-compose.yml 2022-09-24 03:23:41 +02:00
Giandonato Inverso 8bbb4354c0 Update environment.php 2022-09-24 03:23:20 +02:00
0xRenegade 2057df1924 missing scroll bar on documentation page sidebar 2022-09-23 20:09:22 -05:00
Giandonato Inverso b457823654 Update add_dynamic_form.php
added support for http url
2022-09-10 11:50:19 +02:00
Giandonato Inverso 02754fba37 Update MysqliDb.php
fix deprecated implode()
2022-09-05 20:40:26 +02:00
Giandonato Inverso 25add5b7fd Merge pull request #25 from nirpt/master
docker build now supports app release version code.
2022-08-29 14:09:34 +02:00
nirpt 2aa867aa2a Docker build with app version added. 2022-08-29 12:32:20 +02:00
nirpt 9cbf68a99e Merge remote-tracking branch 'origin/master'
# Conflicts:
#	docker/README.md
2022-08-28 13:22:12 +02:00
Giandonato Inverso dfaee77d86 Update README.md 2022-08-28 12:33:14 +02:00
Giandonato Inverso 59c6dc9233 doc update
Signed-off-by: giandonato.inverso@edempg.it <Zannabianca20>
2022-08-28 12:04:48 +02:00
Giandonato Inverso c3e04a4357 new installation process via script, elimination of data entry form for installation
Signed-off-by: giandonato.inverso@edempg.it <Zannabianca20>
2022-08-28 11:57:31 +02:00
nirpt 97da7f0d4f Minor refactor and cleanup 2022-08-28 11:29:20 +02:00
Giandonato Inverso 4dd7f9ab25 configuration file modification, docker environment variable support 2022-08-28 11:24:32 +02:00
Giandonato Inverso 3000271d95 Merge pull request #23 from nirpt/master
docker support added
2022-08-28 10:47:05 +02:00
nirpt 1194259e6e docker support added 2022-08-28 10:24:07 +02:00
Giandonato Inverso 5f1534f0be Update README.md 2022-06-17 11:49:06 +02:00
Giandonato Inverso c76c1164fc Update README.md 2022-06-17 11:48:44 +02:00
Giandonato Inverso c8b3bba9f0 Merge pull request #16 from neoteknic/patch-1
Fix php 8.1 warning in form field
2022-02-22 16:55:19 +01:00
neoteknic 10091ac8e4 Update filters.php
Fix php 8.1 warning in form field
2022-02-22 16:22:13 +01:00
Giandonato Inverso a6bf0d6ca9 Add files via upload 2020-09-08 18:43:51 +02:00
Giandonato Inverso 44e8465129 Add files via upload 2020-09-08 18:42:29 +02:00
Giandonato Inverso cd00141252 Add files via upload 2020-09-08 18:41:51 +02:00
Giandonato Inverso 0ca8bd7cb2 Add files via upload 2020-09-08 18:30:32 +02:00
Giandonato Inverso 554a27762d Add files via upload 2020-09-08 18:28:26 +02:00
Giandonato Inverso 36c52ba003 Add files via upload 2020-09-08 18:22:57 +02:00
Giandonato Inverso 773d4960fd Add files via upload 2020-09-08 18:20:38 +02:00
Giandonato Inverso 7757bcf76b Add files via upload 2020-09-08 18:17:08 +02:00
Giandonato Inverso 76883de681 Add files via upload 2020-09-08 18:15:54 +02:00
Giandonato Inverso 8d45a02149 Add files via upload 2020-09-08 18:13:36 +02:00
Giandonato Inverso d3e11399f7 Add files via upload 2020-09-08 18:11:37 +02:00
Giandonato Inverso 86f32b6327 Add files via upload 2020-09-08 18:09:19 +02:00
Giandonato Inverso 97df13f3f0 Add files via upload 2020-09-08 18:06:28 +02:00
Giandonato Inverso 4ac9cd811a Add files via upload 2020-09-08 17:59:49 +02:00
Giandonato Inverso 1968201a48 Add files via upload 2020-09-08 17:59:21 +02:00
Giandonato Inverso b39c88fc12 Update README.md 2020-09-08 17:31:01 +02:00
Giandonato Inverso 9118d4f33c Update README.md 2020-09-08 17:29:26 +02:00
Giandonato Inverso f8bc49c164 Update README.md 2020-09-08 17:26:41 +02:00
Giandonato Inverso 9db2388d51 Update README.md 2020-09-08 17:25:38 +02:00
Giandonato Inverso 03c9c2d720 Update README.md 2020-09-08 17:24:07 +02:00
Giandonato Inverso 3bb2545521 Update README.md 2020-09-08 17:23:38 +02:00
Giandonato Inverso 283df4b81c Update README.md 2020-09-08 17:23:17 +02:00
Giandonato Inverso e64ca74003 Update README.md 2020-09-08 17:23:02 +02:00
Giandonato Inverso 0c2988791f Update README.md 2020-09-08 17:22:41 +02:00
Giandonato Inverso e123385dac Update README.md 2020-09-08 17:22:25 +02:00
Giandonato Inverso 1c819e6626 Update README.md 2020-09-08 17:21:33 +02:00
Giandonato Inverso 8af83452ba Update README.md 2020-09-08 17:21:07 +02:00
Giandonato Inverso 527eb7b3ba Update README.md 2020-09-08 17:20:39 +02:00
Giandonato Inverso fe4c63e3a6 Update README.md 2020-09-08 17:20:20 +02:00
Giandonato Inverso 601be05d5c Update README.md 2020-09-08 17:20:05 +02:00
Giandonato Inverso f4f061d857 Update README.md 2020-09-08 17:18:41 +02:00
Giandonato Inverso f1eb8545b4 Update README.md 2020-09-08 17:17:36 +02:00
Giandonato Inverso 7e3cef7544 Update README.md 2020-09-08 17:14:57 +02:00
Giandonato Inverso 5530f62195 Update README.md 2020-09-08 17:13:33 +02:00
Giandonato Inverso df3211d583 Update README.md 2020-09-08 17:13:06 +02:00
Giandonato Inverso 117b2b21a2 Update README.md 2020-09-08 17:12:50 +02:00
Giandonato Inverso 5430504fa0 Update README.md 2020-09-08 17:12:34 +02:00
Giandonato Inverso 65c279b65d Update README.md 2020-09-08 17:12:01 +02:00
Giandonato Inverso ceda04f97e Update README.md 2020-09-08 17:11:45 +02:00
Giandonato Inverso 76cad846f9 Update README.md 2020-09-08 17:11:27 +02:00
Giandonato Inverso f0d187f8b7 Update README.md 2020-09-08 17:10:01 +02:00
Giandonato Inverso fefac0fa9f Update README.md 2020-09-08 17:09:36 +02:00
Giandonato Inverso 6f920aec63 Update README.md 2020-09-08 17:09:15 +02:00
Giandonato Inverso ab7a82edbf Update README.md 2020-09-08 17:08:42 +02:00
Giandonato Inverso 7e867ddf9d Update README.md 2020-09-08 17:07:06 +02:00
Giandonato Inverso 255201169f Update README.md 2020-09-08 17:06:25 +02:00
Giandonato Inverso ca0108c247 Update README.md 2020-09-08 17:05:43 +02:00
Giandonato Inverso 23864047ce Initial commit 2020-09-08 16:56:05 +02:00
48 changed files with 858 additions and 67 deletions
+16
View File
@@ -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/
+11
View File
@@ -75,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
@@ -94,12 +95,22 @@ RUN cp -R ./php-qrcode/src /var/www/html/
WORKDIR /var/www/html
RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chmod 755 *;
# Qr code storage lives outside the document root so files can only be reached through
# the authenticated qrcode_image.php / qrcode_zip_download.php endpoints.
RUN mkdir -p /var/www/qrcode-storage/zip && chmod -R 777 /var/www/qrcode-storage
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
# on a brand new volume, so without this an existing install's schema silently falls
# behind the code on every `git pull` + restart.
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
EXPOSE 80
CMD ["php", "-S", "0.0.0.0:80"]
+10
View File
@@ -51,7 +51,9 @@ RUN cp -R ./php-qrcode/src /var/www/html/
WORKDIR /var/www/html
RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chown -R www-data:www-data /var/www/html \
&& find /var/www/html -type f -exec chmod 644 {} \; \
&& find /var/www/html -type d -exec chmod 755 {} \;
@@ -62,5 +64,13 @@ RUN mkdir -p /var/www/qrcode-storage/zip \
&& chown -R www-data:www-data /var/www/qrcode-storage \
&& chmod -R 775 /var/www/qrcode-storage
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
# on a brand new volume, so without this an existing install's schema silently falls
# behind the code on every `git pull` + restart.
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
EXPOSE 9000
CMD ["php-fpm"]
+11 -6
View File
@@ -8,10 +8,13 @@ of the original [PHP Dynamic Qr code](https://github.com/giandonatoinverso/PHP-D
project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
- **Try it free:** [qr.ensembia.com](https://qr.ensembia.com) - fully functional OSS test
instance. Self-service signup isn't live yet, so log in with the temporary shared demo
account `admin` / `admin` in the meantime.
- **Commercial VIP edition** (self-service create-rights, logo-embedded QR codes):
[www.qrforge.eu](https://www.qrforge.eu).
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
@@ -31,7 +34,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
- 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 per-account create rights and view toggles set by an admin)
optional view toggles set by an admin; per-account create rights are
a VIP-edition feature, not available in this OSS version)
- Dashboard with QR/scan statistics and a 7-day activity chart
- CSRF protection, login rate limiting, session hardening, audit log
- Docker Compose setup, both a dev image and a production Nginx + PHP-FPM image
@@ -39,7 +43,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
# What is included
- PHP 8.4 application source
- Database schema + migrations
- Database schema + migrations (applied automatically on every container start,
so `git pull` + restart is enough to bring an existing install up to date)
- Docker Compose files (dev and production)
- CSS/JS assets
+9 -3
View File
@@ -19,14 +19,20 @@ CREATE TABLE IF NOT EXISTS `users` (
`can_view_static` tinyint(1) NOT NULL DEFAULT 0,
`can_view_dynamic` tinyint(1) NOT NULL DEFAULT 0,
`owner_admin_id` int(25) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`must_set_email` tinyint(1) NOT NULL DEFAULT 0,
`self_registered_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=0 ;
-- Default super admin account. Credentials: superadmin / superadmin
-- must_change_password=1 forces a password change on first login (see Fase 1 hardening).
INSERT INTO `users` (`id`, `username`, `password`, `series_id`, `remember_token`, `expires`, `type`, `must_change_password`, `password_changed_at`) VALUES
(1, 'superadmin', '$2y$10$xpZc5KC.aU2XHkcqhuZGFuAnqmtL4Unt8MysOyylceq.19XIyoZpG', NULL, NULL, NULL, 'super', 1, NULL);
-- must_set_email=1: no email yet, so login falls back to username until it's set (same
-- one-time interstitial pre-migration accounts get - see set_email.php).
INSERT INTO `users` (`id`, `username`, `password`, `series_id`, `remember_token`, `expires`, `type`, `must_change_password`, `password_changed_at`, `must_set_email`) VALUES
(1, 'superadmin', '$2y$10$xpZc5KC.aU2XHkcqhuZGFuAnqmtL4Unt8MysOyylceq.19XIyoZpG', NULL, NULL, NULL, 'super', 1, NULL, 1);
CREATE TABLE IF NOT EXISTS `dynamic_qrcodes` (
`id` int(10) NOT NULL AUTO_INCREMENT,
+51
View File
@@ -0,0 +1,51 @@
-- Self-registration: adds email as the login identifier (mirrors qr-vip's
-- 006_vip_and_create_rights.sql email migration) plus a marker for
-- self-registered accounts.
SET @db := DATABASE();
-- 1. email: becomes the login identifier going forward. Nullable so existing accounts (which
-- have no email) don't violate a NOT NULL constraint; a unique index still allows unlimited
-- NULLs in InnoDB, so pre-existing NULL-email rows never collide with each other.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(255) DEFAULT NULL',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND INDEX_NAME = 'email'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. must_set_email: forces existing (pre-migration) accounts through a one-time "set your
-- email" interstitial on next login, mirroring must_change_password. New accounts created
-- after this migration always have an email from creation, so they never get this flag.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'must_set_email'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `must_set_email` TINYINT(1) NOT NULL DEFAULT 0',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE `users` SET `must_set_email` = 1 WHERE `email` IS NULL;
-- 3. self_registered_at: NULL for accounts created by an admin/super, set for accounts created
-- through register.php. Purely informational/reporting for now.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'self_registered_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `self_registered_at` DATETIME DEFAULT NULL',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+11 -6
View File
@@ -31,11 +31,20 @@ services:
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}"
DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}"
ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}"
MAIL_HOST: "${MAIL_HOST:-}"
MAIL_PORT: "${MAIL_PORT:-587}"
MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-tls}"
MAIL_SMTP_AUTH: "${MAIL_SMTP_AUTH:-true}"
MAIL_USERNAME: "${MAIL_USERNAME:-}"
MAIL_PASSWORD: "${MAIL_PASSWORD:-}"
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}"
MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}"
depends_on:
qrforge-db:
condition: service_healthy
volumes:
- qrforge_qrcode_storage:/var/www/qrcode-storage
- ./data/qrcode-storage:/var/www/qrcode-storage
networks:
- qrforge-network
@@ -43,7 +52,7 @@ services:
image: "mysql:8.0"
restart: "unless-stopped"
volumes:
- qrforge_db_data:/var/lib/mysql
- ./data/mysql:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
@@ -58,10 +67,6 @@ services:
networks:
- qrforge-network
volumes:
qrforge_db_data:
qrforge_qrcode_storage:
networks:
qrforge-network:
driver: bridge
+22 -6
View File
@@ -15,13 +15,33 @@ services:
DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}"
DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}"
ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}"
# Defaults here are dev-convenience only (mailhog, no auth) - a real deployment's
# .env must override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD
# explicitly, same as DATABASE_PASSWORD above already requires.
MAIL_HOST: "${MAIL_HOST:-mailhog}"
MAIL_PORT: "${MAIL_PORT:-1025}"
MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-}"
MAIL_SMTP_AUTH: "${MAIL_SMTP_AUTH:-false}"
MAIL_USERNAME: "${MAIL_USERNAME:-}"
MAIL_PASSWORD: "${MAIL_PASSWORD:-}"
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}"
MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}"
ports:
- "80:80"
depends_on:
qrforge-db:
condition: service_healthy
volumes:
- qrforge_qrcode_storage:/var/www/qrcode-storage
- ./data/qrcode-storage:/var/www/qrcode-storage
networks:
- qrforge-network
mailhog:
image: "mailhog/mailhog:v1.0.1"
restart: "unless-stopped"
ports:
- "8025:8025" # web UI: http://localhost:8025
networks:
- qrforge-network
@@ -29,7 +49,7 @@ services:
image: "mysql:8.0"
restart: "unless-stopped"
volumes:
- qrforge_db_data:/var/lib/mysql
- ./data/mysql:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?zet MYSQL_ROOT_PASSWORD in .env}"
@@ -44,10 +64,6 @@ services:
networks:
- qrforge-network
volumes:
qrforge_db_data:
qrforge_qrcode_storage:
networks:
qrforge-network:
driver: bridge
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
php /var/www/html/scripts/migrate.php
exec docker-php-entrypoint "$@"
+3 -2
View File
@@ -51,8 +51,9 @@ require_once BASE_PATH . '/includes/auth_validate.php';
This is the free, open-source (MIT) edition of QRForge. It runs unmodified
as a live, fully functional try-out at
<a href="https://qr.ensembia.com" target="_blank">qr.ensembia.com</a> -
self-service signup isn't live yet, so log in with the temporary shared
demo account <code>admin</code> / <code>admin</code> in the meantime.
<a href="https://qr.ensembia.com/register.php" target="_blank">register your
own free account</a> there (email + a self-hosted CAPTCHA, no third-party
service).
</p>
<p>
The commercial VIP edition (paid create-rights and logo-embedded QR codes)
+19 -8
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,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
$_SESSION['user_id'] = $row['id'];
$_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
$_SESSION['must_set_email'] = !empty($row['must_set_email']);
$_SESSION['can_view_static'] = !empty($row['can_view_static']);
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row);
@@ -86,8 +97,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
}
else
{
qr_record_login_attempt($username, false);
$_SESSION['login_failure'] = 'Invalid username or password';
qr_record_login_attempt($identifier, false);
$_SESSION['login_failure'] = 'Invalid email or password';
header('Location: login.php');
exit;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// Public endpoint (no auth): renders a self-hosted CAPTCHA image for register.php.
// No third-party service (reCAPTCHA/Turnstile/etc) - a simple math challenge drawn
// with GD onto a noisy background, expected answer kept server-side in the session.
require_once 'includes/bootstrap.php';
$a = random_int(1, 9);
$b = random_int(1, 9);
$_SESSION['captcha_answer'] = (string) ($a + $b);
$text = "{$a} + {$b} =";
$width = 160;
$height = 60;
$image = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($image, 245, 245, 245);
$fg = imagecolorallocate($image, 30, 30, 30);
imagefill($image, 0, 0, $bg);
// Noise: random lines behind the text, purely cosmetic distortion.
for ($i = 0; $i < 8; $i++) {
$lineColor = imagecolorallocate($image, random_int(180, 220), random_int(180, 220), random_int(180, 220));
imageline($image, random_int(0, $width), random_int(0, $height), random_int(0, $width), random_int(0, $height), $lineColor);
}
$fontFile = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
if (is_file($fontFile) && function_exists('imagettftext')) {
$fontSize = 22;
$bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$textWidth = abs($bbox[2] - $bbox[0]);
$textHeight = abs($bbox[1] - $bbox[7]);
$x = (int) (($width - $textWidth) / 2);
$y = (int) (($height + $textHeight) / 2);
imagettftext($image, $fontSize, 0, $x, $y, $fg, $fontFile, $text);
} else {
imagestring($image, 5, 10, 20, $text, $fg);
}
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate');
imagepng($image);
imagedestroy($image);
+11
View File
@@ -16,3 +16,14 @@ define('DATABASE_CHARSET', getenv('DATABASE_CHARSET') ?: 'utf8');
define('TYPE', getenv('TYPE') ?: 'local');
define('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');
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+21 -3
View File
@@ -12,6 +12,20 @@
</div>
</div>
<div 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>
@@ -26,25 +40,29 @@
</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": "" ; ?>/> User (read-only)</label>
<input type="radio" name="type" value="user" required="required" id="type-user" <?php echo ($edit && $user['type'] =='user') ? "checked": "" ; ?> <?php echo $editing_self ? "disabled" : ""; ?>/> User (read-only)</label>
</div>
</div>
<?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">
+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">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<div class="row">
@@ -62,6 +61,7 @@
})();
</script>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=bitcoin" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<div class="row">
@@ -47,6 +46,7 @@
</div>
</div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<small class="form-text text-muted mb-2">
@@ -26,6 +25,7 @@
</div>
</div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
+1 -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">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=location" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-8">
<div class="form-group" style="position: relative;">
@@ -30,6 +29,7 @@
</div>
</div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
+1 -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">
+1 -1
View File
@@ -1,6 +1,5 @@
<form class="form" action="static_qrcode.php?type=wifi" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
<div class="row">
@@ -33,6 +32,7 @@
</div>
</div>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
+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 -->
+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();
+60
View File
@@ -36,3 +36,63 @@
navigator.serviceWorker.register('service-worker.js');
}
</script>
<!--
Idle-timeout warning: the session dies silently after
<?php echo SESSION_IDLE_TIMEOUT; ?> seconds of inactivity (no PHP page
load), which loses whatever form the user is filling in. This warns a
couple of minutes before that happens and offers a "stay logged in"
button that pings the server without navigating away.
-->
<div id="session-timeout-toast" class="toast" role="alert" aria-live="assertive" aria-atomic="true"
style="position:fixed;bottom:20px;right:20px;z-index:2000;min-width:320px;display:none;">
<div class="toast-header bg-warning">
<i class="fa fa-clock mr-2"></i>
<strong class="mr-auto">Session expiring soon</strong>
</div>
<div class="toast-body bg-white">
<span id="session-timeout-message">You'll be logged out in a couple of minutes due to inactivity.</span>
<div class="mt-2">
<button type="button" id="session-timeout-extend" class="btn btn-sm btn-primary">Stay logged in</button>
</div>
</div>
</div>
<script>
(function () {
var SESSION_IDLE_TIMEOUT = <?php echo (int) SESSION_IDLE_TIMEOUT; ?>;
var WARNING_LEAD_TIME = 120; // show the warning this many seconds before expiry
var toast = document.getElementById('session-timeout-toast');
var message = document.getElementById('session-timeout-message');
var extendBtn = document.getElementById('session-timeout-extend');
var warnTimer = null;
function showWarning() {
toast.style.display = 'block';
}
function scheduleWarning() {
clearTimeout(warnTimer);
var delayMs = Math.max(0, (SESSION_IDLE_TIMEOUT - WARNING_LEAD_TIME) * 1000);
warnTimer = setTimeout(showWarning, delayMs);
}
extendBtn.addEventListener('click', function () {
fetch('session_ping.php', { method: 'GET', redirect: 'manual', credentials: 'same-origin' })
.then(function (response) {
// redirect: 'manual' turns a server-side redirect (session already
// dead) into an opaque response instead of silently following it.
if (response.type === 'opaqueredirect' || !response.ok) {
throw new Error('expired');
}
toast.style.display = 'none';
scheduleWarning();
})
.catch(function () {
message.textContent = 'Your session already expired - please copy any unsaved work before reloading.';
extendBtn.style.display = 'none';
});
});
scheduleWarning();
})();
</script>
+36 -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
*/
+56
View File
@@ -0,0 +1,56 @@
<?php
require_once __DIR__.'/../../vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
class Mailer
{
/**
* Sends a temporary/initial password to a newly created or provisioned account.
* Returns true on success, false on failure (never throws - callers decide how to
* surface a mail failure without blocking the account creation itself).
*/
public function sendInitialPassword($toEmail, $tempPassword) {
$subject = 'Your ' . MAIL_FROM_NAME . ' account';
$body = "An account was created for you.\n\n"
. "Email: {$toEmail}\n"
. "Temporary password: {$tempPassword}\n\n"
. "You'll be asked to set a new password the first time you log in.\n\n"
. rtrim(BASE_URL, '/') . "/login.php";
return $this->send($toEmail, $subject, $body);
}
private function send($toEmail, $subject, $body) {
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = MAIL_HOST;
$mail->Port = MAIL_PORT;
$mail->SMTPAuth = MAIL_SMTP_AUTH;
if (MAIL_SMTP_AUTH) {
$mail->Username = MAIL_USERNAME;
$mail->Password = MAIL_PASSWORD;
}
if (MAIL_ENCRYPTION !== '') {
$mail->SMTPSecure = MAIL_ENCRYPTION;
}
$mail->setFrom(MAIL_FROM_ADDRESS, MAIL_FROM_NAME);
$mail->addAddress($toEmail);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->isHTML(false);
$mail->send();
return true;
} catch (PHPMailerException $e) {
error_log('Mailer: failed to send to ' . $toEmail . ': ' . $mail->ErrorInfo);
return false;
}
}
}
+3 -3
View File
@@ -171,7 +171,7 @@ class StaticQrcode {
* create a qr code of type "vcard"
*
*/
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>';
+150 -1
View File
@@ -1,5 +1,6 @@
<?php
require_once 'config/config.php';
require_once BASE_PATH . '/lib/Mailer/Mailer.php';
class Users
{
@@ -31,6 +32,23 @@ class Users
return null;
}
/**
* Server-side validation of the (optional, for admin-created accounts) email address.
* Empty is allowed here - an account without one gets must_set_email=1, same as a
* pre-migration legacy account (see the callers below).
*/
private function validateEmail($email) {
if ($email === '' || $email === null) {
return null;
}
if (!is_string($email) || strlen($email) > 255 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return 'Please enter a valid email address.';
}
return null;
}
/**
*
*/
@@ -113,11 +131,19 @@ class Users
$this->failure($validation_error, 'Location: user.php');
}
$email = trim($input_data['email'] ?? '');
$email_error = $this->validateEmail($email);
if ($email_error !== null) {
$this->failure($email_error, 'Location: user.php');
}
if (!isset($input_data['password']) || strlen($input_data['password']) < 10) {
$this->failure('Password must be at least 10 characters long.', 'Location: user.php');
}
$data_to_db["username"] = $input_data["username"];
$data_to_db["email"] = $email !== '' ? $email : null;
$data_to_db['must_set_email'] = $email === '' ? 1 : 0;
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
$data_to_db["type"] = $requested_type;
$data_to_db['owner_admin_id'] = $owner_admin_id;
@@ -130,6 +156,16 @@ class Users
if ($db->count >= 1)
$this->failure('Username already exists');
if ($email !== '') {
$db = getDbInstance();
$db->where('email', $email);
$db->get('users');
if ($db->count >= 1)
$this->failure('An account with this email already exists', 'Location: user.php');
}
$db = getDbInstance();
$last_id = $db->insert('users', $data_to_db);
if ($last_id) {
@@ -138,6 +174,89 @@ class Users
}
}
/**
* Public self-registration (register.php). No session/permission checks - this is
* the one path where an unauthenticated visitor creates their own account. Always
* creates a free-forever 'admin' (self-scoped, no tenant), matching what a manually
* created OSS admin gets. Returns ['ok' => true] on success or
* ['ok' => false, 'error' => string] - callers are responsible for flash/redirect,
* unlike addUser()/editUser() which redirect themselves (this runs pre-login, on a
* page with its own layout).
*/
public function registerSelfUser($email) {
if (!ALLOW_SELF_REGISTRATION) {
return ['ok' => false, 'error' => 'Self-registration is not enabled.'];
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'Please enter a valid email address.'];
}
$db = getDbInstance();
$db->where('email', $email);
$existing = $db->getOne('users');
if (!empty($existing)) {
return ['ok' => false, 'error' => 'An account with this email already exists.'];
}
$username = $this->deriveUniqueUsername($email);
$tempPassword = bin2hex(random_bytes(8));
$data_to_db = [
'username' => $username,
'password' => password_hash($tempPassword, PASSWORD_DEFAULT),
'type' => 'admin',
'owner_admin_id' => null,
'email' => $email,
'must_change_password' => 1,
'self_registered_at' => date('Y-m-d H:i:s'),
];
$db = getDbInstance();
$last_id = $db->insert('users', $data_to_db);
if (!$last_id) {
return ['ok' => false, 'error' => 'Could not create the account: ' . $db->getLastError()];
}
audit_log('user_self_registered', 'user', $last_id);
$mailer = new Mailer();
$mailer->sendInitialPassword($email, $tempPassword);
return ['ok' => true];
}
/**
* Derives a username candidate from the email's local part (letters/digits/dot/
* underscore/hyphen only, matching validateUsernameAndType()'s rules), appending a
* numeric suffix if it's already taken.
*/
private function deriveUniqueUsername($email) {
$localPart = strtolower(strstr($email, '@', true) ?: $email);
$base = preg_replace('/[^a-z0-9._-]/', '', $localPart);
$base = substr($base, 0, 45) ?: 'user';
if (strlen($base) < 3) {
$base = str_pad($base, 3, '0');
}
$candidate = $base;
$suffix = 1;
$db = getDbInstance();
$db->where('username', $candidate);
while ($db->getOne('users') !== null) {
$candidate = $base . $suffix;
$suffix++;
$db = getDbInstance();
$db->where('username', $candidate);
}
return $candidate;
}
/**
* Edit user.
*
@@ -160,13 +279,26 @@ class Users
'edit' => "true",
));
$requested_type = $_SESSION['type'] === 'admin' ? 'user' : ($input_data['type'] ?? '');
$is_self_edit = (int) $input_data['id'] === (int) $_SESSION['user_id'];
// A user editing their own account keeps their current type, even if a
// different value was submitted - prevents accidentally (or deliberately)
// locking yourself out by downgrading your own access level.
$requested_type = $_SESSION['type'] === 'admin'
? 'user'
: ($is_self_edit ? $target['type'] : ($input_data['type'] ?? ''));
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $requested_type);
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);
}
@@ -180,8 +312,25 @@ class Users
$this->failure('Username already exists', 'Location: user.php?'.$query_string);
}
if ($email !== '') {
$db = getDbInstance();
$db->where('email', $email);
$db->where('id', $input_data["id"], '!=');
$row = $db->getOne('users');
if (!empty($row['email'])) {
$this->failure('An account with this email already exists', 'Location: user.php?'.$query_string);
}
}
$data_to_db["username"] = $input_data["username"];
$data_to_db["type"] = $requested_type;
// Only touch email/must_set_email if an email was actually submitted - an admin
// leaving the field blank on an already-set account shouldn't wipe it back out.
if ($email !== '') {
$data_to_db['email'] = $email;
$data_to_db['must_set_email'] = 0;
}
$data_to_db['can_view_static'] = !empty($input_data['can_view_static']) ? 1 : 0;
$data_to_db['can_view_dynamic'] = !empty($input_data['can_view_dynamic']) ? 1 : 0;
+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;
}
+7 -1
View File
@@ -40,6 +40,7 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
$_SESSION['type'] = $row['type'];
$_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
$_SESSION['must_set_email'] = !empty($row['must_set_email']);
$_SESSION['can_view_static'] = !empty($row['can_view_static']);
$_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']);
$_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row);
@@ -81,10 +82,12 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
<div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
<?php include './includes/flash_messages.php'; ?>
<form method="POST" action="authenticate.php">
<?php echo csrf_field(); ?>
<div class="input-group mb-3">
<input type="text" name="username" class="form-control" placeholder="Username" required="required">
<input type="text" name="email" class="form-control" placeholder="Email" required="required">
<div class="input-group-append">
<div class="input-group-text">
<span class="fa fa-user"></span>
@@ -133,6 +136,9 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
</div>
<?php endif; ?>
<?php if (ALLOW_SELF_REGISTRATION): ?>
<p class="mt-3 text-center"><a href="register.php">Register for free</a></p>
<?php endif; ?>
</div>
<!-- /.login-card-body -->
+86
View File
@@ -0,0 +1,86 @@
<?php
require_once 'includes/bootstrap.php';
require_once 'lib/Users/Users.php';
if (!ALLOW_SELF_REGISTRATION) {
header('Location: login.php');
exit;
}
if (isset($_SESSION['user_logged_in']) && $_SESSION['user_logged_in'] === TRUE) {
header('Location: index.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
$email = trim($_POST['email'] ?? '');
if (!captcha_is_valid($_POST['captcha'] ?? '')) {
$_SESSION['failure'] = 'Incorrect CAPTCHA answer, please try again.';
} else {
$users = new Users();
$result = $users->registerSelfUser($email);
if ($result['ok']) {
$_SESSION['success'] = 'Account created! Check your inbox for a temporary password.';
header('Location: login.php');
exit;
}
$_SESSION['failure'] = $result['error'];
}
}
?>
<!DOCTYPE html>
<html lang="en">
<title>Register - QRForge</title>
<?php include './includes/head.php'; ?>
<body class="login-page" style="min-height: 512.391px;">
<div class="login-box">
<div class="login-logo">
<img src="dist/img/brand/logo.svg" alt="QRForge" style="max-width: 260px;">
</div>
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">Create your free account</p>
<?php include './includes/flash_messages.php'; ?>
<form method="POST" action="register.php">
<?php echo csrf_field(); ?>
<div class="input-group mb-3">
<input type="email" name="email" class="form-control" placeholder="Email address" required="required">
</div>
<div class="mb-3 text-center">
<img src="captcha.php" alt="CAPTCHA" id="captcha-image" style="cursor:pointer;" title="Click to refresh">
</div>
<div class="input-group mb-3">
<input type="text" name="captcha" class="form-control" placeholder="Answer the sum above" required="required" autocomplete="off">
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-block">Create account</button>
</div>
</div>
</form>
<p class="mt-3 text-center"><a href="login.php">Back to login</a></p>
</div>
</div>
</div>
<script src="../../plugins/jquery/jquery.min.js"></script>
<script src="../../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="../../dist/js/adminlte.js"></script>
<script>
document.getElementById('captcha-image').addEventListener('click', function () {
this.src = 'captcha.php?' + Date.now();
});
</script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<?php
// Lightweight migration runner: applies any db/migrations/*.sql file not yet recorded
// in schema_migrations, in filename order. Every migration file is itself idempotent
// (checks information_schema before altering), so re-running an already-applied file
// is a safe no-op - this script leans on that instead of needing transactional rollback.
// Run automatically by the container entrypoint on every start (see docker/entrypoint.sh),
// so a `git pull` + restart is enough to bring an existing install's schema up to date -
// docker-entrypoint-initdb.d only ever runs db/init.sql, and only on a brand new volume.
require_once __DIR__ . '/../config/environment.php';
mysqli_report(MYSQLI_REPORT_OFF);
$mysqli = new mysqli(DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME, DATABASE_PORT);
if ($mysqli->connect_errno) {
fwrite(STDERR, "migrate.php: could not connect to database: {$mysqli->connect_error}\n");
exit(1);
}
$mysqli->set_charset(DATABASE_CHARSET);
$mysqli->query(
'CREATE TABLE IF NOT EXISTS schema_migrations (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$files = glob(__DIR__ . '/../db/migrations/*.sql');
sort($files, SORT_STRING);
$applied = [];
$result = $mysqli->query('SELECT filename FROM schema_migrations');
while ($row = $result->fetch_assoc()) {
$applied[$row['filename']] = true;
}
$ran = 0;
foreach ($files as $file) {
$filename = basename($file);
if (isset($applied[$filename])) {
continue;
}
echo "Applying migration: $filename\n";
if (!$mysqli->multi_query(file_get_contents($file))) {
fwrite(STDERR, "migrate.php: failed to apply $filename: {$mysqli->error}\n");
exit(1);
}
// multi_query queues result sets asynchronously - drain them all before the next
// query, and check for a mid-batch error on each one.
do {
if ($res = $mysqli->store_result()) {
$res->free();
}
if ($mysqli->errno) {
fwrite(STDERR, "migrate.php: error while applying $filename: {$mysqli->error}\n");
exit(1);
}
} while ($mysqli->more_results() && $mysqli->next_result());
$stmt = $mysqli->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, NOW())');
$stmt->bind_param('s', $filename);
$stmt->execute();
$stmt->close();
$ran++;
}
echo $ran === 0 ? "No pending migrations.\n" : "Applied $ran migration(s).\n";
$mysqli->close();
+18
View File
@@ -0,0 +1,18 @@
<?php
// Lightweight keep-alive endpoint: including bootstrap.php refreshes
// $_SESSION['last_activity'], extending the idle timeout without navigating
// away from (and losing) whatever form the user is currently filling in.
// Deliberately doesn't use auth_validate.php's redirect-to-login-on-failure
// behavior: this is called from JS, and a 401 lets the caller show "your
// session already expired" instead of silently following a redirect.
require_once 'includes/bootstrap.php';
header('Content-Type: application/json');
if (empty($_SESSION['user_logged_in'])) {
http_response_code(401);
echo json_encode(['ok' => false]);
exit;
}
echo json_encode(['ok' => true]);
+89
View File
@@ -0,0 +1,89 @@
<?php
require_once 'includes/bootstrap.php';
if (empty($_SESSION['user_logged_in'])) {
header('Location: login.php');
exit;
}
$forced = !empty($_SESSION['must_set_email']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$_SESSION['failure'] = 'Please enter a valid email address.';
} else {
$db = getDbInstance();
$db->where('email', $email);
$db->where('id', $_SESSION['user_id'], '!=');
$existing = $db->getOne('users');
if (!empty($existing['email'])) {
$_SESSION['failure'] = 'An account with this email already exists.';
} else {
$db = getDbInstance();
$db->where('id', $_SESSION['user_id']);
$db->update('users', [
'email' => $email,
'must_set_email' => 0,
]);
$_SESSION['must_set_email'] = false;
audit_log('email_set');
$_SESSION['success'] = 'Email address saved.';
header('Location: index.php');
exit;
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<title>Set email - QRForge</title>
<?php include './includes/head.php'; ?>
<body class="login-page" style="min-height: 512.391px;">
<div class="login-box">
<div class="login-logo">
<img src="dist/img/brand/logo.svg" alt="QRForge" style="max-width: 260px;">
</div>
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">
<?php echo $forced
? 'Please set an email address for your account before continuing. This will become your login.'
: 'Set your email address'; ?>
</p>
<?php include './includes/flash_messages.php'; ?>
<form method="POST" action="set_email.php">
<?php echo csrf_field(); ?>
<div class="input-group mb-3">
<input type="email" name="email" class="form-control" placeholder="Email address" required="required">
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-block">Save email</button>
</div>
</div>
</form>
<?php if (!$forced): ?>
<p class="mt-3 text-center"><a href="index.php">Back to dashboard</a></p>
<?php endif; ?>
</div>
</div>
</div>
<script src="../../plugins/jquery/jquery.min.js"></script>
<script src="../../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="../../dist/js/adminlte.js"></script>
</body>
</html>
+1 -1
View File
@@ -65,7 +65,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"]) && !isset($_P
case 'location': $static_qrcode_instance->locationQrcode($_POST['latitude'], $_POST['longitude']);
break;
case 'vcard': $static_qrcode_instance->vcardQrcode($_POST['full_name'], $_POST['nickname'], $_POST['email'], $_POST['website'], $_POST['phone'], $_POST['home_phone'], $_POST['work_phone'], $_POST['company'], $_POST['role'], $_POST['categories'], $_POST['note'], $_POST['photo'], $_POST['address'], $_POST['city'], $_POST['post_code'], $_POST['state']);
case 'vcard': $static_qrcode_instance->vcardQrcode($_POST['full_name'], $_POST['nickname'], $_POST['email'], $_POST['website'], $_POST['phone'], $_POST['home_phone'], $_POST['work_phone'], $_POST['company'], $_POST['role'], $_POST['categories'], $_POST['note'], $_POST['photo'], $_POST['address'], $_POST['city'], $_POST['post_code'], $_POST['state'], $_POST['country']);
break;
case 'event': $static_qrcode_instance->eventQrcode($_POST['title'], $_POST['start'], $_POST['end'], $_POST['timezone'], $_POST['location'], $_POST['description'], $_POST['url']);
+1 -1
View File
@@ -9,7 +9,7 @@ $users = new Users();
if (!in_array($_SESSION['type'], ['super', 'admin'], true))
$users->failure('Only "super admin" and "admin" accounts can access the user management page', 'Location: index.php');
$select = array('id', 'username', 'type');
$select = array('id', 'username', 'email', 'type');
$search_fields = array('username');
require_once BASE_PATH . '/includes/search_order.php';
$page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? 1;