- Backend: hotel_wifi_settings table (migration 046), CRUD routes - Public API endpoint /api/wifi-auth/verify for captive portal - Auth methods: room+lastname, room+birthdate, room+any, room_only - Frontend: WiFiPage with settings, token management, setup guide - Guide tabs: MikroTik, UniFi, Universal with copy-paste instructions - Sidebar: WiFi item in Settings group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
227 lines
8.3 KiB
TypeScript
227 lines
8.3 KiB
TypeScript
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<string | null> => {
|
|
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<SlugParam>(
|
|
'/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<SlugParam & { Body: Partial<Omit<WifiSettings, 'id' | 'hotelId' | 'apiToken'>> }>(
|
|
'/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<SlugParam>(
|
|
'/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 <api_token from hotel_wifi_settings>
|
|
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
|