← back to NationalPaperHangers
routes/claim.js
199 lines
// Claim flow for unclaimed listings.
//
// Per DATA_POLICY.md §6, three claim mechanisms are supported:
// 1. Email verification — token sent to a public info@/hello@/contact@ on
// the studio's website domain. Studio clicks → claim_status='claimed',
// redirect to /signup.
// 2. Domain TXT record — studio adds nph-verify=<token> to their DNS.
// A daily cron (not implemented in this file) checks and flips status.
// 3. Manual — staff approval, also flips status.
//
// In this MVP we wire flow #1 (email) and a manual /admin path. DNS proof is
// stubbed for now.
const express = require('express');
const crypto = require('crypto');
const db = require('../lib/db');
const email = require('../lib/email');
const { escapeHtml, isValidEmail } = require('../lib/utils');
const router = express.Router();
const ALLOWED_LOCAL_PARTS = new Set(['info', 'hello', 'contact', 'admin', 'support', 'office']);
function regenerateSession(req) {
return new Promise((resolve, reject) => {
req.session.regenerate(err => err ? reject(err) : resolve());
});
}
function timingSafeEqualStr(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
try { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } catch { return false; }
}
// GET /installer/:slug/claim — claim landing
router.get('/installer/:slug/claim', async (req, res, next) => {
try {
const installer = await db.one(
`SELECT id, slug, business_name, city, state, country, website, instagram_handle,
claim_status, source_name, source_url
FROM installers WHERE slug = $1`,
[req.params.slug]
);
if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
if (installer.claim_status === 'claimed') {
return res.redirect('/installer/' + installer.slug);
}
res.render('public/claim', {
title: `Claim ${installer.business_name} · National Paper Hangers`,
installer, error: null, sent: false
});
} catch (err) { next(err); }
});
// POST /installer/:slug/claim — start verification by email
router.post('/installer/:slug/claim', async (req, res, next) => {
try {
const installer = await db.one(
`SELECT id, slug, business_name, website, claim_status FROM installers WHERE slug=$1`,
[req.params.slug]
);
if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
if (installer.claim_status === 'claimed') return res.redirect('/installer/' + installer.slug);
const claimerEmail = (req.body.email || '').toLowerCase().trim();
const [localPart, emailDomain] = claimerEmail.split('@');
let websiteDomain = null;
if (installer.website) {
try { websiteDomain = new URL(installer.website).hostname.replace(/^www\./, ''); } catch {}
}
const domainOK = !!(websiteDomain && emailDomain && emailDomain.toLowerCase() === websiteDomain.toLowerCase());
const localPartOK = !!(localPart && ALLOWED_LOCAL_PARTS.has(localPart.toLowerCase()));
if (!domainOK || !localPartOK) {
return res.status(400).render('public/claim', {
title: `Claim ${installer.business_name} · National Paper Hangers`,
installer,
error: websiteDomain
? `Please use a public business email at @${websiteDomain} — accepted: ${[...ALLOWED_LOCAL_PARTS].map(p => p + '@').join(', ')}. If you don't have one, contact info@nationalpaperhangers.com for manual review.`
: `No website is on file for this listing — please email info@nationalpaperhangers.com to claim manually.`,
sent: false
});
}
const token = crypto.randomBytes(32).toString('base64url');
await db.query(
`UPDATE installers SET claim_status='pending_claim', claim_token=$2, claim_token_at=now() WHERE id=$1`,
[installer.id, token]
);
const publicUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
const verifyUrl = `${publicUrl}/installer/${installer.slug}/claim/verify?token=${token}`;
await email.sendEmail({
to: claimerEmail,
subject: `Verify your claim of ${installer.business_name}`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px">Confirm your claim</h1>
<p>You requested to claim the directory listing for <strong>${escapeHtml(installer.business_name)}</strong> on National Paper Hangers.</p>
<p>Click below to confirm and start setting up your studio account:</p>
<p><a href="${verifyUrl}" style="display:inline-block;padding:14px 22px;background:#0e0e0e;color:#fff;text-decoration:none">Confirm claim</a></p>
<p style="font-size:12px;color:#666">If you did not request this, you can ignore this email — the listing will remain unclaimed.</p>
</div>`
});
res.render('public/claim', {
title: `Claim ${installer.business_name} · National Paper Hangers`,
installer, error: null, sent: true, sentTo: claimerEmail
});
} catch (err) { next(err); }
});
// GET /installer/:slug/claim/verify?token=...
//
// Single-use: the moment we accept a token we clear it from the row, so the
// 24h TTL link can't be replayed. The session then carries claimingInstallerId
// through to /claim/complete.
router.get('/installer/:slug/claim/verify', async (req, res, next) => {
try {
const token = (req.query.token || '').trim();
if (!token) return res.status(400).render('public/error', { title: 'Invalid claim link', message: 'Missing token.' });
const installer = await db.one(
`SELECT id, slug, business_name, claim_status, claim_token, claim_token_at FROM installers WHERE slug=$1`,
[req.params.slug]
);
if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
if (installer.claim_status === 'claimed') return res.redirect('/installer/' + installer.slug);
const issuedAt = installer.claim_token_at ? new Date(installer.claim_token_at).getTime() : 0;
const expired = Date.now() - issuedAt > 24 * 3600 * 1000;
const tokenOK = installer.claim_token && timingSafeEqualStr(installer.claim_token, token);
if (!tokenOK || expired) {
return res.status(400).render('public/error', { title: 'Claim link invalid or expired', message: 'Request a new claim link from the listing page.' });
}
// Single-use: clear the token now so the link can't be replayed.
// claim_status stays 'pending_claim' until the user finishes /complete.
await db.query(`UPDATE installers SET claim_token=NULL WHERE id=$1`, [installer.id]);
req.session.claimingInstallerId = installer.id;
res.redirect(`/installer/${installer.slug}/claim/complete`);
} catch (err) { next(err); }
});
// GET /installer/:slug/claim/complete — set password + finish
router.get('/installer/:slug/claim/complete', async (req, res, next) => {
try {
if (!req.session.claimingInstallerId) return res.redirect('/installer/' + req.params.slug + '/claim');
const installer = await db.one(
'SELECT id, slug, business_name, email FROM installers WHERE id=$1 AND slug=$2',
[req.session.claimingInstallerId, req.params.slug]
);
if (!installer) return res.redirect('/');
res.render('public/claim-complete', { title: `Finish claiming ${installer.business_name}`, installer, error: null });
} catch (err) { next(err); }
});
router.post('/installer/:slug/claim/complete', async (req, res, next) => {
try {
if (!req.session.claimingInstallerId) return res.redirect('/installer/' + req.params.slug + '/claim');
const installer = await db.one(
'SELECT id, slug FROM installers WHERE id=$1 AND slug=$2',
[req.session.claimingInstallerId, req.params.slug]
);
if (!installer) return res.redirect('/');
const newEmail = (req.body.email || '').toLowerCase().trim();
const password = req.body.password || '';
if (!isValidEmail(newEmail) || password.length < 8) {
return res.status(400).render('public/claim-complete', { title: 'Finish claim', installer, error: 'Email and 8+ char password required' });
}
const dupe = await db.one('SELECT id FROM installers WHERE email=$1 AND id<>$2', [newEmail, installer.id]);
if (dupe) return res.status(400).render('public/claim-complete', { title: 'Finish claim', installer, error: 'That email already has an account.' });
const { hashPassword } = require('../lib/auth');
const hash = await hashPassword(password);
await db.query(
`UPDATE installers SET email=$2, password_hash=$3, claim_status='claimed', claimed_at=now(), status='pending', claim_token=NULL WHERE id=$1`,
[installer.id, newEmail, hash]
);
// Session-fixation defense: regenerate before assigning the authenticated identity.
await regenerateSession(req);
req.session.installerId = installer.id;
// Surface a one-time prompt on the dashboard nudging the new owner to
// set up Stripe Connect payouts. Survives the regenerate above because
// it's set after the new session is established.
req.session.flash = { connect_prompt: true };
res.redirect('/admin?welcome=1&claimed=1');
} catch (err) { next(err); }
});
module.exports = router;