No description
  • JavaScript 93.5%
  • CSS 4.9%
  • HTML 1.2%
  • Dockerfile 0.4%
Find a file
Jesse van Mullem 56b9669b50 fix: verkoopkaart laadt foto via relatieve URL
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.
2026-05-20 20:38:12 +02:00
config v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
public v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
test v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
docker-compose.yaml edited project 2026-05-18 17:09:34 +02:00
Dockerfile edited project 2026-05-18 17:09:34 +02:00
nixpacks.toml edited project 2026-05-18 17:09:34 +02:00
package-lock.json edited project 2026-05-18 17:09:34 +02:00
package.json v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
README.en.md v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
README.md v23: security-hardening, e-mail, voorraadalerts en nieuwe functies 2026-05-20 13:47:32 +02:00
server.js fix: verkoopkaart laadt foto via relatieve URL 2026-05-20 20:38:12 +02:00

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

  • 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 in config/manual-bikes.json.
  • Cache, admin logs, reservations, and inventory history are stored in cache/.
  • In Coolify, /app/config and /app/cache must 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

  1. On startup, the app reads config/settings.json.
  2. If that file does not exist yet, the app creates a default configuration.
  3. The app fetches used bikes from baseUrl + usedPath.
  4. The app fetches new bikes from baseUrl + newPath.
  5. List pages provide item IDs and titles.
  6. Detail pages provide prices, images, and specifications.
  7. Detail pages are fetched with bounded concurrency (max. 6 at a time).
  8. Results are cached in memory and on disk, and refreshed automatically on an interval.
  9. Manually added bikes are merged with the scraped inventory.
  10. The API, frontend, and WordPress embed read from that cache.
  11. Visitors can send an interest request (reservation) from a detail page.
  12. 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 Secure attribute when NODE_ENV=production is 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

  1. Push the repository to GitHub/GitLab.
  2. Create a new project in Coolify.
  3. Choose New Resource and select the Git repository.
  4. Choose Docker Compose as the deployment type.
  5. Use this compose file:
docker-compose.yaml
  1. Set these environment variables in Coolify:
NODE_ENV=production
SESSION_SECRET=put-a-long-random-secret-here
  1. Attach your domain to the voorraadportaal service on container port 3000.
  2. Use this health check path:
/api/health
  1. 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/config stores admin settings, password hash, MFA secret, Fietsenwijk URL, and hidden IDs.
  • /app/cache stores 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/config and /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 in config/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.json and visible at /admin/reservations.
  • Per request you can set the status to handled (or reopen it) and delete the request.
  • If an alertWebhookUrl is 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

  1. Create a new Coolify resource/deployment.
  2. Set NODE_ENV=production and your own SESSION_SECRET.
  3. Make sure /app/config and /app/cache are persistent.
  4. Deploy the app.
  5. Log in at /admin/login.
  6. Go to /admin/security and change the admin password.
  7. Optionally enable MFA and/or the IP allowlist.
  8. Go to /admin/config and fill in baseUrl, usedPath, newPath, apiBaseUrl, and returnUrl.
  9. Go to /admin/cache and click force refresh.
  10. 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 the Secure attribute when NODE_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.ip combined with trust proxy, so it cannot be bypassed through a forged X-Forwarded-For header.
  • 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 return parameter on /fiets/:id is validated before it is used.
  • Admin responses receive X-Frame-Options: DENY and Cache-Control: no-store.
  • All responses receive X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.

Production Recommendations

  • Always set your own long SESSION_SECRET.
  • Set NODE_ENV=production so the session cookie is Secure.
  • 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/config persistent?
  • 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 voorraadportaal service
  • HOST=0.0.0.0 and PORT=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 return parameter now only accepts relative paths or hostnames listed in returnUrl, apiBaseUrl and the new additionalReturnHosts.
  • 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/subscriptions page to manage stock alert subscribers.
  • New dependencies: nodemailer (mail) and qrcode (QR codes). Run npm install after updating.

v22.1

  • Minor change: reservations are now optional and disabled by default. Enable them via /admin/config under Functies (features).

v22

  • Added manual bikes: add bikes outside of Fietsenwijk through /admin/manual, stored in config/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/refresh and /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 description and Open Graph tags on detail pages, plus /sitemap.xml and /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 committing node_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: Secure cookie 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/refresh and /api/health.
  • Fixed WordPress return navigation.
  • Improved price normalization and detail scraping.
  • Added image proxy.