diff --git a/.gitea/workflows/qa-build.yml b/.gitea/workflows/qa-build.yml
new file mode 100644
index 0000000..db2659b
--- /dev/null
+++ b/.gitea/workflows/qa-build.yml
@@ -0,0 +1,152 @@
+name: Build QA Image
+
+# Manual only. Builds the QA image from a chosen ref, pushes it to the Gitea
+# container registry, and emails when it is ready to redeploy.
+#
+# It deliberately does NOT restart the QA stack. Redeploying stays a human
+# action in Portainer, so nothing changes what is running without someone
+# deciding it should.
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Branch, tag, or commit to build
+ required: true
+ default: main
+
+env:
+ IMAGE: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
+ # The build runs against a Docker-in-Docker service rather than the host
+ # daemon. Mounting the host socket into the runner would give every workflow
+ # on every branch root-equivalent control of the NAS, production included.
+ # Because the image is pushed to a registry, it does not need to survive in
+ # the build daemon.
+ DOCKER_HOST: tcp://docker:2375
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ services:
+ docker:
+ image: docker:27-dind
+ options: --privileged
+ env:
+ DOCKER_TLS_CERTDIR: ""
+
+ steps:
+ - name: Check required configuration
+ run: |
+ missing=""
+ [ -n "${{ secrets.REGISTRY_TOKEN }}" ] || missing="$missing REGISTRY_TOKEN"
+ [ -n "${{ vars.REGISTRY_USER }}" ] || missing="$missing REGISTRY_USER"
+ [ -n "${{ secrets.BREVO_API_KEY }}" ] || missing="$missing BREVO_API_KEY"
+ [ -n "${{ vars.QA_NOTIFY_TO }}" ] || missing="$missing QA_NOTIFY_TO"
+ [ -n "${{ vars.QA_NOTIFY_FROM }}" ] || missing="$missing QA_NOTIFY_FROM"
+ if [ -n "$missing" ]; then
+ echo "::error::Missing configuration:$missing"
+ echo "Secrets go in Settings > Actions > Secrets; variables in Settings > Actions > Variables."
+ exit 1
+ fi
+ echo "All required secrets and variables are present."
+
+ - name: Checkout ${{ inputs.ref }}
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.ref }}
+ fetch-depth: 0
+
+ - name: Ensure a docker CLI is available
+ run: |
+ if command -v docker >/dev/null 2>&1; then
+ echo "docker CLI already present: $(docker --version)"
+ exit 0
+ fi
+ echo "docker CLI missing from the runner image; installing the static binary."
+ curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz
+ tar -xzf /tmp/docker.tgz -C /tmp
+ install -m 0755 /tmp/docker/docker /usr/local/bin/docker
+ docker --version
+
+ - name: Wait for the build daemon
+ run: |
+ # A privileged service container is the one runner capability this
+ # workflow cannot verify in advance. Fail here with an explanation
+ # rather than at `docker build` with a connection refused.
+ for i in $(seq 1 30); do
+ if docker info >/dev/null 2>&1; then
+ echo "Build daemon reachable after ${i}s."
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "::error::No Docker daemon at $DOCKER_HOST after 30s."
+ echo "The dind service needs privileged containers. If the runner"
+ echo "forbids them, this workflow cannot build without host socket access."
+ exit 1
+
+ - name: Record what is being built
+ id: meta
+ run: |
+ echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
+ echo "full_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ {
+ echo "subject<> "$GITHUB_OUTPUT"
+
+ - name: Log in to the Gitea registry
+ run: |
+ echo "${{ secrets.REGISTRY_TOKEN }}" \
+ | docker login gitea.bermudalamb.synology.me \
+ -u "${{ vars.REGISTRY_USER }}" --password-stdin
+
+ - name: Build and push
+ run: |
+ # Tagged twice: :qa is what the stack pulls, and the commit tag makes
+ # it possible to tell what is actually deployed and to roll back to a
+ # specific build rather than "the previous one".
+ docker build --no-cache \
+ -t "$IMAGE:qa" \
+ -t "$IMAGE:${{ steps.meta.outputs.sha }}" \
+ .
+ docker push "$IMAGE:qa"
+ docker push "$IMAGE:${{ steps.meta.outputs.sha }}"
+
+ - name: Email that QA is ready to redeploy
+ run: |
+ cat > /tmp/mail.json <A QA image has been built and pushed.
Ref: ${{ inputs.ref }}
Commit: ${{ steps.meta.outputs.sha }}
Subject: ${{ steps.meta.outputs.subject }}
To deploy it: open the redefined-designs-qa stack in Portainer and redeploy with Pull latest image enabled.
Migrations run automatically as the container starts — check docker logs redefined-designs-qa-syn shows the migration output before listening on 3000, and that it appears only once.
"
+ }
+ JSON
+ code=$(curl -sS -o /tmp/mail-response.json -w '%{http_code}' \
+ -X POST https://api.brevo.com/v3/smtp/email \
+ -H "api-key: ${{ secrets.BREVO_API_KEY }}" \
+ -H "Content-Type: application/json" \
+ --data @/tmp/mail.json)
+ echo "Brevo responded $code"
+ if [ "$code" -ge 300 ]; then
+ cat /tmp/mail-response.json
+ # The image is already pushed and usable at this point, so a failed
+ # notification must not report the build as failed.
+ echo "::warning::Image pushed successfully, but the notification email failed."
+ fi
+
+ - name: Summary
+ run: |
+ {
+ echo "### QA image pushed"
+ echo ""
+ echo "| | |"
+ echo "|---|---|"
+ echo "| Ref | \`${{ inputs.ref }}\` |"
+ echo "| Commit | \`${{ steps.meta.outputs.full_sha }}\` |"
+ echo "| Tags | \`$IMAGE:qa\`, \`$IMAGE:${{ steps.meta.outputs.sha }}\` |"
+ echo ""
+ echo "Redeploy the \`redefined-designs-qa\` stack in Portainer with **Pull latest image** enabled."
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 1c477bc..8d95673 100755
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
+ "express-rate-limit": "^8.6.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"node-pg-migrate": "^7.6.1",
@@ -2865,6 +2866,48 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
+ "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-rate-limit/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/express-rate-limit/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3333,6 +3376,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index a740324..72b4688 100755
--- a/backend/package.json
+++ b/backend/package.json
@@ -19,30 +19,31 @@
"migrate:create": "node-pg-migrate create --migration-file-language js"
},
"dependencies": {
- "express": "^4.19.2",
- "pg": "^8.12.0",
- "multer": "^1.4.5-lts.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
- "nodemailer": "^6.9.14",
+ "express": "^4.19.2",
+ "express-rate-limit": "^8.6.2",
+ "multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
- "node-pg-migrate": "^7.6.1"
+ "node-pg-migrate": "^7.6.1",
+ "nodemailer": "^6.9.14",
+ "pg": "^8.12.0"
},
"devDependencies": {
- "typescript": "^5.5.4",
- "tsx": "^4.16.5",
- "@types/express": "^4.17.21",
- "@types/node": "^20.14.15",
- "@types/multer": "^1.4.11",
- "@types/pg": "^8.11.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
- "@types/nodemailer": "^6.4.15",
- "@types/node-cron": "^3.0.11",
- "jest": "^29.7.0",
- "ts-jest": "^29.2.4",
+ "@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
+ "@types/multer": "^1.4.11",
+ "@types/node": "^20.14.15",
+ "@types/node-cron": "^3.0.11",
+ "@types/nodemailer": "^6.4.15",
+ "@types/pg": "^8.11.6",
+ "@types/supertest": "^6.0.2",
+ "jest": "^29.7.0",
"supertest": "^7.0.0",
- "@types/supertest": "^6.0.2"
+ "ts-jest": "^29.2.4",
+ "tsx": "^4.16.5",
+ "typescript": "^5.5.4"
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts
new file mode 100644
index 0000000..2cfa2ca
--- /dev/null
+++ b/backend/src/rateLimit.ts
@@ -0,0 +1,35 @@
+import rateLimit from 'express-rate-limit';
+import { Request } from 'express';
+
+// First rate limiting in the codebase. The password-reset endpoints need it
+// most: without one, anyone can make the server send unlimited mail to any
+// address. Login and registration are the obvious next candidates.
+//
+// The default in-memory store suits a single-instance deployment, which this
+// is. Running more than one app container would need a shared store, or each
+// instance would enforce its own separate allowance.
+
+const WINDOW_MS = 15 * 60 * 1000;
+const MAX_REQUESTS = 5;
+
+// Keyed on caller *and* address rather than caller alone. Keying on IP only
+// would let one person's reset attempts lock out everyone behind the same
+// NAT or reverse proxy — and everything here arrives via Nginx Proxy Manager,
+// so a great many customers share an apparent address.
+//
+// This key only makes sense on a request that carries an email. Applying the
+// same limiter to an endpoint without one collapses every caller into a single
+// `ip:` bucket, which is a shared allowance rather than a per-caller one.
+function keyByCallerAndEmail(req: Request): string {
+ const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
+ return `${req.ip}:${email}`;
+}
+
+export const passwordResetRequestLimiter = rateLimit({
+ windowMs: WINDOW_MS,
+ limit: MAX_REQUESTS,
+ keyGenerator: keyByCallerAndEmail,
+ standardHeaders: 'draft-7',
+ legacyHeaders: false,
+ message: { error: 'too many attempts, please try again later' }
+});
diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts
index d078a37..5e33b12 100755
--- a/backend/src/routes/customers.ts
+++ b/backend/src/routes/customers.ts
@@ -5,6 +5,8 @@ import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
import { sendMail } from '../mailer';
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
+import { asyncRoute } from '../asyncRoute';
+import { passwordResetRequestLimiter } from '../rateLimit';
const router = Router();
@@ -93,6 +95,98 @@ router.post('/verify-email', async (req: Request, res: Response) => {
res.json({ status: 'verified' });
});
+const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
+
+// Always answers 200, whether or not the address has an account. A response
+// that differed would let anyone test addresses for membership.
+//
+// Note /register still reveals existence via its 409 on a duplicate address,
+// so this protection is currently partial — closing that is its own change.
+router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => {
+ const email = String(req.body?.email || '').toLowerCase().trim();
+ if (!email || !isValidEmail(email)) {
+ return res.status(400).json({ error: 'a valid email is required' });
+ }
+
+ const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
+ const customer = rows[0];
+
+ if (customer) {
+ // Supersede any outstanding token, so a link cannot be resurrected later
+ // from an older message in the customer's inbox.
+ await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
+
+ const token = crypto.randomBytes(32).toString('hex');
+ await pool.query(
+ `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`,
+ [token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)]
+ );
+
+ const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
+ sendMail(
+ customer.email,
+ 'Reset your Redefined Designs password',
+ `
Someone asked to reset the password for this account.