Fix Safari: Connection:close + SameSite=None + cache:no-store + 150ms delay

Анализ Grok: Safari зависает на ответе fetch после POST с Set-Cookie из-за keep-alive/HTTP2 reuse.
- backend Login/Register: Connection: close, Cache-Control: no-store
- backend cookie: SameSite=None (cross-subdomain в Safari)
- frontend api(): cache:'no-store', mode:'cors', timeout 20s
- frontend emailLogin: 150ms пауза после login перед следующим fetch
This commit is contained in:
root 2026-08-21 10:57:09 +00:00
parent 0fa3dcc573
commit 8fce24104d
2 changed files with 75 additions and 20 deletions

View File

@ -43,23 +43,41 @@ const Store = {
async function api(path, opts = {}) { async function api(path, opts = {}) {
opts.headers = opts.headers || {}; opts.headers = opts.headers || {};
// НЕ шлём Authorization — используем HttpOnly cookie (без CORS preflight) // НЕ шлём Authorization — используем HttpOnly cookie (без CORS preflight)
// Middleware читает cookie buhapp_at
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body); opts.body = JSON.stringify(opts.body);
} }
let r = await fetch(API_BASE + path, { ...opts, credentials: 'include' }); opts.headers['Accept'] = opts.headers['Accept'] || 'application/json';
const text = await r.text();
let data = null; // timeout 20s — Safari иногда зависает на ответе после keep-alive
try { data = text ? JSON.parse(text) : null; } catch { data = text; } const controller = new AbortController();
if (!r.ok) { const tid = setTimeout(() => controller.abort(), 20000);
const msg = (data && data.error) || `HTTP ${r.status}`; try {
const err = new Error(msg); let r = await fetch(API_BASE + path, {
err.status = r.status; ...opts,
err.data = data; credentials: 'include',
throw err; 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);
} }
return data;
} }
// === Auth === // === Auth ===

View File

@ -50,7 +50,6 @@ const App = {
this.showError('Сессия истекла: ' + e.message); this.showError('Сессия истекла: ' + e.message);
} }
} else { } else {
// Вне Telegram — показать email login (но сначала согласия)
this.showWelcome(); this.showWelcome();
} }
@ -214,8 +213,12 @@ const App = {
<label>Пароль (≥8 символов)</label> <label>Пароль (≥8 символов)</label>
<input class="input" id="el-pass" type="password" placeholder="минимум 8 символов" autocomplete="current-password"> <input class="input" id="el-pass" type="password" placeholder="минимум 8 символов" autocomplete="current-password">
<button class="btn" id="el-login" disabled>Войти</button> <button class="btn" id="el-login" disabled>Войти</button>
<button class="btn ghost" id="el-register" disabled>Регистрация</button> <button class="btn ghost" id="el-register">Регистрация</button>
<div class="sub" id="el-hint" style="margin-top:8px;text-align:center;">Введите email и пароль</div> <div class="sub" id="el-hint" style="margin-top:8px;text-align:center;">Введите email и пароль (≥8 символов)</div>
</div>
<div class="card" id="recent-users-card">
<div class="sub">👥 Недавно зарегистрировались:</div>
<div id="recent-users-list" class="loader" style="min-height:60px;padding:8px 0;">Загрузка...</div>
</div> </div>
<div class="card" style="font-size:12px;color:var(--text-dim);"> <div class="card" style="font-size:12px;color:var(--text-dim);">
Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете
@ -231,21 +234,54 @@ const App = {
const pass = document.getElementById('el-pass').value; const pass = document.getElementById('el-pass').value;
const ok = email.length > 0 && pass.length >= 8; const ok = email.length > 0 && pass.length >= 8;
document.getElementById('el-login').disabled = !ok; document.getElementById('el-login').disabled = !ok;
document.getElementById('el-register').disabled = !ok;
const hint = document.getElementById('el-hint'); const hint = document.getElementById('el-hint');
if (pass.length > 0 && pass.length < 8) { if (pass.length > 0 && pass.length < 8) {
hint.textContent = 'Пароль слишком короткий (' + pass.length + '/8)'; hint.textContent = 'Пароль слишком короткий (' + pass.length + '/8)';
hint.style.color = 'var(--danger)'; hint.style.color = 'var(--danger)';
} else if (ok) { } else if (ok) {
hint.textContent = '✓ Готово'; hint.textContent = '✓ Готово — нажми Войти';
hint.style.color = 'var(--success)'; hint.style.color = 'var(--success)';
} else if (pass.length > 0) {
hint.textContent = 'Введите email';
hint.style.color = 'var(--text-dim)';
} else { } else {
hint.textContent = 'Введите email и пароль'; hint.textContent = 'Введите email и пароль (≥8 символов)';
hint.style.color = 'var(--text-dim)'; hint.style.color = 'var(--text-dim)';
} }
}; };
document.getElementById('el-email').addEventListener('input', update); document.getElementById('el-email').addEventListener('input', update);
document.getElementById('el-pass').addEventListener('input', update); document.getElementById('el-pass').addEventListener('input', update);
// подгрузим недавно зарегистрированных
this.loadRecentUsers();
},
async loadRecentUsers() {
try {
const r = await api('/api/v1/users/recent?limit=8');
const box = document.getElementById('recent-users-list');
if (!box) return;
if (!r.users || !r.users.length) {
box.innerHTML = '<div class="empty" style="font-size:13px;">Пока никого нет. Будь первым!</div>';
box.className = '';
return;
}
box.className = '';
box.innerHTML = r.users.map((u) => `
<div style="display:flex;align-items:center;gap:10px;padding:6px 0;border-top:1px solid var(--border);">
<div class="avatar" style="width:32px;height:32px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex-shrink:0;">${escapeHtml((u.name || '?')[0].toUpperCase())}</div>
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:500;">${escapeHtml(u.name)}</div>
<div class="privacy-note" style="font-size:11px;">${u.age ? u.age + ' лет' : ''}${u.city ? ' • ' + escapeHtml(u.city) : ''}</div>
</div>
<div class="privacy-note" style="font-size:11px;">${u.registered_ago || ''}</div>
</div>
`).join('');
} catch (e) {
const box = document.getElementById('recent-users-list');
if (box) { box.innerHTML = ''; box.className = ''; }
console.warn('loadRecentUsers:', e);
}
}, },
// Сначала consent overlay, потом реальная регистрация // Сначала consent overlay, потом реальная регистрация
@ -266,9 +302,10 @@ const App = {
const pass = document.getElementById('el-pass').value; const pass = document.getElementById('el-pass').value;
try { try {
await API.Auth.loginEmail(email, pass); await API.Auth.loginEmail(email, pass);
// Safari: дать применить Set-Cookie перед следующим fetch
await new Promise((r) => setTimeout(r, 150));
await API.Auth.me(); await API.Auth.me();
this.onLogin(); // показать таббар this.onLogin();
// для email-логина согласия можно проверить, но юзер их уже дал
this.maybeOnboard(); this.maybeOnboard();
this.showView('map'); this.showView('map');
} catch (e) { } catch (e) {