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 клиент
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 {}
},
};
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,
const DEFAULT_INIT = {
credentials: 'include',
cache: 'no-store',
mode: 'cors',
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;
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;
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;
} 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 = {
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 } });
Store: {
access: null,
refresh: null,
user: null,
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 } }); },
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 });
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 };

View File

@ -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');