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:
parent
8fce24104d
commit
058649013d
246
public/js/api.js
246
public/js/api.js
@ -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 };
|
|
||||||
@ -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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user