import { FastifyPluginAsync } from 'fastify' import { db } from '../db' import { decryptField } from '../lib/crypto' import crypto from 'crypto' type SlugParam = { Params: { slug: string } } interface WifiSettings { id: string hotelId: string enabled: boolean apiToken: string authMethod: 'room_lastname' | 'room_birthdate' | 'room_any' | 'room_only' ssidName: string | null welcomeText: string | null sessionHours: number } const wifiSettings: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id ?? null } const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug // Ensures a wifi settings row exists for this hotel, returns it const ensureSettings = async (hotelId: string) => { const { rows } = await db.query( `INSERT INTO hotel_wifi_settings (hotel_id) VALUES ($1) ON CONFLICT (hotel_id) DO UPDATE SET updated_at = hotel_wifi_settings.updated_at RETURNING *`, [hotelId], ) return rows[0] } // ── GET /api/hotels/:slug/wifi-settings ──────────────────────────────────── fastify.get( '/api/hotels/:slug/wifi-settings', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const row = await ensureSettings(hotelId) return { id: row.id, hotelId: row.hotel_id, enabled: row.enabled, apiToken: row.api_token, authMethod: row.auth_method, ssidName: row.ssid_name, welcomeText: row.welcome_text, sessionHours: row.session_hours, } satisfies WifiSettings }, ) // ── PATCH /api/hotels/:slug/wifi-settings ────────────────────────────────── fastify.patch> }>( '/api/hotels/:slug/wifi-settings', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) await ensureSettings(hotelId) const b = req.body const sets: string[] = [] const vals: unknown[] = [hotelId] let idx = 2 const add = (col: string, val: unknown) => { if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } } add('enabled', b.enabled) add('auth_method', b.authMethod) add('ssid_name', b.ssidName) add('welcome_text', b.welcomeText) add('session_hours', b.sessionHours) if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' }) sets.push('updated_at = NOW()') const { rows: [row] } = await db.query( `UPDATE hotel_wifi_settings SET ${sets.join(', ')} WHERE hotel_id = $1 RETURNING *`, vals, ) return { id: row.id, hotelId: row.hotel_id, enabled: row.enabled, apiToken: row.api_token, authMethod: row.auth_method, ssidName: row.ssid_name, welcomeText: row.welcome_text, sessionHours: row.session_hours, } }, ) // ── POST /api/hotels/:slug/wifi-settings/regenerate-token ────────────────── fastify.post( '/api/hotels/:slug/wifi-settings/regenerate-token', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) await ensureSettings(hotelId) const newToken = crypto.randomBytes(32).toString('hex') const { rows: [row] } = await db.query( `UPDATE hotel_wifi_settings SET api_token = $2, updated_at = NOW() WHERE hotel_id = $1 RETURNING api_token`, [hotelId, newToken], ) return { apiToken: row.api_token } }, ) } // ── Public: POST /api/wifi-auth/verify ───────────────────────────────────── // Called by wifisync.ru captive portal to verify a guest // Auth: Bearer export const wifiAuthVerify: FastifyPluginAsync = async (fastify) => { fastify.post<{ Body: { room_number: string; last_name?: string; birth_date?: string } }>( '/api/wifi-auth/verify', async (req, reply) => { const authHeader = req.headers.authorization ?? '' if (!authHeader.startsWith('Bearer ')) return reply.code(401).send({ ok: false, error: 'Unauthorized' }) const token = authHeader.slice(7).trim() // Find hotel by token const { rows: [ws] } = await db.query( `SELECT hotel_id, auth_method, session_hours, enabled FROM hotel_wifi_settings WHERE api_token = $1`, [token], ) if (!ws) return reply.code(401).send({ ok: false, error: 'Invalid token' }) if (!ws.enabled) return reply.code(403).send({ ok: false, error: 'WiFi auth is disabled' }) const { room_number, last_name, birth_date } = req.body if (!room_number?.trim()) return reply.code(400).send({ ok: false, error: 'room_number required' }) // Find active booking in this room const today = new Date().toISOString().slice(0, 10) const { rows: bookings } = await db.query( `SELECT b.id, b.guest_id, b.check_out, g.first_name, g.last_name AS last_name_enc, g.birth_date AS birth_date_enc FROM bookings b JOIN rooms r ON r.id = b.room_id LEFT JOIN guests g ON g.id = b.guest_id WHERE b.hotel_id = $1 AND r.number = $2 AND b.status IN ('confirmed', 'checked_in') AND b.check_in <= $3 AND b.check_out >= $3`, [ws.hotel_id, room_number.trim(), today], ) if (bookings.length === 0) { return { ok: false, error: 'Бронирование не найдено. Обратитесь на ресепшн.' } } const method: string = ws.auth_method // room_only — no extra check needed if (method === 'room_only') { const b = bookings[0] return { ok: true, guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null, checkout: b.check_out, sessionHours: ws.session_hours, } } // Check credentials against all bookings in that room for (const b of bookings) { const dbLastName = (decryptField(b.last_name_enc) ?? '').toLowerCase().trim() const dbBirthDate = decryptField(b.birth_date_enc) ?? '' // YYYY-MM-DD const lastNameMatch = last_name && dbLastName && last_name.toLowerCase().trim() === dbLastName const birthDateMatch = birth_date && dbBirthDate && birth_date === dbBirthDate const matches = (method === 'room_lastname' && lastNameMatch) || (method === 'room_birthdate' && birthDateMatch) || (method === 'room_any' && (lastNameMatch || birthDateMatch)) if (matches) { return { ok: true, guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null, checkout: b.check_out, sessionHours: ws.session_hours, } } } return { ok: false, error: 'Неверные данные. Проверьте фамилию или дату рождения.' } }, ) } export default wifiSettings