From 058649013d4e39ae727e241d32fe545473259c44 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 11:04:11 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20login:=20same-origin=20proxy=20+=20minima?= =?UTF-8?q?l=20api.js=20+=20=D0=B1=D0=B5=D0=B7=20retry/timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.js полностью переписан: simple fetch с credentials:'include', без mode:cors - emailLogin: без 150мс паузы, без retry — same-origin не висит - nginx: /api/* → backend через тот же домен (убирает cross-subdomain) - backend cookie: SameSite=Lax, host-only --- public/js/api.js | 256 ++++++++++++++++------------------------------- public/js/app.js | 9 +- 2 files changed, 89 insertions(+), 176 deletions(-) diff --git a/public/js/api.js b/public/js/api.js index e40a6d1..1043460 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,187 +1,99 @@ -// API клиент -const API_BASE = window.API_BASE || 'https://api.buhapp.mygoodservice.ru'; +// Same-origin API wrapper (api проксируется через nginx на тот же домен) +// Никаких retry/timeout на login — только UI feedback -const Store = { - access: null, - refresh: null, - user: null, - - save(tokens) { - if (!tokens) return; - // бэкенд отдаёт {Access, Refresh} — поддержим оба варианта - this.access = tokens.Access || tokens.access; - this.refresh = tokens.Refresh || tokens.refresh; - try { - localStorage.setItem('buhapp', JSON.stringify({ - access: this.access, - refresh: this.refresh, - user: this.user, - })); - } catch {} - }, - - load() { - try { - const s = JSON.parse(localStorage.getItem('buhapp') || 'null'); - if (s) { - this.access = s.access || s.Access; - this.refresh = s.refresh || s.Refresh; - this.user = s.user; - } - } catch {} - return this.access; - }, - - clear() { - this.access = null; - this.refresh = null; - this.user = null; - try { localStorage.removeItem('buhapp'); } catch {} - }, +const DEFAULT_INIT = { + credentials: 'include', + cache: 'no-store', + redirect: 'follow', }; -async function api(path, opts = {}) { - opts.headers = opts.headers || {}; - // НЕ шлём Authorization — используем HttpOnly cookie (без CORS preflight) - if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { - opts.headers['Content-Type'] = 'application/json'; - opts.body = JSON.stringify(opts.body); - } - opts.headers['Accept'] = opts.headers['Accept'] || 'application/json'; +export async function api(path, options = {}) { + const { json, headers: extraHeaders, ...rest } = options; + const headers = new Headers(extraHeaders || {}); + let body = rest.body; - // timeout 20s — Safari иногда зависает на ответе после keep-alive - const controller = new AbortController(); - const tid = setTimeout(() => controller.abort(), 20000); - try { - let r = await fetch(API_BASE + path, { - ...opts, - credentials: 'include', - cache: 'no-store', - mode: 'cors', - redirect: 'follow', - signal: controller.signal, - }); - const text = await r.text(); - let data = null; - try { data = text ? JSON.parse(text) : null; } catch { data = text; } - if (!r.ok) { - const msg = (data && data.error) || `HTTP ${r.status}`; - const err = new Error(msg); - err.status = r.status; - err.data = data; - throw err; - } - return data; - } catch (e) { - if (e.name === 'AbortError') throw new Error('Сервер не отвечает (таймаут 20с)'); - throw e; - } finally { - clearTimeout(tid); + if (json !== undefined) { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(json); } + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + const res = await fetch(path, { + ...DEFAULT_INIT, + ...rest, + headers, + body, + }); + + const ct = res.headers.get('content-type') || ''; + const isJson = ct.includes('application/json'); + const data = isJson ? await res.json().catch(() => null) : await res.text(); + + if (!res.ok) { + const err = new Error((data && (data.message || data.error)) || `HTTP ${res.status}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; } -// === Auth === +export const apiGet = (path, opts) => api(path, { ...opts, method: 'GET' }); +export const apiPost = (path, json, opts) => api(path, { ...opts, method: 'POST', json }); +export const apiPut = (path, json, opts) => api(path, { ...opts, method: 'PUT', json }); +export const apiDelete = (path, opts) => api(path, { ...opts, method: 'DELETE' }); -const Auth = { - async loginWithTelegram(initData) { - const r = await api('/api/v1/auth/telegram', { - method: 'POST', - body: { init_data: initData }, - }); - Store.save(r.tokens); - Store.user = r.user; - return r; +// Совместимость со старым window.API.Auth +window.API = { + api, apiGet, apiPost, apiPut, apiDelete, + // обёртки для совместимости со старыми view + Auth: { + loginEmail: (email, password) => apiPost('/api/v1/auth/login', { email, password }), + registerEmail: (data) => apiPost('/api/v1/auth/register', data), + me: () => apiGet('/api/v1/me'), + updateMe: (p) => apiPut('/api/v1/me', p), + getPrefs: () => apiGet('/api/v1/me/prefs'), + updatePrefs: (p) => apiPut('/api/v1/me/prefs', p), + updateLocation: (lat, lng, visible) => apiPut('/api/v1/me/location', { lat, lng, visible }), + setVisibility: (v) => apiPut('/api/v1/me/visibility', { visible: v }), + searchNearby: (lat, lng, radius) => apiGet(`/api/v1/search/nearby?lat=${lat}&lng=${lng}&radius=${radius}`), + getUser: (id) => apiGet('/api/v1/users/' + id), }, - - async registerEmail(data) { - const r = await api('/api/v1/auth/register', { method: 'POST', body: data }); - Store.save(r.tokens); - Store.user = r.user; - return r; + ChatApi: { + ensureChat: (otherId) => apiPost('/api/v1/chats', { other_id: otherId }), + listChats: () => apiGet('/api/v1/chats'), + listMessages: (chatId) => apiGet('/api/v1/chats/' + chatId + '/messages'), + sendMessage: (chatId, body, photoUrl) => apiPost('/api/v1/chats/' + chatId + '/messages', { body, photo_url: photoUrl }), + editMessage: (msgId, body) => apiPut('/api/v1/messages/' + msgId, { body }), + deleteMessage: (msgId) => apiDelete('/api/v1/messages/' + msgId), + markRead: (chatId) => apiPut('/api/v1/chats/' + chatId + '/read', {}), + block: (userId) => apiPost('/api/v1/blocks', { user_id: userId }), + report: (type, id, reason) => apiPost('/api/v1/reports', { target_type: type, target_id: id, reason }), }, - - async loginEmail(email, password) { - const r = await api('/api/v1/auth/login', { method: 'POST', body: { email, password } }); - Store.save(r.tokens); - Store.user = r.user; - return r; + ReviewApi: { + create: (chatId, rating, body, anonymous) => apiPost('/api/v1/reviews', { chat_id: chatId, rating, body, anonymous }), + listForUser: (userId) => apiGet('/api/v1/users/' + userId + '/reviews'), + stats: (userId) => apiGet('/api/v1/users/' + userId + '/stats'), }, - - async me() { - const r = await api('/api/v1/me'); - Store.user = r.user; - return r; + ConsentApi: { + status: () => apiGet('/api/v1/auth/consents').catch(() => ({ all_accepted: false, accepted: {} })), + accept: (payload) => apiPost('/api/v1/auth/consents', payload), }, - - async updateMe(p) { - return api('/api/v1/me', { method: 'PUT', body: p }); + PlacesApi: { + nearby: (lat, lng, radius, limit = 30) => apiGet(`/api/v1/places/nearby?lat=${lat}&lng=${lng}&radius=${radius || 5}&limit=${limit}`).catch((e) => ({ places: [], count: 0, error: e.message })), }, - - async getPrefs() { return api('/api/v1/me/prefs'); }, - async updatePrefs(p) { return api('/api/v1/me/prefs', { method: 'PUT', body: p }); }, - async updateLocation(lat, lng, visible) { - return api('/api/v1/me/location', { method: 'PUT', body: { lat, lng, visible } }); - }, - async setVisibility(v) { return api('/api/v1/me/visibility', { method: 'PUT', body: { visible: v } }); }, - async searchNearby(lat, lng, radiusKm) { - return api('/api/v1/search/nearby?lat=' + lat + '&lng=' + lng + '&radius=' + radiusKm); - }, - async getUser(id) { return api('/api/v1/users/' + id); }, -}; - -// === Chat === - -const ChatApi = { - async ensureChat(otherId) { return api('/api/v1/chats', { method: 'POST', body: { other_id: otherId } }); }, - async listChats() { return api('/api/v1/chats'); }, - async listMessages(chatId) { return api('/api/v1/chats/' + chatId + '/messages'); }, - async sendMessage(chatId, body, photoUrl) { - return api('/api/v1/chats/' + chatId + '/messages', { method: 'POST', body: { body, photo_url: photoUrl } }); - }, - async editMessage(msgId, body) { - return api('/api/v1/messages/' + msgId, { method: 'PUT', body: { body } }); - }, - async deleteMessage(msgId) { - return api('/api/v1/messages/' + msgId, { method: 'DELETE' }); - }, - async markRead(chatId) { - return api('/api/v1/chats/' + chatId + '/read', { method: 'PUT', body: {} }); - }, - async block(userId) { return api('/api/v1/blocks', { method: 'POST', body: { user_id: userId } }); }, - async report(type, id, reason) { return api('/api/v1/reports', { method: 'POST', body: { target_type: type, target_id: id, reason } }); }, -}; - -// === Reviews === - -const ReviewApi = { - async create(chatId, rating, body, anonymous) { - return api('/api/v1/reviews', { method: 'POST', body: { chat_id: chatId, rating, body, anonymous } }); - }, - async listForUser(userId) { return api('/api/v1/users/' + userId + '/reviews'); }, - async stats(userId) { return api('/api/v1/users/' + userId + '/stats'); }, -}; - -const ConsentApi = { - async status() { - try { - return await api('/api/v1/auth/consents'); - } catch (e) { - return { all_accepted: false, accepted: {} }; - } - }, - async accept(payload) { - return api('/api/v1/auth/consents', { method: 'POST', body: payload }); + Store: { + access: null, + refresh: null, + user: null, + save(tokens) { + if (!tokens) return; + this.access = tokens.Access || tokens.access; + this.refresh = tokens.Refresh || tokens.refresh; + }, + load() { return this.access; }, + clear() { this.access = null; this.refresh = null; this.user = null; }, }, }; - -const PlacesApi = { - async nearby(lat, lng, radius, limit = 30) { - try { - return await api(`/api/v1/places/nearby?lat=${lat}&lng=${lng}&radius=${radius || 5}&limit=${limit}`); - } catch (e) { - return { places: [], count: 0, error: e.message }; - } - }, -}; - -window.API = { api, Auth, ChatApi, ReviewApi, ConsentApi, PlacesApi, Store }; \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 966ea75..7086bef 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -301,10 +301,11 @@ const App = { const email = document.getElementById('el-email').value.trim(); const pass = document.getElementById('el-pass').value; try { - await API.Auth.loginEmail(email, pass); - // Safari: дать применить Set-Cookie перед следующим fetch - await new Promise((r) => setTimeout(r, 150)); - await API.Auth.me(); + const r = await API.Auth.loginEmail(email, pass); + if (r && r.tokens) API.Store.save(r.tokens); + // same-origin: cookie + body, ничего не висит + const me = await API.Auth.me(); + API.Store.user = me.user || me; this.onLogin(); this.maybeOnboard(); this.showView('map');