← 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;