183 lines
8.6 KiB
JavaScript
183 lines
8.6 KiB
JavaScript
|
|
'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/<bot>?start=<code>.
|
|||
|
|
// 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,
|
|||
|
|
};
|
|||
|
|
})();
|