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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
- 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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
- 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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo3DiRRpmz2DXjD7Uzhc8u
- 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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196bLhZhuxwK3MkHuKLe5WB
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).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
### 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._ 😊
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