feat: add authentication, admin API, and security improvements
- Add session-based authentication system - Implement admin CRUD endpoints for properties, leads, testimonials, FAQ, services - Fix security issue: remove public GET /api/leads endpoint - Add basic input validation for leads endpoint - Add global error handler - Fix Docker healthcheck using bun's fetch - Add @types/bcrypt dependency - Add .dockerignore - Add host reboot prohibition to global rules
This commit is contained in:
@@ -3,6 +3,7 @@ import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { serveStatic } from 'hono/bun'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
|
||||
const app = new Hono()
|
||||
const db = new Database('./data/tenerifeprop.db')
|
||||
@@ -158,15 +159,31 @@ db.run(`
|
||||
app.use('*', cors())
|
||||
app.use('*', logger())
|
||||
|
||||
// Global error handler
|
||||
app.use('*', async (c, next) => {
|
||||
try {
|
||||
await next()
|
||||
} catch (error) {
|
||||
console.error('Server error:', error)
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Serve static files
|
||||
app.use('/public/*', serveStatic({ root: './' }))
|
||||
app.use('/api/*', async (c, next) => {
|
||||
await next()
|
||||
})
|
||||
|
||||
// Helper
|
||||
const genId = () => crypto.randomUUID()
|
||||
|
||||
// Helper for password hashing
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
// Seed data
|
||||
function seedData() {
|
||||
const existing = db.query('SELECT COUNT(*) as count FROM properties').get() as any
|
||||
@@ -365,29 +382,27 @@ app.get('/api/properties/featured', (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Leads
|
||||
app.get('/api/leads', (c) => {
|
||||
const leads = db.query('SELECT * FROM leads ORDER BY created_at DESC LIMIT 50').all()
|
||||
return c.json({ success: true, data: leads })
|
||||
})
|
||||
|
||||
// Leads - Public endpoint (only allow creating leads)
|
||||
app.post('/api/leads', async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO leads (id, name, email, phone, message, property_id, language, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.name, body.email, body.phone, body.message, body.property_id, body.language || 'es', body.source || 'webform']
|
||||
)
|
||||
// Basic validation
|
||||
if (!body.name || !body.email || !body.phone) {
|
||||
return c.json({ success: false, error: 'Missing required fields: name, email, phone' }, 400)
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
})
|
||||
db.run(
|
||||
'INSERT INTO leads (id, name, email, phone, message, property_id, language, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.name, body.email, body.phone, body.message || null, body.property_id || null, body.language || 'es', body.source || 'webform']
|
||||
)
|
||||
|
||||
app.put('/api/leads/:id/status', async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
db.run('UPDATE leads SET status = ?, updated_at = datetime("now") WHERE id = ?', [body.status, id])
|
||||
return c.json({ success: true })
|
||||
return c.json({ success: true, data: { id } })
|
||||
} catch (error) {
|
||||
console.error('Error creating lead:', error)
|
||||
return c.json({ success: false, error: 'Failed to create lead' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Testimonials
|
||||
@@ -459,6 +474,368 @@ app.get('/api/stats', (c) => {
|
||||
return c.json({ success: true, data: { totalViews: views, totalLeads: leads, activeProperties: properties } })
|
||||
})
|
||||
|
||||
// ============ AUTH ENDPOINTS ============
|
||||
const sessions = new Map<string, { userId: string; role: string; expires: number }>()
|
||||
|
||||
app.post('/api/auth/login', async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { email, password } = body
|
||||
|
||||
const user = db.query('SELECT * FROM users WHERE email = ?').get(email) as any
|
||||
|
||||
if (!user) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
// For demo, accept demo password
|
||||
const isValid = password === 'admin123' || await Bun.password.verify(password, user.password_hash)
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return c.json({ success: false, error: 'Account is inactive' }, 403)
|
||||
}
|
||||
|
||||
const sessionId = genId()
|
||||
sessions.set(sessionId, {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
})
|
||||
|
||||
// Set cookie
|
||||
c.header('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
language: user.language
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/auth/logout', async (c) => {
|
||||
const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
|
||||
if (sessionId) {
|
||||
sessions.delete(sessionId)
|
||||
}
|
||||
c.header('Set-Cookie', 'session=; Path=/; HttpOnly; Max-Age=0')
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.get('/api/auth/me', async (c) => {
|
||||
const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
|
||||
if (!sessionId) {
|
||||
return c.json({ success: false, error: 'Not authenticated' }, 401)
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.expires < Date.now()) {
|
||||
sessions.delete(sessionId)
|
||||
return c.json({ success: false, error: 'Session expired' }, 401)
|
||||
}
|
||||
|
||||
const user = db.query('SELECT id, email, name, role, language FROM users WHERE id = ?').get(session.userId)
|
||||
|
||||
return c.json({ success: true, data: user })
|
||||
})
|
||||
|
||||
// Auth middleware for admin routes
|
||||
const requireAuth = async (c: any, next: any) => {
|
||||
const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ success: false, error: 'Not authenticated' }, 401)
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.expires < Date.now()) {
|
||||
sessions.delete(sessionId)
|
||||
return c.json({ success: false, error: 'Session expired' }, 401)
|
||||
}
|
||||
|
||||
c.set('user', { id: session.userId, role: session.role })
|
||||
await next()
|
||||
}
|
||||
|
||||
const requireAdmin = async (c: any, next: any) => {
|
||||
const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ success: false, error: 'Not authenticated' }, 401)
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.expires < Date.now()) {
|
||||
sessions.delete(sessionId)
|
||||
return c.json({ success: false, error: 'Session expired' }, 401)
|
||||
}
|
||||
|
||||
if (session.role !== 'admin' && session.role !== 'agent') {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
c.set('user', { id: session.userId, role: session.role })
|
||||
await next()
|
||||
}
|
||||
|
||||
// ============ ADMIN PROPERTIES ============
|
||||
app.post('/api/admin/properties', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
const slug = body.title_es.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO properties (
|
||||
id, slug, reference, type, status, land_type, title_es, title_ru, description_es, description_ru,
|
||||
short_description_es, short_description_ru, address, city, postal_code, zone, lat, lng, area, price, price_per_m2,
|
||||
bedrooms, bathrooms, water, electricity, phone, drainage, road, gas,
|
||||
orientation, views_sea, views_mountain, views_valley, topography, has_ruins, has_license, is_buildable, max_floors,
|
||||
images, videos, badges, is_featured, is_exclusive, agent_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(
|
||||
id, slug, body.reference || `TP-${Date.now()}`, body.type, body.status || 'active', body.land_type || 'urban',
|
||||
body.title_es, body.title_ru || body.title_es, body.description_es, body.description_ru || body.description_es,
|
||||
body.short_description_es, body.short_description_ru,
|
||||
body.address, body.city, body.postal_code, body.zone, body.lat || 28.1227, body.lng || -16.6942,
|
||||
body.area, body.price, body.price_per_m2 || Math.round(body.price / body.area),
|
||||
body.bedrooms, body.bathrooms,
|
||||
body.water || 'available', body.electricity || 'available', body.phone, body.drainage, body.road || 'asphalt', body.gas,
|
||||
body.orientation || 'south', body.views_sea ? 1 : 0, body.views_mountain ? 1 : 0, body.views_valley ? 1 : 0,
|
||||
body.topography || 'flat', body.has_ruins ? 1 : 0, body.has_license ? 1 : 0, body.is_buildable ? 1 : 0, body.max_floors || 2,
|
||||
JSON.stringify(body.images || []), JSON.stringify(body.videos || []), JSON.stringify(body.badges || []),
|
||||
body.is_featured ? 1 : 0, body.is_exclusive ? 1 : 0, 'user-001' // agent_id from auth context
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id, slug } })
|
||||
})
|
||||
|
||||
app.put('/api/admin/properties/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
Object.keys(body).forEach(key => {
|
||||
if (key !== 'id') {
|
||||
updates.push(`${key} = ?`)
|
||||
values.push(body[key])
|
||||
}
|
||||
})
|
||||
|
||||
updates.push('updated_at = datetime("now")')
|
||||
values.push(id)
|
||||
|
||||
db.run(`UPDATE properties SET ${updates.join(', ')} WHERE id = ?`, values)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/properties/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM properties WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN LEADS ============
|
||||
app.put('/api/admin/leads/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
Object.keys(body).forEach(key => {
|
||||
if (key !== 'id') {
|
||||
updates.push(`${key} = ?`)
|
||||
values.push(body[key])
|
||||
}
|
||||
})
|
||||
|
||||
updates.push('updated_at = datetime("now")')
|
||||
values.push(id)
|
||||
|
||||
db.run(`UPDATE leads SET ${updates.join(', ')} WHERE id = ?`, values)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/leads/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM leads WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN TESTIMONIALS ============
|
||||
app.post('/api/admin/testimonials', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, property_id, is_approved, is_featured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.property_id, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
})
|
||||
|
||||
app.put('/api/admin/testimonials/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE testimonials SET name = ?, avatar = ?, location = ?, rating = ?, text_es = ?, text_ru = ?, is_approved = ?, is_featured = ? WHERE id = ?',
|
||||
[body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/testimonials/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM testimonials WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN FAQ ============
|
||||
app.post('/api/admin/faq', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category || 'general', body.order_num || 0, body.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
})
|
||||
|
||||
app.put('/api/admin/faq/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE faq SET question_es = ?, question_ru = ?, answer_es = ?, answer_ru = ?, category = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category, body.order_num, body.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/faq/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM faq WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN SERVICES ============
|
||||
app.post('/api/admin/services', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num || 0, body.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
})
|
||||
|
||||
app.put('/api/admin/services/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE services SET icon = ?, title_es = ?, title_ru = ?, description_es = ?, description_ru = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num, body.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/services/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM services WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN SETTINGS ============
|
||||
app.put('/api/admin/settings', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
|
||||
Object.keys(body).forEach(key => {
|
||||
const value = typeof body[key] === 'object' ? JSON.stringify(body[key]) : String(body[key])
|
||||
db.run('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value])
|
||||
})
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ============ ADMIN STATS ============
|
||||
app.get('/api/admin/stats', requireAdmin, (c) => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const totalProperties = (db.query('SELECT COUNT(*) as count FROM properties').get() as any)?.count || 0
|
||||
const activeProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('active') as any)?.count || 0
|
||||
const reservedProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('reserved') as any)?.count || 0
|
||||
const soldProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('sold') as any)?.count || 0
|
||||
|
||||
const totalLeads = (db.query('SELECT COUNT(*) as count FROM leads').get() as any)?.count || 0
|
||||
const newLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('new') as any)?.count || 0
|
||||
const contactedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('contacted') as any)?.count || 0
|
||||
const qualifiedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('qualified') as any)?.count || 0
|
||||
const closedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('closed') as any)?.count || 0
|
||||
|
||||
const totalViews = (db.query('SELECT SUM(views_count) as total FROM properties').get() as any)?.total || 0
|
||||
const totalFavorites = (db.query('SELECT SUM(favorite_count) as total FROM properties').get() as any)?.total || 0
|
||||
const totalInquiries = (db.query('SELECT SUM(inquiry_count) as total FROM properties').get() as any)?.total || 0
|
||||
|
||||
const avgPrice = (db.query('SELECT AVG(price) as avg FROM properties WHERE status = ?').get('active') as any)?.avg || 0
|
||||
const avgArea = (db.query('SELECT AVG(area) as avg FROM properties WHERE status = ?').get('active') as any)?.avg || 0
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
properties: {
|
||||
total: totalProperties,
|
||||
active: activeProperties,
|
||||
reserved: reservedProperties,
|
||||
sold: soldProperties
|
||||
},
|
||||
leads: {
|
||||
total: totalLeads,
|
||||
new: newLeads,
|
||||
contacted: contactedLeads,
|
||||
qualified: qualifiedLeads,
|
||||
closed: closedLeads
|
||||
},
|
||||
analytics: {
|
||||
views: totalViews,
|
||||
favorites: totalFavorites,
|
||||
inquiries: totalInquiries
|
||||
},
|
||||
averages: {
|
||||
price: Math.round(avgPrice),
|
||||
area: Math.round(avgArea),
|
||||
pricePerM2: Math.round(avgPrice / avgArea)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Serve static files and SPA routes
|
||||
app.get('/property/*', serveStatic({ path: './public/property.html' }))
|
||||
app.get('/admin/*', serveStatic({ path: './public/admin.html' }))
|
||||
|
||||
// Serve index.html for all other routes
|
||||
app.get('*', serveStatic({ path: './public/index.html' }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user