- Web server: Apache 2.x with
mod_rewriteenabled - PHP: 8.x with
mysqli,gd(image manipulation), andopensslextensions - Database: MariaDB 10.x or MySQL 8.x — empty database recommended
- Disk: ~100MB free for application + product images
bash install.shThe script:
- Detects PHP, MySQL/MariaDB, and Apache
- Probes for admin DB access — prefers
sudo mysql(unix_socket auth on Debian/Ubuntu MariaDB), falls back to user/password prompt - Creates the database from
schema.sql - Creates a least-privilege application user
webuser@localhostwith onlySELECT/INSERT/UPDATE/DELETEon the inventory database - Generates
.envwith a secureAPP_SECRET(32 random bytes hex) - Adds execute (
o+x) on the home directory if needed so Apache can traverse a symlinked DocumentRoot - Creates
/var/www/html/inventory→ project-root symlink - Adds
Listen 8080to Apacheports.confif missing - Writes Apache vhost config at
/etc/apache2/sites-available/inventory.conflistening on port 8080 - Enables the site (
a2ensite) and reloads Apache afterconfigtest
Access the app at http://<server>:8080 (or http://localhost:8080 from the host itself).
To wipe the database, vhost, symlink, uploaded user content, and .env, then reinstall fresh:
bash install.sh --reinstall # prompts: type 'RESET' to confirm
bash install.sh --reinstall -y # non-interactiveThe reinstall step preserves git-tracked files in uploads/ (default placeholder images) — only user-uploaded content is removed. The current .env is backed up as .env.bak.<timestamp> before deletion.
-
Clone the repo somewhere — typically
/var/www/html/inventory, or symlinked there from your dev tree. -
Create the database:
sudo mysql -e "CREATE DATABASE inventory CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" sudo mysql inventory < schema.sql
-
Create an app user:
sudo mysql -e "CREATE USER 'webuser'@'localhost' IDENTIFIED BY ''; GRANT SELECT, INSERT, UPDATE, DELETE ON inventory.* TO 'webuser'@'localhost'; FLUSH PRIVILEGES;"
-
Write
.env(project root):DB_HOST=localhost DB_USER=webuser DB_PASS= DB_NAME=inventory APP_SECRET=<run: openssl rand -hex 32> SESSION_TIMEOUT_MINUTES=30SESSION_TIMEOUT_MINUTEScontrols the idle-session timeout (default 30; set higher for dev,0to disable). -
Permissions on
uploads/:sudo chmod -R 775 uploads/ sudo chown -R www-data:www-data uploads/
-
Apache vhost (
/etc/apache2/sites-available/inventory.conf):<VirtualHost *:8080> ServerName inventory.local DocumentRoot /var/www/html/inventory <Directory /var/www/html/inventory> AllowOverride All Require all granted DirectoryIndex index.php </Directory> <FilesMatch "\.(env|sql|sh|md|gitignore)$"> Require all denied </FilesMatch> ErrorLog ${APACHE_LOG_DIR}/inventory_error.log </VirtualHost>
Then:
echo "Listen 8080" | sudo tee -a /etc/apache2/ports.conf sudo a2ensite inventory.conf sudo apache2ctl configtest && sudo systemctl reload apache2
-
If the DocumentRoot is a symlink into a home directory, give Apache traversal access:
chmod o+x ~
After pulling new code, apply any pending schema migrations against the live DB. The runner uses .env credentials, executes each .up.sql once, and is idempotent:
php scripts/migrate.php # apply all pending
php scripts/migrate.php --status # show applied / pending
php scripts/migrate.php 022 023 024 # apply a specific list
install.shalready applies the full schema, so migrations only matter when upgrading an existing deployment. The migration runner is shipped via PR #53.
.envmust exist with valid DB credentials (the test harness connects to the live DB using aHARNESS_data prefix)- Composer dev dependencies installed:
composer install - Apache must be running (SecurityHeadersTest makes real HTTP requests to
localhost:8080)
bash tests/run.sh # all suites (PHP + Playwright)Expected output when app is reachable at http://blueberry.local:8080 (test vhost):
OK--- Authentication (integration) ---
OK: All tenancy tests passed.
OK: All org management tests passed.
OK--- Security Headers (http) ---
=========================================
Summary: 7/7 legacy suites passed
=========================================
The legacy harness (run.sh) covers: Auth, CRUD, Settings, SoftDelete, Tenancy, OrgManagement, SecurityHeaders.
PR #52 introduced PHPUnit-style tests for the new security primitives. Run them with:
vendor/bin/phpunitPHPUnit suites cover: CSRF, Session (idle timeout + is_secure_context()), PasswordReset (token lifecycle), Permissions (can() + role defaults), plus infra smoke tests (Health, Backup, LogRotate, InfraSmoke).
Requires a running app and seeded auth state:
npx playwright test # headless
npx playwright test --headed # with browser visible
npx playwright test tests/ui/products # specific specPlaywright tests seed their own
HARNESS_users viaauth.setup.js. If the setup spec fails, the remaining UI tests won't run — check that the app is accessible athttp://localhost:8080.
All passwords are bcrypt-hashed in the database (and auto-upgraded from legacy SHA1 on first login).
| Role | Username | Password | user_level |
|---|---|---|---|
| Administrator | admin |
admin |
1 |
| Supervisor | special |
special |
2 |
| Default User | user |
user |
3 |
Change all three on first login via Settings → Change Password.
- Manage users and groups (Users → Add/Edit)
- Manage products, categories, stock adjustments
- Process sales, generate invoices and picklists
- Run sales and stock reports
- Manage customers (contact, payment info)
- Add/edit products and adjust stock
- Create orders and sales, print invoices and picklists
- View customer details
- Access sales and stock reports
- Browse products by category, search by name/SKU
- Add sales to existing orders
- Look up order status
- Products → Add Product
- Fill in: Name, SKU, Location, Quantity, Buy Price, Sale Price, Category
- Optionally upload an image (PNG/JPG/GIF; handled by the
Mediaclass) - Submit — created via prepared
INSERT
- Sales → Add Order
- Search or select a customer (AJAX autocomplete from
includes/sql.php) - Add line items by SKU or product search
- Each sale decreases product quantity via
decrease_product_qty() - Print invoice or picklist from the order detail page
- Date range: Reports → Sales Report → pick start/end → submit (
find_sale_by_dates()) - Daily: Reports → Daily Sales → pick year/month
- Monthly: Reports → Monthly Sales → pick year
- Users → Groups to define tiers; each group has name, level (1-3), status
- Users get a
user_levelmatching a group - Disabling a user (
status=0) or group (group_status=0) blocks access viapage_require_level()
Users → Permissions opens a matrix of (user × module × action) — view, create, edit, delete. Admins always bypass; for Supervisors and Users the matrix overrides the role defaults (Supervisor = view/create/edit, User = view only). Used by can() / require_permission() in module handlers. Backed by the permissions table (migration 024).
Users → Audit Log lists CRUD + login/logout events from the audit_log table (user, module, action, record id, summary, IP). The recorder (audit() in includes/sql.php) is wrapped in try/catch so a DB outage cannot white-screen a login. Migration 023 created the table.
From the login page, Forgot password? captures a username or email and creates a single-use, 1-hour reset token (bcrypt-hashed in the password_resets table). The reset link is currently surfaced via server logs only — SMTP email delivery is deferred to a follow-up plan. Visiting the link loads users/reset_password.php, which validates the token and atomically updates the password + marks the token consumed. Migration 022 created the table.
To seed the Default Organization with realistic demo data (products, customers, orders, sales, stock):
php scripts/demo_seed.phpThis inserts:
| Section | Count | Notes |
|---|---|---|
| Categories | 6 | Microcontrollers, Single Board Computers, Plants & Living Goods, Cables & Connectors, Sensors & Modules, Tools & Equipment |
| Products | 12 | 3 with real images (Arduino Nano, RPi 3, Pothos); rest use the placeholder |
| Customers | 8 | Ontario community orgs with addresses, emails, and payment methods |
| Orders | 6 | Mixed statuses: fulfilled ×3, shipped ×1, processing ×1, pending ×1 |
| Sales | 14 | Line items across 5 of the 6 orders |
| Stock | 12 | One opening-receipt movement per product |
The script is idempotent — it skips silently if demo data is already present. To remove the demo data and re-seed from scratch:
php scripts/demo_seed.php --cleanThe --clean step deletes only rows matching the known demo SKUs and customer names — it will not touch any data you have entered manually.
Note: The seeder reads
.envthe same way the app does. It requires the database to be set up (runinstall.shor follow the manual install steps first).
| Symptom | Likely cause | Fix |
|---|---|---|
Browser shows 403 Forbidden on localhost:8080 |
Apache can't traverse home dir to symlinked DocumentRoot | chmod o+x ~ (execute-only, no list) |
| "Database connection failed. Please try again later." | .env has wrong creds, or webuser is missing |
Re-check .env; recreate webuser per manual install step 3 |
| "Failed to select database" | Database doesn't exist | sudo mysql inventory < schema.sql |
| White screen / no output | PHP fatal error | sudo tail -50 /var/log/apache2/inventory_error.log |
| Login form accepts credentials but redirects right back to login | Old bug — header role check used string compare on int. Fixed in 2026-05-14 cycle. If reappears, check layouts/header.php:74-82 |
Cast to int: (int)$user['user_level'] === 1 |
| CSRF "Invalid security token" on login | Stale tab / expired session | Refresh login page to fetch a new token |
| Upload failures | uploads/ not writable by www-data |
sudo chown -R www-data:www-data uploads/ |
| Product image shows broken | Missing no_image.jpg placeholder |
git checkout HEAD -- uploads/ to restore tracked seeds |
| AJAX search not working | jQuery not loaded | Open browser console; look for 404s on libs/js/jquery.min.js |
| Apache reload silently skipped during install | Pre-2026-05-14 install.sh checked stdout but apache2ctl configtest writes to stderr |
Update to current install.sh (2>&1 before grep) |