'use strict'; // api.js — обёртка над HTTP к бэкенду: хранение сессии, тихий refresh, // единый формат ошибок. Никакой бизнес-логики — только транспорт. const API = (() => { // Пустой BASE = API на том же origin, что и страница: nginx (см. // nginx.conf) проксирует API-пути на бэкенд. Фронту не нужны CORS и // отдельный порт — один домен и локально, и в проде. const BASE = ''; const STORE = 'building-mgmt.session'; // Ошибка API: status + короткий `error` из ответа (или HTTP N). class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } } function load() { try { return JSON.parse(localStorage.getItem(STORE)); } catch (e) { return null; } } function save(sess) { localStorage.setItem(STORE, JSON.stringify(sess)); } function clear() { localStorage.removeItem(STORE); } // Текущий профиль или null (не залогинен). function user() { const s = load(); return s ? s.user : null; } function session() { return load(); } // Запомнить результат входа/обновления: в localStorage живут только // access-токен, срок и профиль. Refresh-токен сюда никогда не попадает — // он в httpOnly-куке, из JavaScript недоступен (защита от XSS). function store(data) { save(Object.assign({}, data, { expires_at: Date.now() + data.expires_in * 1000 })); } async function login(email, password) { const data = await request('POST', '/auth/login', { email, password }, { auth: false }); store(data); return data.user; } // «Забыли пароль»: сервер всегда отвечает одинаково ({ ok: true }) — // ответ не перечисляет существующие ящики. 429 — лимит попыток (IP // плюс 3 письма в сутки на один адрес). async function forgotPassword(email) { return request('POST', '/auth/forgot-password', { email }, { auth: false }); } // Сброс по токену из письма. 400 — токен неизвестен, уже // использован или истёк; слабый пароль тоже 400 с тем же кодом. async function resetPassword(token, password) { return request('POST', '/auth/reset-password', { token, password }, { auth: false }); } // --- Telegram: уведомления о заявках, не взятых в работу за 24 часа --- // Состояние своей привязки: enabled (настроен ли бот), bound/target, // имя бота для ссылки. Чужим это поле сервер не отдаёт. const tgStatus = () => get('/auth/telegram'); // Одноразовый код привязки + ссылка t.me/?start=. // 400 — бот не настроен, 429 — лимит (5 кодов за 5 минут). const tgCode = () => post('/auth/telegram/code', {}); // Ручная цель (@группа или chat_id); пустая строка — отключить. // 400 — формат не тот либо бот не видит чат. const tgSet = (target) => request('PUT', '/auth/telegram', { target }); // То же, что tgSet(''): убрать цель и перестать слать уведомления. const tgUnbind = () => del('/auth/telegram'); // Свой бот: токен от @BotFather. Сервер проверяет его через getMe, // шифрует и запускает поллер. 400 — нет DATA_ENCRYPTION_KEY / формат // не тот / getMe не принял, 429 — лимит попыток (5 за 5 минут). const tgSetBot = (token) => post('/auth/telegram/bot', { token }); // Отключить своего бота: токен стирается, поллер гасится, дальше // уведомления (если цель осталась) пойдут через бота сервера. const tgDelBot = () => del('/auth/telegram/bot'); // Выход: гасим сессию на сервере (не даём ошибке сбить локальный выход). // Тело не нужно: сервер берёт refresh-токен из куки и очищает её. async function logout() { clear(); try { await request('POST', '/auth/logout', null, { auth: false, noRetry: true }); } catch (e) { /* локально уже вышли — серверная ошибка не мешает */ } } // Принудительный refresh (когда access-токен протух). Токен в теле // не шлём — сервер читает refresh-куку (same-origin, кука уходит сама). async function refreshNow() { const s = load(); if (!s) throw new ApiError(401, 'no token'); const data = await request('POST', '/auth/refresh', null, { auth: false, noRetry: true }); store(data); return data; } // Тихий refresh до запроса, если токен вот-вот протухнет. async function ensureFresh() { const s = load(); if (!s) throw new ApiError(401, 'no token'); if (s.expires_at - Date.now() > 15000) return; await refreshNow(); } // request(method, path, body, opts) — основной вызов. // opts.auth — слать ли Authorization (по умолчанию да) // opts.noRetry — не пробовать refresh при 401 async function request(method, path, body, opts) { opts = opts || {}; const withAuth = opts.auth !== false; if (withAuth) await ensureFresh(); // FormData (multipart, загрузка фото) не оборачиваем в JSON и не // задаём Content-Type сами: браузер сам поставит boundary. const isForm = (typeof FormData !== 'undefined') && (body instanceof FormData); const headers = {}; if (!isForm) headers['Content-Type'] = 'application/json'; if (withAuth) { const s = load(); if (s) headers.Authorization = 'Bearer ' + s.token; } let res; try { res = await fetch(BASE + path, { method, headers, body: (body === undefined || body === null) ? undefined : (isForm ? body : JSON.stringify(body)), // Куки (refresh-токен, Path=/auth) уходят только на своих — // same-origin и есть поведение по умолчанию, но помечаем явно: // вся сессия держится на этой куке. credentials: 'same-origin', }); } catch (e) { // Это не ответ API, а обрыв связи (упал сервер/прокси): сырой // "Failed to fetch" пользователю ничего не говорит. throw new ApiError(0, 'сервер недоступен, попробуйте ещё раз'); } if (res.status === 204) return null; const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } } if (!res.ok) { // 401 при живой сессии — пробуем refresh ровно один раз. if (res.status === 401 && withAuth && !opts.noRetry && !opts.retried) { try { await refreshNow(); } catch (e) { clear(); // сессия мертва — на экран входа throw new ApiError(401, 'no token'); } return request(method, path, body, Object.assign({}, opts, { retried: true })); } throw new ApiError(res.status, (data && data.error) || ('HTTP ' + res.status)); } return data; } const get = (path) => request('GET', path); const post = (path, body) => request('POST', path, body); const patch = (path, body) => request('PATCH', path, body); const del = (path) => request('DELETE', path); // Загрузка файла: тело — FormData, остальное (refresh, формат ошибок) // — те же пути, что у request. const upload = (path, formData) => request('POST', path, formData); return { user, login, forgotPassword, resetPassword, logout, tgStatus, tgCode, tgSet, tgUnbind, tgSetBot, tgDelBot, get, post, patch, del, upload, }; })();