Spaces:
Running
Running
| 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`); | |
| }); | |