- JavaScript 93.5%
- CSS 4.9%
- HTML 1.2%
- Dockerfile 0.4%
De afbeelding op /fiets/:id/flyer gebruikte een absolute URL gebaseerd op apiBaseUrl/Host, waardoor de foto kon breken bij afwijkend schema of host (bijv. lokaal testen tegen een productie-apiBaseUrl). De foto en de pagina komen van dezelfde origin, dus een relatieve URL is robuuster. De QR-code blijft een absolute URL omdat die door een ander apparaat wordt gescand. |
||
|---|---|---|
| config | ||
| public | ||
| test | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| nixpacks.toml | ||
| package-lock.json | ||
| package.json | ||
| README.en.md | ||
| README.md | ||
| server.js | ||
Voorraadportaal v23
Nederlandse versie: README.md
White-label inventory portal for a bicycle shop or another organization using a Fietsenwijk source. The app fetches inventory from Fietsenwijk, enriches the data, caches the result, and exposes it through a public frontend, a JSON/XML API, a WordPress embed, and a secured admin environment.
Developer: Jesse van Mullem Company: Je-Ma ICT Beheer
Table Of Contents
- In Short
- How The App Works
- Important Files
- Run Locally
- Environment Variables
- Coolify Deployment
- First Admin Login
- Reset The Admin Account
- The Admin Portal
- Manual Bikes
- Reservations
- Organization Configuration
- Cache And Automatic Refresh
- Public Routes And API
- WordPress Embed
- Connect A New Organization
- Security
- Troubleshooting
- Changelog
In Short
- One deployment is intended for one organization.
- The source is a Fietsenwijk installation with separate paths for used and new bikes.
- Besides Fietsenwijk, you can also add bikes manually through the admin portal.
- The app runs as a Node/Express server on port
3000(Node 20 or higher). - Runtime settings are stored in
config/settings.json, manual bikes inconfig/manual-bikes.json. - Cache, admin logs, reservations, and inventory history are stored in
cache/. - In Coolify,
/app/configand/app/cachemust be persistent. - The admin login is available at
/admin/login, not/admin. - The admin portal manages configuration, manual bikes, items, reservations, cache, audit log, and security.
How The App Works
- On startup, the app reads
config/settings.json. - If that file does not exist yet, the app creates a default configuration.
- The app fetches used bikes from
baseUrl + usedPath. - The app fetches new bikes from
baseUrl + newPath. - List pages provide item IDs and titles.
- Detail pages provide prices, images, and specifications.
- Detail pages are fetched with bounded concurrency (max. 6 at a time).
- Results are cached in memory and on disk, and refreshed automatically on an interval.
- Manually added bikes are merged with the scraped inventory.
- The API, frontend, and WordPress embed read from that cache.
- Visitors can send an interest request (reservation) from a detail page.
- The admin portal lets you manage everything: configuration, manual bikes, cache, reservations, audit log, security, and hidden items.
Important Files
| File | Purpose |
|---|---|
server.js |
Express server: scraping, parsing, API, frontend routes, and admin portal. |
public/ |
Public frontend files. |
test/server.test.js |
Unit tests for the parser and helper functions (npm test). |
config/settings.example.json |
Example configuration. |
config/settings.json |
Runtime configuration, created automatically on first start. |
config/manual-bikes.json |
Manually added bikes, created automatically. |
cache/fietsen.json |
Disk cache for inventory data. |
cache/admin-log.json |
Audit log of admin actions. |
cache/reservations.json |
Interest requests submitted from the detail pages. |
cache/history.json |
Inventory history: bikes added or removed per refresh. |
Dockerfile |
Production container. |
docker-compose.yaml |
Recommended Coolify Compose stack. |
nixpacks.toml |
Fallback for Nixpacks deployments. |
.env.example |
Example environment variables. |
Run Locally
Install dependencies and start the server:
npm install
npm start
Run the tests with:
npm test
The app will run at:
http://localhost:3000
Useful local URLs:
http://localhost:3000/
http://localhost:3000/voorraad
http://localhost:3000/api/health
http://localhost:3000/admin/login
Locally the app runs without HTTPS by default. The session cookie only receives the
Secureattribute whenNODE_ENV=productionis set, so do not set that variable locally.
Environment Variables
The app supports these variables:
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
none | Set to production in production. This makes the session cookie Secure automatically. |
HOST |
0.0.0.0 |
Server bind address. For Docker/Coolify this must be 0.0.0.0. |
PORT |
3000 |
Internal port used by Express. |
SESSION_SECRET |
dev fallback | Secret for admin sessions. You must set this yourself in production. |
CONFIG_DIR |
./config |
Directory containing settings.json. |
CACHE_DIR |
./cache |
Directory for cache and admin logs. |
Always use a long, random SESSION_SECRET in production, for example:
openssl rand -base64 32
If the app runs in production without its own SESSION_SECRET, a warning appears in the logs.
Coolify Deployment
Recommended setup: Docker Compose with docker-compose.yaml.
Step By Step
- Push the repository to GitHub/GitLab.
- Create a new project in Coolify.
- Choose New Resource and select the Git repository.
- Choose Docker Compose as the deployment type.
- Use this compose file:
docker-compose.yaml
- Set these environment variables in Coolify:
NODE_ENV=production
SESSION_SECRET=put-a-long-random-secret-here
- Attach your domain to the
voorraadportaalservice on container port3000. - Use this health check path:
/api/health
- Deploy.
After deployment, test:
https://yourdomain.com/api/health
https://yourdomain.com/admin/login
Persistent Storage
The Compose stack uses volumes for:
/app/config
/app/cache
Why this matters:
/app/configstores admin settings, password hash, MFA secret, Fietsenwijk URL, and hidden IDs./app/cachestores the inventory cache and admin log.- Without persistent storage, this data is lost after a redeploy.
Dockerfile Without Compose
You can also use the Dockerfile build pack.
Coolify settings:
- Port exposes:
3000 - Health check path:
/api/health - Persistent storage:
/app/configand/app/cache - Environment variables:
NODE_ENV=production
HOST=0.0.0.0
PORT=3000
SESSION_SECRET=put-a-long-random-secret-here
Nixpacks
nixpacks.toml starts the app with:
node server.js
And installs dependencies with:
npm install --omit=dev --no-audit --no-fund
Nixpacks deployments also need persistent storage for /app/config and /app/cache.
First Admin Login
After the first start, the app creates a default admin account:
Username: admin
Password: change-me-now
Log in via:
/admin/login
As long as the default password is still active, the login page shows a hint with these credentials and a warning appears in the logs. Once you change the password, those messages disappear automatically.
After the first login, immediately go to:
/admin/security
Change:
- the admin password (minimum 10 characters, with confirmation);
- the MFA settings (disabled by default, see Security);
- the IP allowlist, if needed.
Reset The Admin Account
If you can still log in, change the password via /admin/security.
If you can no longer log in, open the container terminal in Coolify and run:
node -e "const fs=require('fs');const crypto=require('crypto');const file='/app/config/settings.json';const c=JSON.parse(fs.readFileSync(file,'utf8'));const password='NewStrongPassword123!';const salt=crypto.randomBytes(16).toString('hex');c.admin=c.admin||{};c.admin.username='admin';c.admin.passwordSalt=salt;c.admin.passwordHash=crypto.pbkdf2Sync(password,salt,100000,64,'sha512').toString('hex');c.admin.mfaEnabled=false;c.security=c.security||{};c.security.allowlistEnabled=false;fs.writeFileSync(file,JSON.stringify(c,null,2));console.log('Admin reset: admin / '+password);"
This command also disables MFA and the IP allowlist so you are guaranteed to get back in. Then restart the container. The login will be:
Username: admin
Password: NewStrongPassword123!
Change the password again in /admin/security afterward.
The Admin Portal
The admin portal has a fixed sidebar, a dashboard with statistic tiles, and instant confirmation messages after every action.
Admin Routes
| Route | Purpose |
|---|---|
/admin/login |
Log in. |
/admin/mfa |
MFA verification when MFA is enabled. |
/admin/dashboard |
Status, inventory statistics, inventory changes, and audit log. |
/admin/config |
Edit organization configuration. |
/admin/manual |
Add and remove manual bikes. |
/admin/items |
View the full inventory and check hidden IDs. |
/admin/reservations |
View, handle, or delete interest requests (optional, disabled by default). |
/admin/cache |
View, refresh, or clear cache. |
/admin/log |
Full audit log with pagination, including clearing. |
/admin/security |
Manage password, MFA, and IP allowlist. |
/admin/logout |
Log out. |
Note: /admin itself is not a route. Use /admin/login.
Pages
- Dashboard – tiles with counts (total, used, new, manual, new reservations, cache age), a status overview with badges for IP allowlist and MFA, quick actions, recent inventory changes, and the last ten audit log entries.
- Configuration – a Huisstijl (branding) card (site name, logo, accent color) and a Bron en cache (source and cache) card (source URLs, cache duration, alert webhook, and hidden item IDs).
- Manual bikes – add and remove bikes outside of Fietsenwijk.
- Items – a table of the first 80 items (Fietsenwijk and manual) and the list of hidden IDs.
- Reservations – interest requests from the detail pages, with handle/reopen status and delete. Optional: only shown in the menu when the feature is enabled.
- Cache – overview of the cache file and the last refresh, plus buttons to refresh or clear.
- Audit log – the full audit log with pagination and a button to clear it.
- Security – change the password, enable/disable MFA, and manage the IP allowlist.
Manual Bikes
Not every bike is in Fietsenwijk. Through /admin/manual you can add bikes manually.
- A manual bike requires at least a title; type, price, image URL, external detail URL, and specifications are optional.
- The image URL and detail URL must start with
http(s). - Manual bikes get their own ID with an
m-prefix and are stored inconfig/manual-bikes.json. - They appear immediately in the inventory, the API, the XML feed, and the WordPress embed, regardless of cache status.
- Images are proxied and cached through
/image/:id; without an image URL the app shows a neat placeholder. - Each bike can be removed individually from the list on the same page.
Reservations
Reservations are optional and disabled by default. Enable them via /admin/config under Functies (features). While disabled, there is no interest form, the related routes return 404, and the admin menu hides the Reservations page.
When the feature is enabled, every detail page (/fiets/:id) has an interest form.
- A visitor leaves their name, email, phone, and a message.
- A name and a valid email address are required; there is a honeypot field and rate limiting against spam.
- Requests are stored in
cache/reservations.jsonand visible at/admin/reservations. - Per request you can set the status to handled (or reopen it) and delete the request.
- If an
alertWebhookUrlis configured, you receive a notification for every new request.
Organization Configuration
The most important fields in config/settings.json:
| Field | Description |
|---|---|
siteName |
Name shown in the app/admin. |
logoUrl |
Optional logo URL for the login page and admin sidebar. Empty = default icon. |
accentColor |
Accent color (hex) for the portal's visual style. Empty = default dark blue. |
baseUrl |
Base URL of the Fietsenwijk installation. |
usedPath |
Path for used bikes, default /fietsen/?cat=1. |
newPath |
Path for new bikes, default /fietsen/?cat=2. |
apiBaseUrl |
Public API URL, used for embeds, sitemap, and og tags. |
returnUrl |
URL detail pages link back to. |
alertWebhookUrl |
Optional webhook (Slack/Discord/custom endpoint) for alerts on a failed refresh, empty inventory, or new reservations. |
reservationsEnabled |
Turns the reservation/interest form on or off. Defaults to false. |
cacheTtlMs |
Cache duration in milliseconds; also sets the interval of the automatic refresh. |
hiddenIds |
Item IDs that should be hidden (also works for manual bikes). |
admin |
Admin username, password hash, salt, MFA secret, and MFA status. |
security |
IP allowlist settings. |
Example:
{
"siteName": "Voorraadportaal",
"logoUrl": "",
"accentColor": "",
"baseUrl": "https://example.hst.fietsenwijk.nl",
"usedPath": "/fietsen/?cat=1",
"newPath": "/fietsen/?cat=2",
"apiBaseUrl": "https://api.example.com",
"returnUrl": "https://example.com/inventory/",
"alertWebhookUrl": "",
"reservationsEnabled": false,
"cacheTtlMs": 1800000,
"hiddenIds": []
}
Usually you edit this through /admin/config.
White-Label And Branding
The portal can be white-labeled per customer, entirely through /admin/config in the Huisstijl (branding) card:
- Site name – shown on the login page, in the admin sidebar, and in page titles.
- Accent color – a hex value (for example
#0a7d2c) that sets the color of the sidebar, buttons, focus rings, and the login background. Invalid values fall back to the default dark blue. - Logo URL – an optional
http(s)URL to a logo that replaces the default shield icon on the login page and in the sidebar.
A fresh deployment starts with a neutral configuration: baseUrl, apiBaseUrl, and returnUrl are empty. You fill everything in per customer through the admin portal, so the same codebase works for multiple organizations without code changes.
Cache And Automatic Refresh
The app uses three cache layers:
| Cache | Location | Behavior |
|---|---|---|
| Memory cache | In the Node process | Fast, disappears on restart. |
| Disk cache | cache/fietsen.json |
Persists if /app/cache is persistent. |
| Image cache | In the Node process | Proxied images, disappears on restart or cache clear. |
Default cache duration: 30 minutes (cacheTtlMs).
The app refreshes the inventory automatically on an interval equal to cacheTtlMs. In addition, an expired cache is served immediately on a request while a fresh refresh starts in the background. The public frontend therefore no longer forces a refresh on every visit.
Manually refresh cache:
/api/fietsen?refresh=1
/api/refresh
These two routes are rate limited (max. 6 times per 5 minutes) to prevent abuse and unnecessary load on Fietsenwijk.
Manage cache through admin:
/admin/cache
Public Routes And API
Frontend:
/
/voorraad
/fiets/:id
Submit an interest request:
POST /fiets/:id/reserveren
API:
/api/fietsen
/api/fietsen?refresh=1
/api/fietsen.xml
/api/fietsen.xml?refresh=1
/api/refresh
/api/health
SEO:
/sitemap.xml
/robots.txt
Media and embed:
/image/:id
/embed.js
/api/fietsen supports filtering, sorting, and pagination through query parameters:
| Parameter | Description |
|---|---|
state |
used or new. |
q |
Search term across title and specifications. |
minPrice / maxPrice |
Price bounds (only items with a recognized price). |
sort |
price-asc, price-desc, or title. |
page / pageSize |
Pagination; only active when pageSize is supplied. |
Without parameters the API returns the full inventory.
/api/fietsen returns JSON:
{
"success": true,
"count": 0,
"total": 0,
"filtered": 0,
"pagination": null,
"fietsen": [],
"laatsteUpdate": "2026-05-13T00:00:00.000Z",
"source": {
"used": "https://example.hst.fietsenwijk.nl/fietsen/?cat=1",
"new": "https://example.hst.fietsenwijk.nl/fietsen/?cat=2"
}
}
A bike item includes, among other fields, id, title, state, stateLabel, price, priceValue, image, rawImage, url, detailUrl, manual, and specification fields such as kleur, maat, wielmaat, and modeljaar.
/api/health returns status information about cache, item count, manual bike count, source configuration, and the last refresh error if one exists.
WordPress Embed
Simple embed:
<div id="bvb-voorraad"></div>
<script src="https://api.yourdomain.com/embed.js"></script>
Embed with type filter:
<div class="busvolbikes-voorraad" data-type="used"></div>
<script src="https://api.yourdomain.com/embed.js"></script>
Possible data-type values: all, used, new.
The embed fetches data from /api/fietsen on the same server that serves embed.js; no separate plugin or configuration is needed.
Connect A New Organization
- Create a new Coolify resource/deployment.
- Set
NODE_ENV=productionand your ownSESSION_SECRET. - Make sure
/app/configand/app/cacheare persistent. - Deploy the app.
- Log in at
/admin/login. - Go to
/admin/securityand change the admin password. - Optionally enable MFA and/or the IP allowlist.
- Go to
/admin/configand fill inbaseUrl,usedPath,newPath,apiBaseUrl, andreturnUrl. - Go to
/admin/cacheand click force refresh. - Test
/api/health,/api/fietsen?refresh=1, and/voorraad.
Security
The admin portal is protected on multiple levels.
Authentication
- Passwords are stored as a PBKDF2 hash (SHA-512, 100,000 iterations) with a unique salt per password.
- Password verification runs in constant time, so no information leaks through response timing.
- A new password must be at least 10 characters long and must be entered twice for confirmation when changing it.
- The login and MFA routes have rate limiting (a maximum of 10 attempts per 15 minutes).
- Failed and successful logins are recorded in the audit log (
cache/admin-log.json).
Sessions
- Sessions are signed with
SESSION_SECRET. - The session cookie is
HttpOnly,SameSite=Lax, and automatically receives theSecureattribute whenNODE_ENV=production. - On a successful login the session is regenerated (protection against session fixation).
- Sessions expire after 8 hours and are extended on activity.
Two-Factor Authentication (MFA)
- MFA uses TOTP and works with standard authenticator apps.
- MFA is disabled by default. You enable it via
/admin/security. - Add the displayed secret key to your authenticator app first, and only then enable MFA, otherwise you can lock yourself out.
IP Allowlist
- The allowlist can restrict all admin routes to known IP addresses.
- The client address is determined via
req.ipcombined withtrust proxy, so it cannot be bypassed through a forgedX-Forwarded-Forheader. - The allowlist can only be enabled when your current IP address is in the list; this prevents you from locking yourself out.
Headers And Hardening
- All dynamic values on admin and detail pages are HTML-escaped (protection against XSS).
- The
returnparameter on/fiets/:idis validated before it is used. - Admin responses receive
X-Frame-Options: DENYandCache-Control: no-store. - All responses receive
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer.
Production Recommendations
- Always set your own long
SESSION_SECRET. - Set
NODE_ENV=productionso the session cookie isSecure. - Change the default admin password immediately after the first login.
- Consider enabling MFA and the IP allowlist.
- Run the app behind HTTPS (Coolify/Traefik handles this).
Troubleshooting
/admin Does Not Work
Use /admin/login. /admin itself is not a route.
Login Does Not Work
Check:
- Are you using
/admin/login? - Is
/app/configpersistent? - Has the password already been changed?
- Is the IP allowlist enabled and is your IP in it?
- Is MFA enabled while you do not have the code?
- If needed, reset the admin account through the container terminal.
Deploy Is Green But The Site Does Not Open
Check in Coolify:
- container port is
3000 - health check path is
/api/health - domain is attached to the
voorraadportaalservice HOST=0.0.0.0andPORT=3000
Staying Logged In Does Not Work
Check that NODE_ENV=production is set and that the app runs behind HTTPS. A Secure cookie does not work over an unencrypted connection.
Data Disappears After Redeploy
Then /app/config and/or /app/cache are not attached as persistent storage.
/api/fietsen Is Empty
Check baseUrl, usedPath, newPath, whether the Fietsenwijk page is reachable from the server, and /api/health for lastRefreshError.
Images Do Not Load
The app proxies images through /image/:id. Check whether the Fietsenwijk detail page and image wrapper are reachable from the server.
Changelog
v23
- Security:
- The default password is no longer displayed on the login page; after the first login the app forces a password change before any other admin function becomes accessible.
- In production the application refuses to start without its own
SESSION_SECRET. - CSRF tokens added to every admin POST route.
- Server-side validation on manual-bike image URLs blocks requests targeting private or loopback addresses.
- The
returnparameter now only accepts relative paths or hostnames listed inreturnUrl,apiBaseUrland the newadditionalReturnHosts.
- New optional features (all disabled by default, toggle via
/admin/config→ Functies):- Per-bike QR code on the detail page.
- "Nieuw binnen" (new) badge with a configurable day window.
- Price-drop indicator shown as "was €X" (configurable window).
- Printable A5 sale card at
/fiets/:id/flyer, including QR. - Share button (WhatsApp / e-mail /
navigator.share). - Stock alert "houd me op de hoogte" (
/abonneer, double opt-in, automatic e-mail for new matching bikes).
- E-mail support via SMTP — configurable through
/admin/config(host, port, user, password, sender, secure flag). Works with Brevo and any standard SMTP provider. Includes a test-mail button and an optional admin notification address for reservations. - Statistics in the admin dashboard — per-bike view counters, total view count, and top-10 most viewed bikes.
- Extended inventory history — price changes are now recorded alongside added/removed entries and shown in the admin.
- New
/admin/subscriptionspage to manage stock alert subscribers. - New dependencies:
nodemailer(mail) andqrcode(QR codes). Runnpm installafter updating.
v22.1
- Minor change: reservations are now optional and disabled by default. Enable them via
/admin/configunder Functies (features).
v22
- Added manual bikes: add bikes outside of Fietsenwijk through
/admin/manual, stored inconfig/manual-bikes.json. - Added reservations (optional, disabled by default): an interest form on every detail page, managed through
/admin/reservations, enabled via/admin/config. - Automatic background refresh: the inventory now actually refreshes periodically based on
cacheTtlMs; an expired cache is served immediately while a refresh runs in the background. - Rate limiting on
/api/refreshand/api/fietsen?refresh=1(max. 6 per 5 minutes). - Images are cached in memory so they are not re-fetched from Fietsenwijk on every hit.
- Detail pages are scraped with bounded concurrency (max. 6 at a time) instead of all at once.
- API extended with filtering (
state,q,minPrice,maxPrice), sorting (sort), and pagination (page,pageSize). - Added SEO:
meta descriptionand Open Graph tags on detail pages, plus/sitemap.xmland/robots.txt. - Inventory page extended with a search field and sort options.
- Admin portal extended with pages for manual bikes, reservations, and a full paginated audit log.
- Optional alert webhook for failed refreshes, empty inventory, or new reservations.
- Added unit tests for the parser and helper functions (
npm test). - Detail pages fall back to a live scrape when an item is not in the cache.
v21
- Admin portal fully redesigned: fixed sidebar, dashboard with statistic tiles, and confirmation messages after actions.
- White-label options added: configurable site name, logo, and accent color through
/admin/config. - Default configuration made neutral so a fresh deployment starts blank.
- Repository cleaned up: removed unused legacy files, added
.gitignore, and stopped committingnode_modules. - MFA handling fixed: the setting now actually works and persists after a restart. MFA is disabled by default.
- HTML escaping added on all admin and detail pages (XSS protection).
- The IP allowlist now uses the reliable client address and can no longer be bypassed through a forged header.
- Session security improved:
Securecookie in production, session regeneration on login, rolling sessions. - Constant-time password verification; minimum password length raised to 10 characters with a confirmation field.
- Security headers added; failed logins are logged.
v20
- Added admin backend.
- Added login, MFA, and IP allowlist.
- Added configuration management.
- Added cache management.
- Added hidden IDs management.
- Added Coolify/Docker/Nixpacks deployment.
v19 And Older
- Added disk cache.
- Added
/api/refreshand/api/health. - Fixed WordPress return navigation.
- Improved price normalization and detail scraping.
- Added image proxy.