← back to AsSeenInMovies
asseeninmovies: SPOTTED submission form — public /spotted/submit + POST /api/spotted/submit with rate-limit (1/30s), autocomplete movie picker, validation, queues unverified rows for admin review
1cdcaf53459a7606502dbddd4f67a36f8f9e62e6 · 2026-05-12 16:08:26 -0700 · SteveStudio2
Files touched
Diff
commit 1cdcaf53459a7606502dbddd4f67a36f8f9e62e6
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 16:08:26 2026 -0700
asseeninmovies: SPOTTED submission form — public /spotted/submit + POST /api/spotted/submit with rate-limit (1/30s), autocomplete movie picker, validation, queues unverified rows for admin review
---
server.js | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 237 insertions(+)
diff --git a/server.js b/server.js
index 3742c10..2227cb8 100644
--- a/server.js
+++ b/server.js
@@ -211,6 +211,242 @@ app.get('/api/ingest/status', async (_req, res) => {
}
});
+// SPOTTED — public submission form. Users (fans / set decorators / brand
+// reps) can flag a product they saw on screen. Inserts with verified_at=NULL
+// so admin review is required before it surfaces in /api/spotted. Rate-limit:
+// 1 submission per IP per 30 seconds (anti-spam, generous for honest users).
+const spottedRateMap = new Map(); // ip → last-submit-timestamp
+function spottedRateLimit(ip) {
+ const last = spottedRateMap.get(ip) || 0;
+ const now = Date.now();
+ if (now - last < 30000) return false;
+ spottedRateMap.set(ip, now);
+ // Garbage-collect map every ~5min.
+ if (spottedRateMap.size > 2000) {
+ const cutoff = now - 60000;
+ for (const [k, v] of spottedRateMap) if (v < cutoff) spottedRateMap.delete(k);
+ }
+ return true;
+}
+
+app.get('/spotted/submit', async (req, res) => {
+ const movieId = parseInt(req.query.movie_id || '0', 10);
+ let movieTitle = '', movieYear = '';
+ if (Number.isFinite(movieId) && movieId > 0) {
+ try {
+ const r = await pool.query(`SELECT title, release_year FROM asim_movies WHERE id=$1 LIMIT 1`, [movieId]);
+ if (r.rows[0]) { movieTitle = r.rows[0].title; movieYear = r.rows[0].release_year || ''; }
+ } catch (_e) { /* fall through */ }
+ }
+ const body = `
+ <style>
+ .form-stack{max-width:640px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
+ .form-stack label{display:flex;flex-direction:column;gap:5px;font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:var(--ink-faint)}
+ .form-stack input,.form-stack textarea,.form-stack select{background:transparent;border:1px solid var(--line);color:var(--ink);padding:10px 12px;border-radius:6px;font:inherit;font-size:14.5px;text-transform:none;letter-spacing:normal}
+ .form-stack textarea{min-height:84px;resize:vertical}
+ .form-stack .row{display:grid;grid-template-columns:2fr 1fr;gap:14px}
+ @media (max-width:560px){.form-stack .row{grid-template-columns:1fr}}
+ .submit-btn{background:var(--gold);color:#1a1410;border:0;padding:14px 28px;border-radius:6px;font:inherit;font-size:14px;letter-spacing:0.12em;text-transform:uppercase;cursor:pointer;font-weight:600;margin-top:8px}
+ .submit-btn:hover{filter:brightness(1.1)}
+ .submit-btn:disabled{opacity:0.4;cursor:wait}
+ .form-help{color:var(--ink-faint);font-size:12px;margin-top:-2px}
+ .form-result{margin-top:18px;padding:14px 18px;border-radius:6px;border:1px solid var(--gold);background:rgba(201,161,75,0.07)}
+ .form-result.bad{border-color:#a33;background:rgba(170,50,50,0.07);color:#fbb}
+ .ms-search{position:relative}
+ .ms-results{position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--card);border:1px solid var(--line);border-radius:6px;max-height:240px;overflow:auto;z-index:5;display:none}
+ .ms-results.open{display:block}
+ .ms-results a{display:block;padding:8px 12px;color:var(--ink);font-size:13px;border-bottom:1px solid var(--line);text-decoration:none}
+ .ms-results a:hover{background:rgba(201,161,75,0.08);color:var(--gold)}
+ </style>
+ <h1 class="title">Submit a SPOTTED placement</h1>
+ <p class="meta-line">Saw a product on screen you can identify? Submit it for the community.</p>
+
+ <form id="spotted-form" class="form-stack" autocomplete="off" novalidate>
+ <label>
+ Movie or TV episode
+ <div class="ms-search">
+ <input id="movie-search" name="movie_search" type="text" placeholder="Type to search…" value="${esc(movieTitle ? movieTitle + (movieYear ? ' (' + movieYear + ')' : '') : '')}" required>
+ <input id="movie-id" name="movie_id" type="hidden" value="${esc(movieId || '')}">
+ <div id="movie-results" class="ms-results"></div>
+ </div>
+ <span class="form-help">Required. Start typing and pick from the dropdown.</span>
+ </label>
+
+ <div class="row">
+ <label>
+ Product name
+ <input name="product_name" type="text" placeholder="e.g. Brass arc floor lamp" required maxlength="160">
+ </label>
+ <label>
+ Category
+ <select name="product_category">
+ <option value="">— pick one —</option>
+ <option>Wallcovering</option>
+ <option>Lamp / Lighting</option>
+ <option>Furniture</option>
+ <option>Rug / Floor</option>
+ <option>Window treatment</option>
+ <option>Art / Wall décor</option>
+ <option>Accessories</option>
+ <option>Wardrobe</option>
+ <option>Tabletop / Kitchen</option>
+ <option>Other</option>
+ </select>
+ </label>
+ </div>
+
+ <div class="row">
+ <label>
+ Brand
+ <input name="brand" type="text" placeholder="e.g. Schoolhouse" maxlength="120">
+ </label>
+ <label>
+ Scene timecode
+ <input name="scene_timecode" type="text" placeholder="e.g. 0:42:13" maxlength="14">
+ </label>
+ </div>
+
+ <label>
+ Scene description
+ <textarea name="scene_description" placeholder="Where in the film does this appear? e.g. \\"Living room scene when Eve confronts Marlene, ~42 min in.\\"" maxlength="500"></textarea>
+ </label>
+
+ <label>
+ Where to buy (URL)
+ <input name="shop_url" type="url" placeholder="https://…" maxlength="500">
+ </label>
+
+ <div class="row">
+ <label>
+ Your email (optional)
+ <input name="submitted_by_email" type="email" placeholder="you@example.com" maxlength="160">
+ <span class="form-help">Only used to credit you if verified. Never shown publicly.</span>
+ </label>
+ <label>
+ Your role
+ <select name="submitted_by_role">
+ <option value="fan">Fan</option>
+ <option value="set-decorator">Set decorator</option>
+ <option value="brand-rep">Brand representative</option>
+ <option value="other">Other</option>
+ </select>
+ </label>
+ </div>
+
+ <button type="submit" class="submit-btn">Submit for review →</button>
+ <div id="form-result" class="form-result" style="display:none"></div>
+ </form>
+
+ <script>
+ (function(){
+ var ms = document.getElementById('movie-search');
+ var mid = document.getElementById('movie-id');
+ var box = document.getElementById('movie-results');
+ var debounce = null;
+ function clearResults(){ box.innerHTML=''; box.classList.remove('open'); }
+ function pickMovie(id, title, year){ mid.value = id; ms.value = title + (year ? ' ('+year+')' : ''); clearResults(); }
+ function searchMovies(q){
+ fetch('/api/search?q=' + encodeURIComponent(q)).then(function(r){return r.json()}).then(function(j){
+ var movies = (j && j.movies) ? j.movies.slice(0,8) : [];
+ if (!movies.length){ clearResults(); return; }
+ box.innerHTML = movies.map(function(m){
+ var safeTitle = (m.title||'').replace(/[<>&"]/g, function(c){return {'<':'<','>':'>','&':'&','"':'"'}[c]});
+ return '<a href="#" data-id="'+m.id+'" data-title="'+safeTitle+'" data-year="'+(m.release_year||'')+'">' + safeTitle + ' <span style="color:var(--ink-faint);font-size:11px">'+(m.release_year||'')+'</span></a>';
+ }).join('');
+ box.classList.add('open');
+ box.querySelectorAll('a').forEach(function(a){
+ a.addEventListener('click', function(e){
+ e.preventDefault();
+ pickMovie(a.getAttribute('data-id'), a.getAttribute('data-title'), a.getAttribute('data-year'));
+ });
+ });
+ }).catch(clearResults);
+ }
+ ms.addEventListener('input', function(){
+ mid.value = ''; // clear bound id when user types again
+ clearTimeout(debounce);
+ var q = ms.value.trim();
+ if (q.length < 2) { clearResults(); return; }
+ debounce = setTimeout(function(){ searchMovies(q); }, 200);
+ });
+ document.addEventListener('click', function(e){
+ if (!box.contains(e.target) && e.target !== ms) clearResults();
+ });
+
+ var form = document.getElementById('spotted-form');
+ var result = document.getElementById('form-result');
+ form.addEventListener('submit', function(e){
+ e.preventDefault();
+ result.style.display='none';
+ var fd = new FormData(form);
+ var data = {};
+ fd.forEach(function(v,k){ data[k] = v; });
+ if (!data.movie_id) { result.className='form-result bad'; result.style.display='block'; result.textContent='Pick a movie from the dropdown.'; return; }
+ if (!data.product_name) { result.className='form-result bad'; result.style.display='block'; result.textContent='Product name required.'; return; }
+ var btn = form.querySelector('button[type=submit]');
+ btn.disabled = true; btn.textContent = 'Submitting…';
+ fetch('/api/spotted/submit', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)})
+ .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, body:j} }) })
+ .then(function(x){
+ btn.disabled=false; btn.textContent='Submit for review →';
+ if (x.ok) {
+ result.className='form-result'; result.style.display='block';
+ result.innerHTML='<strong>Thanks!</strong> Submission #'+x.body.id+' is queued for review. We\\'ll cross-check it against the set-decorator credits and verify within 48h.';
+ form.reset();
+ } else {
+ result.className='form-result bad'; result.style.display='block';
+ result.textContent = x.body.error || 'Submission failed. Please try again.';
+ }
+ })
+ .catch(function(err){
+ btn.disabled=false; btn.textContent='Submit for review →';
+ result.className='form-result bad'; result.style.display='block';
+ result.textContent='Network error. Please try again.';
+ });
+ });
+ })();
+ </script>
+ `;
+ res.type('html').send(htmlShell('Submit a placement', body));
+});
+
+app.post('/api/spotted/submit', async (req, res) => {
+ const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || 'unknown';
+ if (!spottedRateLimit(ip)) return res.status(429).json({ error: 'Too many submissions — wait 30 seconds before retrying.' });
+ const b = req.body || {};
+ const movieId = parseInt(b.movie_id, 10);
+ if (!Number.isFinite(movieId) || movieId < 1) return res.status(400).json({ error: 'movie_id required' });
+ const productName = String(b.product_name || '').trim();
+ if (!productName || productName.length < 2 || productName.length > 160) return res.status(400).json({ error: 'product_name must be 2–160 chars' });
+ // Bound all free-text fields.
+ const cat = String(b.product_category || '').slice(0, 60) || null;
+ const brand = String(b.brand || '').slice(0, 120) || null;
+ const desc = String(b.scene_description || '').slice(0, 500) || null;
+ const tc = String(b.scene_timecode || '').slice(0, 14) || null;
+ const url = String(b.shop_url || '').slice(0, 500) || null;
+ const em = String(b.submitted_by_email || '').slice(0, 160) || null;
+ const role = ['fan','set-decorator','brand-rep','other'].includes(b.submitted_by_role) ? b.submitted_by_role : 'fan';
+ if (url && !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'shop_url must start with http(s)://' });
+ if (em && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(em)) return res.status(400).json({ error: 'submitted_by_email looks invalid' });
+ try {
+ // Confirm the movie_id exists (else FK would error anyway, but a friendlier
+ // 404 helps).
+ const m = await pool.query(`SELECT id FROM asim_movies WHERE id=$1 LIMIT 1`, [movieId]);
+ if (!m.rows[0]) return res.status(404).json({ error: 'movie not found' });
+ const r = await pool.query(`
+ INSERT INTO asim_spotted
+ (movie_id, product_name, product_category, brand, scene_description,
+ scene_timecode, shop_url, submitted_by_email, submitted_by_role,
+ image_source)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'submitted')
+ RETURNING id`,
+ [movieId, productName, cat, brand, desc, tc, url, em, role]);
+ res.json({ ok: true, id: r.rows[0].id });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// SPOTTED feed — most recent verified placements across all films
app.get('/api/spotted', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
@@ -321,6 +557,7 @@ function htmlShell(title, body) {
<a href="/movies">Movies</a>
<a href="/people">People</a>
<a href="/api/spotted">Spotted</a>
+ <a href="/spotted/submit">Submit</a>
<a href="/search">Search</a>
</nav>
</header>
← 562f3b6 asseeninmovies: /search HTML page — combined movies+people g
·
back to AsSeenInMovies
·
asseeninmovies: /spotted HTML gallery — verified placements 6ed5341 →