wallapi / server.js
Ig0tU
Chore: environment variable support and security updates
33644aa
require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const bodyParser = require('body-parser');
const { google } = require('googleapis');
const fs = require('fs');
const path = require('path');
const { Server } = require("socket.io");
const nodemailer = require('nodemailer');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*", // allow all domains to connect for real-time updates (you can restrict this later)
methods: ["GET", "POST"]
}
});
const port = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(bodyParser.json({ limit: '10mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
// Basic Auth for UI Protection
app.use((req, res, next) => {
// Always permit the API and integration scripts
if (req.path.startsWith('/api/') || req.path === '/embed.js' || req.path === '/live-site-integration.js') {
return next();
}
// For HTML pages and other assets, require a password
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
// Let them use 'admin' as the username, and the SMTP password (or fallback to 'admin' if not deployed yet)
const expectedPassword = process.env.UI_PASSWORD || process.env.SMTP_PASS || 'admin';
// We allow 'admin' user or ANY user as long as the password matches. Browsers usually prompt for both.
if (password === expectedPassword) {
return next();
}
res.set('WWW-Authenticate', 'Basic realm="Restricted WallAPI Space"');
res.status(401).send('Authentication required. Username: admin. Password matches your SMTP_PASS Secret (or "admin" if not set).');
});
app.use(express.static('public')); // Serve the frontend assets
// Google Sheets Setup
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets'];
async function getAuth() {
if (process.env.GOOGLE_CREDENTIALS) {
// Hosted on Hugging Face: we read the JSON string directly from the secret
try {
const credentials = JSON.parse(process.env.GOOGLE_CREDENTIALS);
const auth = new google.auth.GoogleAuth({
credentials,
scopes: SCOPES,
});
return auth;
} catch (e) {
console.error("Failed to parse GOOGLE_CREDENTIALS secret. Is it valid JSON?", e);
throw new Error("Invalid Google Credentials Secret");
}
} else if (fs.existsSync(process.env.GOOGLE_CREDENTIALS_PATH)) {
// Local fallback: read from the file
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_CREDENTIALS_PATH,
scopes: SCOPES,
});
return auth;
} else {
throw new Error('Google Credentials not found. Please set GOOGLE_CREDENTIALS secret or provide a local credentials.json.');
}
}
// Ensure the Google Sheet ID is configured
const spreadsheetId = process.env.GOOGLE_SHEET_ID;
// API Endpoint to receive form submissions
app.post('/api/submit', async (req, res) => {
try {
const { name, email, message, phone, subject, origin } = req.body;
const hasIdentifier = name || email || phone ||
req.body.submitterEmail || req.body.submitterName || req.body.submitterPhone ||
req.body.vetFirstName || req.body.contactName || req.body.groupName;
if (!hasIdentifier) {
return res.status(400).json({ error: 'Please provide at least a name, email, or phone number.' });
}
if (!spreadsheetId || spreadsheetId === 'your_google_sheet_id_here') {
console.error('SERVER SETUP ERROR: GOOGLE_SHEET_ID is not properly configured in .env');
return res.status(500).json({ error: 'Server configuration error.' });
}
const auth = await getAuth();
const sheets = google.sheets({ version: 'v4', auth });
// Assuming the first sheet is where data goes.
// We are going to append to Columns A to G (Timestamp, Name, Email, Message, Phone, Subject, Origin).
const TIMESTAMP = new Date().toISOString();
// The range specifies where to append.
// Dynamically sort into different tabs based on the origin URL!
let range = 'Sheet1';
let rowData = [];
// Fallbacks if generic integration script used
const safeName = req.body.name || req.body.Name || req.body._generic_name || '';
const safePhone = req.body.phone || req.body.Phone || '';
const safeEmail = req.body.email || req.body.Email || '';
const safeMessage = req.body.message || req.body.Message || '';
if (origin) {
const lowerOrigin = origin.toLowerCase();
if (lowerOrigin.includes('contact')) {
range = 'Contact';
// Match Contact Form: Date, Name, Email, Phone, Subject, Message, Origin
rowData = [
TIMESTAMP,
safeName,
safeEmail,
safePhone,
req.body.subject || '',
safeMessage,
origin
];
} else if (lowerOrigin.includes('tours')) {
range = 'Tours';
// Map exactly to: Date, Group Name, Tour Type, Contact Name, Contact Title, Email, Phone, Group Size, Date, Time, Special Needs, Additional Info, Origin
rowData = [
TIMESTAMP,
req.body.groupName || '',
req.body.tourType || '',
req.body.contactName || '',
req.body.contactTitle || '',
req.body.email || '',
req.body.phone || '',
req.body.groupSize || '',
req.body.preferredDate || '',
req.body.preferredTime || '',
req.body.specialNeeds || '',
req.body.additionalInfo || '',
origin
];
} else if (lowerOrigin.includes('escort')) {
range = 'Escort';
// Map exactly to: Date, Name, Email, Phone, Vehicle, Group Affiliation, Origin
rowData = [
TIMESTAMP,
req.body.name || '',
req.body.email || '',
req.body.phone || '',
req.body.vehicle || '',
req.body.groupAffiliation || '',
origin
];
} else if (lowerOrigin.includes('honor')) {
range = 'Honor';
let vetPhotoUrl = '';
const photoData = req.body.vetPhoto;
if (photoData && photoData.startsWith('data:image')) {
try {
const matches = photoData.match(/^data:image\/(\w+);base64,(.+)$/s);
if (matches) {
const uploadDir = path.join(__dirname, 'public', 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const fileName = `${Date.now()}.${matches[1]}`;
fs.writeFileSync(path.join(uploadDir, fileName), Buffer.from(matches[2], 'base64'));
vetPhotoUrl = `${req.protocol}://${req.get('host')}/uploads/${fileName}`;
}
} catch (photoErr) {
console.error('Failed to save photo:', photoErr);
}
}
rowData = [
TIMESTAMP,
req.body.vetFirstName || '',
req.body.vetMiddleName || '',
req.body.vetLastName || '',
req.body.vetRank || '',
req.body.vetBranch || '',
req.body.yearsServed || '',
req.body.conflictEra || '',
req.body.vetHometown || '',
req.body.isDeceased || 'No',
req.body.vetStory || '',
req.body.specialRecognition || '',
req.body.submitterName || '',
req.body.relationship || '',
req.body.submitterEmail || '',
req.body.submitterPhone || '',
req.body.displayPermission ? 'Yes' : 'No',
origin,
vetPhotoUrl
];
} else {
// Fallback catch-all
rowData = [TIMESTAMP, safeName, safeEmail, safeMessage, safePhone, subject || '', origin];
}
} else {
rowData = [TIMESTAMP, safeName, safeEmail, safeMessage, safePhone, subject || '', origin || 'Unknown'];
}
const response = await sheets.spreadsheets.values.append({
spreadsheetId,
range,
valueInputOption: 'USER_ENTERED',
requestBody: {
values: [rowData],
},
});
console.log(`Successfully appended row to sheet: ${response.data.updates.updatedRange}`);
// Broadcast the new submission to all connected clients
io.emit('new_submission', {
timestamp: TIMESTAMP,
name,
email,
message,
phone: phone || '',
subject: subject || '',
origin: origin || 'Hugging Face Widget'
});
// Send Email Notification if credentials exist
if (process.env.SMTP_USER && process.env.SMTP_PASS && process.env.NOTIFICATION_EMAIL) {
try {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 465,
secure: process.env.SMTP_PORT == 465 || process.env.SMTP_PORT == undefined,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
const mailOptions = {
from: `"WallAPI Form System" <${process.env.SMTP_USER}>`,
to: process.env.NOTIFICATION_EMAIL,
subject: `New Lead: ${name} via ${subject || 'Website'}`,
html: `
<h2>New Form Submission</h2>
<p>You have received a new contact submission.</p>
<table border="1" cellpadding="8" style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td width="30%" bgcolor="#f3f4f6"><strong>Name</strong></td><td>${name}</td></tr>
<tr><td bgcolor="#f3f4f6"><strong>Email</strong></td><td><a href="mailto:${email}">${email}</a></td></tr>
<tr><td bgcolor="#f3f4f6"><strong>Phone</strong></td><td>${phone || '<i>Not provided</i>'}</td></tr>
<tr><td bgcolor="#f3f4f6"><strong>Subject</strong></td><td>${subject || '<i>Not provided</i>'}</td></tr>
<tr><td bgcolor="#f3f4f6"><strong>Origin</strong></td><td>${origin || 'Hugging Face Widget'}</td></tr>
<tr><td bgcolor="#f3f4f6"><strong>Timestamp</strong></td><td>${TIMESTAMP}</td></tr>
<tr>
<td colspan="2" bgcolor="#f3f4f6"><strong>Message</strong></td>
</tr>
<tr>
<td colspan="2" style="white-space: pre-wrap;">${message}</td>
</tr>
</table>
`
};
const info = await transporter.sendMail(mailOptions);
console.log(`Email notification sent: ${info.messageId}`);
} catch (emailErr) {
console.error('Failed to send email notification:', emailErr);
// We do not fail the request if the email fails, since it's already in the Sheet
}
}
return res.status(200).json({ success: true, message: 'Form submitted successfully!' });
} catch (error) {
console.error('Error handling form submission:', error);
if (error.message && (error.message.includes('Credentials file not found') || error.message.includes('Google Credentials not found') || error.message.includes('Invalid Google Credentials'))) {
return res.status(500).json({ error: 'Server is missing Google Sheets credentials.' });
}
return res.status(500).json({ error: 'An error occurred while submitting the form.' });
}
});
// Socket.IO Connection Handling
io.on('connection', (socket) => {
console.log('A client connected for real-time updates:', socket.id);
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
// Start the server (changed from app.listen to server.listen for Socket.IO)
server.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
console.log(`Test the embeddable form at http://localhost:${port}/index.html`);
});