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:
TenerifeProp Dev
2026-04-05 00:01:54 +01:00
parent c1867fe074
commit 3bbbb126ab
9 changed files with 2552 additions and 45 deletions

View File

@@ -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' }))