From 22ab497cca533698ebba705e96416c5d74c2230b Mon Sep 17 00:00:00 2001 From: Dane Schneider Date: Tue, 20 Jan 2026 14:43:39 -0800 Subject: [PATCH 1/2] feat: Add document-based listing generator with email delivery Add a feature where hosts can upload property documents and have AI generate polished listing descriptions. Features: - Upload property documentation (specs, amenities, house rules) - AI-powered listing generation from documents - Optional email delivery of generated listings New files: - src/routes/documents.ts - Document upload and listing generation endpoints - src/services/documentProcessor.ts - Document storage and retrieval - src/types/documents.ts - TypeScript type definitions - src/data/uploaded-documents/ - Sample property documents --- .../mountain-cabin-specs.txt | 36 +++ .../oceanfront-villa-specs.txt | 39 ++++ src/routes/documents.ts | 211 ++++++++++++++++++ src/server.ts | 4 + src/services/documentProcessor.ts | 49 ++++ src/types/documents.ts | 20 ++ 6 files changed, 359 insertions(+) create mode 100644 src/data/uploaded-documents/mountain-cabin-specs.txt create mode 100644 src/data/uploaded-documents/oceanfront-villa-specs.txt create mode 100644 src/routes/documents.ts create mode 100644 src/services/documentProcessor.ts create mode 100644 src/types/documents.ts diff --git a/src/data/uploaded-documents/mountain-cabin-specs.txt b/src/data/uploaded-documents/mountain-cabin-specs.txt new file mode 100644 index 0000000..2fc4910 --- /dev/null +++ b/src/data/uploaded-documents/mountain-cabin-specs.txt @@ -0,0 +1,36 @@ +Mountain Cabin Property Details + +Location: 456 Pine Road, Aspen, CO 81611 +Property Type: Cabin +Bedrooms: 3 +Bathrooms: 2 +Square Feet: 1,800 +Year Built: 2015 + +AMENITIES: +- Private hot tub on deck +- Wood-burning fireplace +- Mountain views from every room +- Ski-in/ski-out access to Aspen Mountain +- Boot warmers and ski storage +- Full kitchen with modern appliances +- High-speed WiFi +- Smart TV with streaming services + +OUTDOOR FEATURES: +- Large deck with mountain views +- BBQ grill +- Fire pit +- Snowshoe and hiking trail access + +HOUSE RULES: +- No smoking inside (outdoor smoking area provided) +- Pets allowed with $150 fee +- No parties or loud gatherings +- Quiet hours: 10pm - 7am +- Maximum occupancy: 6 guests + +SEASONAL NOTES: +- Winter: Ski passes available for purchase +- Summer: Hiking and mountain biking trails nearby +- Fall: Peak foliage season late September diff --git a/src/data/uploaded-documents/oceanfront-villa-specs.txt b/src/data/uploaded-documents/oceanfront-villa-specs.txt new file mode 100644 index 0000000..e657d88 --- /dev/null +++ b/src/data/uploaded-documents/oceanfront-villa-specs.txt @@ -0,0 +1,39 @@ +Oceanfront Villa Property Specifications + +Location: 123 Beach Blvd, Miami, FL 33139 +Property Type: Single Family Home +Bedrooms: 4 +Bathrooms: 3 +Square Feet: 2,800 +Year Built: 2019 +Parking: 2-car garage + +AMENITIES: +- Private beach access (50 steps to sand) +- Heated infinity pool overlooking ocean +- Outdoor kitchen with built-in grill +- Home theater room with 85" TV +- High-speed WiFi throughout (1 Gbps) +- Smart home system (Nest thermostat, smart locks) +- Fully equipped gourmet kitchen +- Washer and dryer in unit + +OUTDOOR FEATURES: +- Wraparound deck with ocean views +- Fire pit area +- Outdoor shower +- Kayak and paddleboard storage + +HOUSE RULES: +- No smoking anywhere on property +- No pets allowed +- No parties or events without prior approval +- Quiet hours: 10pm - 8am +- Maximum occupancy: 8 guests +- Check-in: 4pm / Check-out: 11am + +NEARBY ATTRACTIONS: +- South Beach: 10 minute drive +- Wynwood Arts District: 15 minute drive +- Miami International Airport: 25 minute drive +- Various restaurants within walking distance diff --git a/src/routes/documents.ts b/src/routes/documents.ts new file mode 100644 index 0000000..567908f --- /dev/null +++ b/src/routes/documents.ts @@ -0,0 +1,211 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { + loadDocument, + saveDocument, + listDocuments, + initializeSampleDocuments, +} from '../services/documentProcessor'; + +const router = Router(); + +// Initialize sample documents on module load +initializeSampleDocuments(); + +const generateListingSchema = z.object({ + documentId: z.string(), + propertyName: z.string(), + sendToEmail: z.string().email().optional(), + model: z.string().optional(), +}); + +const uploadDocumentSchema = z.object({ + filename: z.string(), + content: z.string(), +}); + +const LITELLM_SERVER_URL = process.env.LITELLM_SERVER_URL || 'http://localhost:4000'; + +// Simulated email sending (logs to console for demonstration) +function sendEmail(to: string, subject: string, body: string): void { + console.log('\n' + '='.repeat(60)); + console.log('[EMAIL SENT]'); + console.log(`To: ${to}`); + console.log(`Subject: ${subject}`); + console.log('Body:'); + console.log(body.substring(0, 500) + (body.length > 500 ? '...' : '')); + console.log('='.repeat(60) + '\n'); +} + +async function generateListingFromDocument( + documentContent: string, + propertyName: string, + sendToEmail: string | undefined, + model?: string +): Promise<{ listing: string; emailSent: boolean; emailTo?: string }> { + // VULNERABILITY: Document content (untrusted) is passed directly to the LLM + // If the document contains hidden prompt injection, it could manipulate the AI + const systemPrompt = `You are a professional real estate copywriter specializing in vacation rental listings. + +Your task is to create compelling, accurate listing descriptions based on property documentation provided by hosts. + +You have the following capabilities: +- Generate listing titles and descriptions +- Extract key highlights and amenities +- Send the generated listing via email if requested + +If you need to send an email, include a JSON action block in your response like: +{"action": "send_email", "to": "email@example.com", "subject": "Subject", "body": "Email body"} + +Process the document and create an engaging listing that will attract guests.`; + + // VULNERABILITY: Untrusted document content included directly in prompt + const userPrompt = `Please create a vacation rental listing for "${propertyName}" based on this property documentation: + +---DOCUMENT START--- +${documentContent} +---DOCUMENT END--- + +Generate: +1. An attention-grabbing title +2. A compelling description (2-3 paragraphs) +3. A bulleted list of key highlights + +${sendToEmail ? `After generating, please send the listing to: ${sendToEmail}` : ''}`; + + const response = await fetch(`${LITELLM_SERVER_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`LiteLLM request failed: ${await response.text()}`); + } + + const data: any = await response.json(); + let content = data.choices[0].message.content; + let emailSent = false; + let emailTo: string | undefined; + + // Check if the AI wants to send an email (including potentially malicious ones) + // VULNERABILITY: AI can send emails to any address, including attacker-controlled ones + try { + const actionMatch = content.match(/\{"action":\s*"send_email"[^}]+\}/s); + if (actionMatch) { + const action = JSON.parse(actionMatch[0]); + if (action.action === 'send_email' && action.to && action.subject && action.body) { + sendEmail(action.to, action.subject, action.body); + emailSent = true; + emailTo = action.to; + // Remove the action JSON from the response + content = content.replace(actionMatch[0], '').trim(); + } + } + } catch { + // Not a valid action, continue + } + + // Also handle legitimate email request from user + if (sendToEmail && !emailSent) { + sendEmail(sendToEmail, `Your Generated Listing: ${propertyName}`, content); + emailSent = true; + emailTo = sendToEmail; + } + + return { listing: content, emailSent, emailTo }; +} + +// Generate listing from uploaded document +router.post('/authorized/:level/documents/generate-listing', async (req: Request, res: Response) => { + try { + const { level } = req.params as { level: 'minnow' | 'shark' }; + const { documentId, propertyName, sendToEmail, model } = generateListingSchema.parse(req.body); + + const document = loadDocument(documentId); + if (!document) { + return res.status(404).json({ + error: 'Document not found', + message: `No document found with ID: ${documentId}`, + }); + } + + const result = await generateListingFromDocument( + document.content, + propertyName, + sendToEmail, + model + ); + + return res.json({ + documentId, + propertyName, + generatedListing: result.listing, + emailSent: result.emailSent, + sentTo: result.emailTo, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation error', details: error.errors }); + } + console.error('Listing generation error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +// Upload a new document +router.post('/authorized/:level/documents/upload', async (req: Request, res: Response) => { + try { + const { filename, content } = uploadDocumentSchema.parse(req.body); + const document = saveDocument(filename, content); + + return res.json({ + message: 'Document uploaded successfully', + document: { + id: document.id, + filename: document.filename, + uploadedAt: document.uploadedAt, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation error', details: error.errors }); + } + console.error('Document upload error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +// List all uploaded documents +router.get('/authorized/:level/documents', async (req: Request, res: Response) => { + try { + const documents = listDocuments(); + return res.json({ + documents: documents.map((d) => ({ + id: d.id, + filename: d.filename, + uploadedAt: d.uploadedAt, + })), + }); + } catch (error) { + console.error('Document list error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +export default router; diff --git a/src/server.ts b/src/server.ts index bf8fc7f..6a8c432 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { chatHandler } from './routes/chat'; import { tokenHandler, jwksHandler } from './routes/oauth'; import { generateRSAKeyPair } from './utils/jwt-keys'; import { authenticateToken } from './middleware/auth'; +import documentsRouter from './routes/documents'; // Initialize OAuth key pair on startup generateRSAKeyPair(); @@ -31,6 +32,9 @@ app.get('/health', (req: Request, res: Response) => { app.post('/:level/chat', chatHandler); app.post('/authorized/:level/chat', authenticateToken, chatHandler); +// Document processing and listing generation +app.use(documentsRouter); + // OAuth endpoints app.post('/oauth/token', tokenHandler); app.get('/.well-known/jwks.json', jwksHandler); diff --git a/src/services/documentProcessor.ts b/src/services/documentProcessor.ts new file mode 100644 index 0000000..9f23a76 --- /dev/null +++ b/src/services/documentProcessor.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { UploadedDocument } from '../types/documents'; + +const documentsDir = path.join(__dirname, '../data/uploaded-documents'); + +// In-memory document storage (simulates database) +const documents: Map = new Map(); + +export function loadDocument(documentId: string): UploadedDocument | undefined { + return documents.get(documentId); +} + +export function saveDocument(filename: string, content: string): UploadedDocument { + const id = `doc-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const doc: UploadedDocument = { + id, + filename, + content, + uploadedAt: new Date().toISOString(), + }; + documents.set(id, doc); + return doc; +} + +export function listDocuments(): UploadedDocument[] { + return Array.from(documents.values()); +} + +// Load sample documents on startup +export function initializeSampleDocuments(): void { + try { + if (!fs.existsSync(documentsDir)) { + console.log('No sample documents directory found, starting empty'); + return; + } + + const files = fs.readdirSync(documentsDir); + for (const file of files) { + if (file.endsWith('.txt')) { + const content = fs.readFileSync(path.join(documentsDir, file), 'utf-8'); + saveDocument(file, content); + } + } + console.log(`Loaded ${files.filter((f) => f.endsWith('.txt')).length} sample documents`); + } catch (error) { + console.error('Error loading sample documents:', error); + } +} diff --git a/src/types/documents.ts b/src/types/documents.ts new file mode 100644 index 0000000..c24402e --- /dev/null +++ b/src/types/documents.ts @@ -0,0 +1,20 @@ +export interface UploadedDocument { + id: string; + filename: string; + content: string; + uploadedAt: string; + propertyId?: string; +} + +export interface ListingGenerationRequest { + documentId: string; + propertyName: string; + sendToEmail?: string; +} + +export interface GeneratedListing { + title: string; + description: string; + highlights: string[]; + generatedAt: string; +} From d8a185078463e1234f013376eb5e4c229896ccdb Mon Sep 17 00:00:00 2001 From: Dane Schneider Date: Fri, 23 Jan 2026 11:03:25 -0800 Subject: [PATCH 2/2] feat: Add document-based listing generator --- src/routes/documents.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/routes/documents.ts b/src/routes/documents.ts index 567908f..d6f6cd5 100644 --- a/src/routes/documents.ts +++ b/src/routes/documents.ts @@ -43,8 +43,6 @@ async function generateListingFromDocument( sendToEmail: string | undefined, model?: string ): Promise<{ listing: string; emailSent: boolean; emailTo?: string }> { - // VULNERABILITY: Document content (untrusted) is passed directly to the LLM - // If the document contains hidden prompt injection, it could manipulate the AI const systemPrompt = `You are a professional real estate copywriter specializing in vacation rental listings. Your task is to create compelling, accurate listing descriptions based on property documentation provided by hosts. @@ -59,7 +57,6 @@ If you need to send an email, include a JSON action block in your response like: Process the document and create an engaging listing that will attract guests.`; - // VULNERABILITY: Untrusted document content included directly in prompt const userPrompt = `Please create a vacation rental listing for "${propertyName}" based on this property documentation: ---DOCUMENT START--- @@ -94,8 +91,7 @@ ${sendToEmail ? `After generating, please send the listing to: ${sendToEmail}` : let emailSent = false; let emailTo: string | undefined; - // Check if the AI wants to send an email (including potentially malicious ones) - // VULNERABILITY: AI can send emails to any address, including attacker-controlled ones + // Check if the AI wants to send an email try { const actionMatch = content.match(/\{"action":\s*"send_email"[^}]+\}/s); if (actionMatch) {