← back to New Items Dashboard

server.js

201 lines

const express = require('express');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const sqlite3 = require('sqlite3').verbose();

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.PORT || 7201;

// Snapshot/editor scratch-file 404 guard — never serve *.bak, *.pre-*, *.orig, etc
// even if one accidentally lands under public/
app.use((req, res, next) => {
  if (/\.(bak|orig|rej|swp)(\?|$)|\.bak\.|\.pre-|~(\?|$)/i.test(req.path)) {
    return res.status(404).send('Not found');
  }
  next();
});

// ---------------------------------------------------------------------------
// Basic-auth gate (added 2026-05-19 — was publicly exposing vendor + price data)
// SECURITY: rotate before next deploy — set BASIC_AUTH in prod .env to override.
const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DW2025Secure!';
function safeEqual(a, b) {
  const ab = Buffer.from(String(a));
  const bb = Buffer.from(String(b));
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}
app.use((req, res, next) => {
  // In dev (NODE_ENV !== 'production') with no explicit BASIC_AUTH set, fail OPEN.
  if (process.env.NODE_ENV !== 'production' && !process.env.BASIC_AUTH) return next();
  const hdr = req.headers.authorization || '';
  if (!hdr.startsWith('Basic ')) {
    res.set('WWW-Authenticate', 'Basic realm="new-items-dashboard"');
    return res.status(401).send('Authentication required');
  }
  const supplied = Buffer.from(hdr.slice(6), 'base64').toString();
  if (!safeEqual(supplied, BASIC_AUTH)) {
    res.set('WWW-Authenticate', 'Basic realm="new-items-dashboard"');
    return res.status(401).send('Invalid credentials');
  }
  next();
});
// ---------------------------------------------------------------------------

// Serve static files
app.use(express.static('public'));

// Shared sort helper — applies `sort` query param to any item list
// in-memory. Frontend persists the user's choice and re-fetches with ?sort=
function applySort(items, sort, type) {
  if (!Array.isArray(items) || items.length === 0) return items;
  const arr = items.slice();
  const cmp = (a, b) => {
    const av = (a == null) ? '' : a;
    const bv = (b == null) ? '' : b;
    if (typeof av === 'number' && typeof bv === 'number') return av - bv;
    return String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
  };
  switch (sort) {
    case 'title':
      return arr.sort((a, b) => cmp(a.title || a.name || '', b.title || b.name || ''));
    case 'source':
      return arr.sort((a, b) => cmp(a.source || '', b.source || ''));
    case 'vendor':
      // wines have no vendor field; fall back to source. handbags use brand.
      return arr.sort((a, b) => cmp(a.vendor || a.brand || a.source || '', b.vendor || b.brand || b.source || ''));
    case 'series':
      // wines: vintage; handbags: model
      return arr.sort((a, b) => cmp(a.series || a.vintage || a.model || '', b.series || b.vintage || b.model || ''));
    case 'sku':
      return arr.sort((a, b) => cmp(a.sku || a.id || '', b.sku || a.id || ''));
    case 'newest':
    default: {
      // newest: prefer crawled_at, then timestamp, otherwise leave server order
      const hasTs = arr.some(x => x && (x.crawled_at || x.timestamp || x.listed_at));
      if (!hasTs) return arr;
      return arr.sort((a, b) => {
        const at = new Date(a.crawled_at || a.timestamp || a.listed_at || 0).getTime();
        const bt = new Date(b.crawled_at || b.timestamp || b.listed_at || 0).getTime();
        return bt - at;
      });
    }
  }
}

// API: Get NEW wines
app.get('/api/new-wines', async (req, res) => {
  try {
    const wineHistoryPath = '/root/Projects/wine-finder/data/seen_wines.json';

    // Find latest NEW wines file
    const dataDir = '/root/Projects/wine-finder/data';
    const files = await fs.readdir(dataDir);
    const newWineFiles = files
      .filter(f => f.startsWith('NEW-wines-'))
      .sort()
      .reverse();

    if (newWineFiles.length === 0) {
      return res.json({ success: true, wines: [], message: 'No crawls yet. First crawl will happen at 6am.' });
    }

    const latestFile = path.join(dataDir, newWineFiles[0]);
    const data = JSON.parse(await fs.readFile(latestFile, 'utf8'));

    const wines = applySort(data.newWines || [], req.query.sort, 'wine');

    res.json({
      success: true,
      wines,
      count: wines.length,
      timestamp: data.timestamp,
      summary: data.summary
    });
  } catch (error) {
    console.error('Error reading wines:', error);
    res.json({ success: true, wines: [], message: 'No new wines yet. First crawl at 6am.' });
  }
});

// API: Get NEW handbags
app.get('/api/new-handbags', (req, res) => {
  const dbPath = '/root/Projects/handbag-authentication/data/handbags.db';
  const db = new sqlite3.Database(dbPath);

  const sql = `
    SELECT * FROM listings
    WHERE is_new = 1
    ORDER BY crawled_at DESC
    LIMIT 50
  `;

  db.all(sql, [], (err, rows) => {
    db.close();

    if (err) {
      console.error('Database error:', err);
      return res.json({ success: true, handbags: [], count: 0 });
    }

    const handbags = applySort(rows || [], req.query.sort, 'handbag');

    res.json({
      success: true,
      handbags,
      count: handbags.length
    });
  });
});

// API: Get stats
app.get('/api/stats', async (req, res) => {
  try {
    // Wine stats
    let wineStats = { total: 0, lastCrawl: null };
    try {
      const seenWinesPath = '/root/Projects/wine-finder/data/seen_wines.json';
      const seenData = JSON.parse(await fs.readFile(seenWinesPath, 'utf8'));
      wineStats.total = seenData.totalWines || 0;
      wineStats.lastCrawl = seenData.lastUpdated;
    } catch (e) {
      // File doesn't exist yet
    }

    // Handbag stats
    const handbagStats = await new Promise((resolve) => {
      const db = new sqlite3.Database('/root/Projects/handbag-authentication/data/handbags.db');

      db.get('SELECT COUNT(*) as total, COUNT(CASE WHEN is_new = 1 THEN 1 END) as new FROM listings', [], (err, row) => {
        db.get('SELECT MAX(crawled_at) as lastCrawl FROM listings', [], (err2, row2) => {
          db.close();
          resolve({
            total: row ? row.total : 0,
            new: row ? row.new : 0,
            lastCrawl: row2 ? row2.lastCrawl : null
          });
        });
      });
    });

    res.json({
      success: true,
      wines: wineStats,
      handbags: handbagStats
    });
  } catch (error) {
    console.error('Stats error:', error);
    res.json({ success: false, error: error.message });
  }
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`\n🆕 NEW Items Dashboard running at:`);
  console.log(`   http://45.61.58.125:${PORT}`);
  console.log(`\nShowing latest wines and handbags that just got listed!\n`);
});