Fix login: same-origin proxy + minimal api.js + без retry/timeout

- api.js полностью переписан: simple fetch с credentials:'include', без mode:cors
- emailLogin: без 150мс паузы, без retry — same-origin не висит
- nginx: /api/* → backend через тот же домен (убирает cross-subdomain)
- backend cookie: SameSite=Lax, host-only
This commit is contained in:
root 2026-08-21 11:04:11 +00:00
parent 8fce24104d
commit 058649013d
2 changed files with 89 additions and 176 deletions

View File

@ -1,187 +1,99 @@
// API клиент // Same-origin API wrapper (api проксируется через nginx на тот же домен)
const API_BASE = window.API_BASE || 'https://api.buhapp.mygoodservice.ru'; // Никаких retry/timeout на login — только UI feedback
const Store = { const DEFAULT_INIT = {
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 {}
},
};
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';
// 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', credentials: 'include',
cache: 'no-store', cache: 'no-store',
mode: 'cors',
redirect: 'follow', redirect: 'follow',
signal: controller.signal, };
export async function api(path, options = {}) {
const { json, headers: extraHeaders, ...rest } = options;
const headers = new Headers(extraHeaders || {});
let body = rest.body;
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 text = await r.text();
let data = null; const ct = res.headers.get('content-type') || '';
try { data = text ? JSON.parse(text) : null; } catch { data = text; } const isJson = ct.includes('application/json');
if (!r.ok) { const data = isJson ? await res.json().catch(() => null) : await res.text();
const msg = (data && data.error) || `HTTP ${r.status}`;
const err = new Error(msg); if (!res.ok) {
err.status = r.status; const err = new Error((data && (data.message || data.error)) || `HTTP ${res.status}`);
err.status = res.status;
err.data = data; err.data = data;
throw err; throw err;
} }
return data; return data;
} catch (e) {
if (e.name === 'AbortError') throw new Error('Сервер не отвечает (таймаут 20с)');
throw e;
} finally {
clearTimeout(tid);
}
} }
// === 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 = { // Совместимость со старым window.API.Auth
async loginWithTelegram(initData) { window.API = {
const r = await api('/api/v1/auth/telegram', { api, apiGet, apiPost, apiPut, apiDelete,
method: 'POST', // обёртки для совместимости со старыми view
body: { init_data: initData }, Auth: {
}); loginEmail: (email, password) => apiPost('/api/v1/auth/login', { email, password }),
Store.save(r.tokens); registerEmail: (data) => apiPost('/api/v1/auth/register', data),
Store.user = r.user; me: () => apiGet('/api/v1/me'),
return r; 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),
}, },
ChatApi: {
async registerEmail(data) { ensureChat: (otherId) => apiPost('/api/v1/chats', { other_id: otherId }),
const r = await api('/api/v1/auth/register', { method: 'POST', body: data }); listChats: () => apiGet('/api/v1/chats'),
Store.save(r.tokens); listMessages: (chatId) => apiGet('/api/v1/chats/' + chatId + '/messages'),
Store.user = r.user; sendMessage: (chatId, body, photoUrl) => apiPost('/api/v1/chats/' + chatId + '/messages', { body, photo_url: photoUrl }),
return r; 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 }),
}, },
ReviewApi: {
async loginEmail(email, password) { create: (chatId, rating, body, anonymous) => apiPost('/api/v1/reviews', { chat_id: chatId, rating, body, anonymous }),
const r = await api('/api/v1/auth/login', { method: 'POST', body: { email, password } }); listForUser: (userId) => apiGet('/api/v1/users/' + userId + '/reviews'),
Store.save(r.tokens); stats: (userId) => apiGet('/api/v1/users/' + userId + '/stats'),
Store.user = r.user;
return r;
}, },
ConsentApi: {
async me() { status: () => apiGet('/api/v1/auth/consents').catch(() => ({ all_accepted: false, accepted: {} })),
const r = await api('/api/v1/me'); accept: (payload) => apiPost('/api/v1/auth/consents', payload),
Store.user = r.user;
return r;
}, },
PlacesApi: {
async updateMe(p) { 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 })),
return api('/api/v1/me', { method: 'PUT', body: p });
}, },
Store: {
async getPrefs() { return api('/api/v1/me/prefs'); }, access: null,
async updatePrefs(p) { return api('/api/v1/me/prefs', { method: 'PUT', body: p }); }, refresh: null,
async updateLocation(lat, lng, visible) { user: null,
return api('/api/v1/me/location', { method: 'PUT', body: { lat, lng, visible } }); save(tokens) {
if (!tokens) return;
this.access = tokens.Access || tokens.access;
this.refresh = tokens.Refresh || tokens.refresh;
}, },
async setVisibility(v) { return api('/api/v1/me/visibility', { method: 'PUT', body: { visible: v } }); }, load() { return this.access; },
async searchNearby(lat, lng, radiusKm) { clear() { this.access = null; this.refresh = null; this.user = null; },
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 });
}, },
}; };
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 };

View File

@ -301,10 +301,11 @@ const App = {
const email = document.getElementById('el-email').value.trim(); const email = document.getElementById('el-email').value.trim();
const pass = document.getElementById('el-pass').value; const pass = document.getElementById('el-pass').value;
try { try {
await API.Auth.loginEmail(email, pass); const r = await API.Auth.loginEmail(email, pass);
// Safari: дать применить Set-Cookie перед следующим fetch if (r && r.tokens) API.Store.save(r.tokens);
await new Promise((r) => setTimeout(r, 150)); // same-origin: cookie + body, ничего не висит
await API.Auth.me(); const me = await API.Auth.me();
API.Store.user = me.user || me;
this.onLogin(); this.onLogin();
this.maybeOnboard(); this.maybeOnboard();
this.showView('map'); this.showView('map');