← back to Dw War Room

serve.cjs

186 lines

// Simple static server with Basic Auth for War Room
// Includes Shopify search proxy endpoint for showroom live search
// Uses Shopify GraphQL Admin API for fuzzy product search
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const url = require('url');

const PORT = 4060;
const USER = 'admin';
const PASS = 'DWSecure2024!';
const DIST = path.join(__dirname, 'dist');

// Shopify API config (Admin API — server-side only)
const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
const SHOPIFY_TOKEN = (process.env.SHOPIFY_ADMIN_TOKEN || '');
const SHOPIFY_API_VERSION = '2024-01';

const MIME = {
  '.html': 'text/html',
  '.js': 'application/javascript',
  '.css': 'text/css',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.svg': 'image/svg+xml',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff',
};

/**
 * Proxy Shopify product search via GraphQL — keeps API token server-side.
 * Uses GraphQL for proper fuzzy search (REST title= requires exact match).
 * GET /api/shopify/search?q=QUERY&limit=10
 * Returns: { products: [{ id, title, vendor, product_type, handle, images, variants, tags }] }
 */
function handleShopifySearch(query, limit, res) {
  if (!query || query.length < 2) {
    res.writeHead(400, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
  }

  limit = Math.min(parseInt(limit, 10) || 10, 20);

  // GraphQL query — searches across title, vendor, tags, etc.
  const gqlQuery = JSON.stringify({
    query: `{
      products(first: ${limit}, query: "${query.replace(/"/g, '\\"')}") {
        edges {
          node {
            id
            title
            vendor
            productType
            handle
            tags
            featuredImage {
              url
              altText
            }
            images(first: 1) {
              edges {
                node {
                  url
                  altText
                }
              }
            }
            variants(first: 1) {
              edges {
                node {
                  price
                  sku
                }
              }
            }
          }
        }
      }
    }`
  });

  const options = {
    hostname: SHOPIFY_STORE,
    path: `/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': SHOPIFY_TOKEN,
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(gqlQuery),
    },
  };

  const req2 = https.request(options, (shopifyRes) => {
    let data = '';
    shopifyRes.on('data', (chunk) => { data += chunk; });
    shopifyRes.on('end', () => {
      try {
        const gqlResponse = JSON.parse(data);
        const edges = gqlResponse?.data?.products?.edges || [];

        // Transform GraphQL response to REST-like format for frontend compatibility
        const products = edges.map(({ node }) => ({
          id: parseInt(node.id.replace('gid://shopify/Product/', ''), 10),
          title: node.title,
          vendor: node.vendor,
          product_type: node.productType,
          handle: node.handle,
          tags: node.tags ? node.tags.join(', ') : '',
          images: node.images?.edges?.length
            ? node.images.edges.map(e => ({ src: e.node.url }))
            : node.featuredImage
              ? [{ src: node.featuredImage.url }]
              : [],
          variants: node.variants?.edges?.length
            ? node.variants.edges.map(e => ({ price: e.node.price, sku: e.node.sku || '' }))
            : [{ price: '0.00', sku: '' }],
        }));

        res.writeHead(200, {
          'Content-Type': 'application/json',
          'Cache-Control': 'max-age=30',
        });
        res.end(JSON.stringify({ products }));
      } catch (parseErr) {
        console.error('[Shopify Search Proxy] Parse error:', parseErr.message);
        res.writeHead(502, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Failed to parse Shopify response', products: [] }));
      }
    });
  });

  req2.on('error', (err) => {
    console.error('[Shopify Search Proxy] Error:', err.message);
    res.writeHead(502, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Shopify API request failed', products: [] }));
  });

  req2.write(gqlQuery);
  req2.end();
}

const server = http.createServer((req, res) => {
  // Basic Auth check
  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Basic ')) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW War Room"' });
    return res.end('Unauthorized');
  }
  const [u, p] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
  if (u !== USER || p !== PASS) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW War Room"' });
    return res.end('Unauthorized');
  }

  // Parse URL
  const parsedUrl = url.parse(req.url, true);
  const pathname = parsedUrl.pathname;

  // API routes — handle before static file serving
  if (pathname === '/api/shopify/search' && req.method === 'GET') {
    return handleShopifySearch(parsedUrl.query.q, parsedUrl.query.limit, res);
  }

  // Serve static files
  let filePath = path.join(DIST, pathname === '/' ? 'index.html' : pathname);
  if (!fs.existsSync(filePath)) filePath = path.join(DIST, 'index.html');

  const ext = path.extname(filePath);
  const mime = MIME[ext] || 'application/octet-stream';

  try {
    const content = fs.readFileSync(filePath);
    res.writeHead(200, { 'Content-Type': mime });
    res.end(content);
  } catch {
    res.writeHead(404);
    res.end('Not found');
  }
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`[War Room] Serving on port ${PORT} with auth + Shopify search proxy (GraphQL)`);
});