feat: sunucu tarafı hesap altyapısı — Stripe webhook, kotalar, gerçek API anahtarı ve belge deposu
Denetlenen 5 teknik borç kalemi de kapatıldı:
1) Stripe webhook + gerçek yetkilendirme (ÖNCEDEN YOKTU)
- POST /api/billing/webhook — SDK'sız HMAC-SHA256 imza doğrulaması,
tekrar saldırısına karşı ±300sn zaman penceresi, timingSafeEqual
- checkout.session.completed / subscription.created|updated|deleted işlenir
- Ödeme → kullanıcının planı sunucuda güncellenir (önce ödeme hiçbir şeyi
değiştirmiyordu)
2) Plan kotaları GERÇEKTEN uygulanıyor (önce sadece tanımlıydı)
- server/entitlements.mjs: plan hakları + kotaKontrol()
- ekip, belge adedi/boyutu, API anahtarı kotaları sunucuda zorlanır
- Kota aşımında 402 + anlaşılır Türkçe mesaj; arayüzde KotaUyarisi bandı
- /api/me/plan: plan, haklar ve gerçek kullanım
3) Kullanıcı verisi localStorage'dan sunucuya taşındı
- server/user-store.mjs: kullanici_verisi tablosu (izinli anahtar listesi)
- AppContext girişte sunucudan yükler, değişiklikleri sunucuya yazar;
localStorage yalnızca çevrimdışı önbellek
- Artık cihaz değişince veri kaybolmuyor
4) API anahtarları gerçek (önce Math.random ile tarayıcıda üretiliyordu)
- Sunucuda crypto.randomBytes ile üretilir, YALNIZCA sha256 hash'i saklanır
- Ham değer bir kez gösterilir; verifyApiKey ile doğrulanır, son kullanım
izlenir, iptal anında geçerli olur
5) Belge deposu gerçek (önce boyut Math.random'dı, dosya hiç yüklenmiyordu)
- Dosyalar kalıcı volume'e yazılır, boyut GERÇEK bayt
- Auth'lu indirme (blob), silme diskten de siler, kota sunucuda uygulanır
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Sunucu tarafı kullanıcı deposu — plan, abonelik, kullanıcı verisi,
|
||||
* API anahtarları ve belgeler. (Önceden bunların hepsi localStorage'daydı;
|
||||
* cihaz değişince kayboluyor, sunucu tarafında doğrulanamıyordu.)
|
||||
*
|
||||
* Kalıcı volume: server/data/users.sqlite
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
let DatabaseSync = null;
|
||||
try {
|
||||
({ DatabaseSync } = await import('node:sqlite'));
|
||||
} catch {
|
||||
console.warn('[user-store] node:sqlite yok — sunucu tarafı kullanıcı verisi devre dışı');
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'users.sqlite');
|
||||
export const BELGE_DIR = path.join(DATA_DIR, 'belgeler');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS kullanicilar (
|
||||
email TEXT PRIMARY KEY,
|
||||
ad TEXT,
|
||||
plan TEXT NOT NULL DEFAULT 'ucretsiz',
|
||||
plan_kaynak TEXT, -- 'stripe' | 'manuel' | 'deneme'
|
||||
plan_bitis TEXT,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_sub_id TEXT,
|
||||
olusturma TEXT,
|
||||
son_giris TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_cus ON kullanicilar(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_sub ON kullanicilar(stripe_sub_id);
|
||||
|
||||
-- localStorage yerine: favoriler, ekip, filtreler, ayarlar…
|
||||
CREATE TABLE IF NOT EXISTS kullanici_verisi (
|
||||
email TEXT NOT NULL,
|
||||
anahtar TEXT NOT NULL,
|
||||
deger TEXT,
|
||||
guncelleme TEXT,
|
||||
PRIMARY KEY (email, anahtar)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_anahtarlari (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
ad TEXT,
|
||||
onek TEXT NOT NULL, -- gösterim için ilk 12 karakter
|
||||
hash TEXT NOT NULL, -- sha256(anahtar) — ham anahtar SAKLANMAZ
|
||||
olusturma TEXT,
|
||||
son_kullanim TEXT,
|
||||
iptal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_hash ON api_anahtarlari(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_email ON api_anahtarlari(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS belgeler (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
ad TEXT NOT NULL,
|
||||
kategori TEXT,
|
||||
boyut INTEGER NOT NULL, -- GERÇEK bayt
|
||||
mime TEXT,
|
||||
dosya TEXT NOT NULL, -- diskteki dosya adı
|
||||
son_kullanma TEXT,
|
||||
olusturma TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_belge_email ON belgeler(email);
|
||||
`;
|
||||
|
||||
export function available() { return Boolean(DatabaseSync); }
|
||||
|
||||
export function openDb() {
|
||||
if (!DatabaseSync) return null;
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(BELGE_DIR, { recursive: true });
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const withDb = (fn, fallback = null) => {
|
||||
const db = openDb();
|
||||
if (!db) return fallback;
|
||||
try { return fn(db); }
|
||||
catch (e) { console.warn('[user-store]', e.message); return fallback; }
|
||||
finally { db.close(); }
|
||||
};
|
||||
|
||||
// --- Kullanıcı / plan ------------------------------------------------------
|
||||
|
||||
export function ensureUser(email, ad = null) {
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanicilar (email, ad, plan, olusturma, son_giris)
|
||||
VALUES (?,?, 'ucretsiz', ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET son_giris=excluded.son_giris,
|
||||
ad=COALESCE(excluded.ad, kullanicilar.ad)`)
|
||||
.run(email, ad, now(), now());
|
||||
return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email);
|
||||
});
|
||||
}
|
||||
|
||||
export function getUser(email) {
|
||||
return withDb((db) => db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email) || null);
|
||||
}
|
||||
|
||||
/** Planı doğrudan ayarlar (webhook veya yönetici). */
|
||||
export function setPlan(email, { plan, kaynak = 'stripe', bitis = null, customerId = null, subId = null, ad = null }) {
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanicilar (email, ad, plan, plan_kaynak, plan_bitis, stripe_customer_id, stripe_sub_id, olusturma, son_giris)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
plan=excluded.plan, plan_kaynak=excluded.plan_kaynak, plan_bitis=excluded.plan_bitis,
|
||||
stripe_customer_id=COALESCE(excluded.stripe_customer_id, kullanicilar.stripe_customer_id),
|
||||
stripe_sub_id=COALESCE(excluded.stripe_sub_id, kullanicilar.stripe_sub_id),
|
||||
ad=COALESCE(excluded.ad, kullanicilar.ad)`)
|
||||
.run(email, ad, plan, kaynak, bitis, customerId, subId, now(), now());
|
||||
return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email);
|
||||
});
|
||||
}
|
||||
|
||||
export function findByStripe({ customerId, subId }) {
|
||||
return withDb((db) => {
|
||||
if (subId) {
|
||||
const r = db.prepare('SELECT * FROM kullanicilar WHERE stripe_sub_id=?').get(subId);
|
||||
if (r) return r;
|
||||
}
|
||||
if (customerId) return db.prepare('SELECT * FROM kullanicilar WHERE stripe_customer_id=?').get(customerId) || null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine) --------------------------------
|
||||
|
||||
const IZINLI_ANAHTARLAR = new Set([
|
||||
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder',
|
||||
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
|
||||
]);
|
||||
|
||||
export function getData(email, anahtar) {
|
||||
if (!IZINLI_ANAHTARLAR.has(anahtar)) return null;
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT deger FROM kullanici_verisi WHERE email=? AND anahtar=?').get(email, anahtar);
|
||||
if (!r?.deger) return null;
|
||||
try { return JSON.parse(r.deger); } catch { return null; }
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllData(email) {
|
||||
return withDb((db) => {
|
||||
const rows = db.prepare('SELECT anahtar, deger FROM kullanici_verisi WHERE email=?').all(email);
|
||||
const out = {};
|
||||
for (const r of rows) {
|
||||
try { out[r.anahtar] = JSON.parse(r.deger); } catch { /* bozuk kayıt atlanır */ }
|
||||
}
|
||||
return out;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function setData(email, anahtar, deger) {
|
||||
if (!IZINLI_ANAHTARLAR.has(anahtar)) throw new Error(`Bilinmeyen veri anahtarı: ${anahtar}`);
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanici_verisi (email, anahtar, deger, guncelleme) VALUES (?,?,?,?)
|
||||
ON CONFLICT(email, anahtar) DO UPDATE SET deger=excluded.deger, guncelleme=excluded.guncelleme`)
|
||||
.run(email, anahtar, JSON.stringify(deger ?? null), now());
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
// --- API anahtarları (gerçek, hash'li) -------------------------------------
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||
|
||||
/** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */
|
||||
export function createApiKey(email, ad) {
|
||||
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
|
||||
const id = crypto.randomUUID();
|
||||
withDb((db) => db.prepare(
|
||||
'INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)'
|
||||
).run(id, email, ad || 'API Anahtarı', ham.slice(0, 12), sha256(ham), now()));
|
||||
return { id, ad: ad || 'API Anahtarı', onek: ham.slice(0, 12), anahtar: ham, olusturma: now() };
|
||||
}
|
||||
|
||||
export function listApiKeys(email) {
|
||||
return withDb((db) => db.prepare(
|
||||
'SELECT id, ad, onek, olusturma, son_kullanim, iptal FROM api_anahtarlari WHERE email=? AND iptal=0 ORDER BY olusturma DESC'
|
||||
).all(email), []);
|
||||
}
|
||||
|
||||
export function revokeApiKey(email, id) {
|
||||
return withDb((db) => db.prepare('UPDATE api_anahtarlari SET iptal=1 WHERE id=? AND email=?').run(id, email).changes > 0, false);
|
||||
}
|
||||
|
||||
/** Ham anahtarı doğrular; geçerliyse sahibini döner. */
|
||||
export function verifyApiKey(ham) {
|
||||
if (!ham || !String(ham).startsWith('ih_live_')) return null;
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT * FROM api_anahtarlari WHERE hash=? AND iptal=0').get(sha256(ham));
|
||||
if (!r) return null;
|
||||
db.prepare('UPDATE api_anahtarlari SET son_kullanim=? WHERE id=?').run(now(), r.id);
|
||||
return { email: r.email, id: r.id, ad: r.ad };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Belgeler (gerçek dosya, gerçek boyut) ---------------------------------
|
||||
|
||||
export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma }) {
|
||||
const id = crypto.randomUUID();
|
||||
withDb((db) => db.prepare(
|
||||
'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,olusturma) VALUES (?,?,?,?,?,?,?,?,?)'
|
||||
).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, now()));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function listDocuments(email) {
|
||||
return withDb((db) => db.prepare(
|
||||
'SELECT id, ad, kategori, boyut, mime, son_kullanma, olusturma FROM belgeler WHERE email=? ORDER BY olusturma DESC'
|
||||
).all(email), []);
|
||||
}
|
||||
|
||||
export function getDocument(email, id) {
|
||||
return withDb((db) => db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id, email) || null);
|
||||
}
|
||||
|
||||
export function deleteDocument(email, id) {
|
||||
const rec = getDocument(email, id);
|
||||
if (!rec) return false;
|
||||
try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } catch { /* dosya zaten yok */ }
|
||||
return withDb((db) => db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id, email).changes > 0, false);
|
||||
}
|
||||
|
||||
export function documentUsage(email) {
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT COUNT(*) adet, COALESCE(SUM(boyut),0) bayt FROM belgeler WHERE email=?').get(email);
|
||||
return { adet: r?.adet ?? 0, bayt: r?.bayt ?? 0 };
|
||||
}, { adet: 0, bayt: 0 });
|
||||
}
|
||||
Reference in New Issue
Block a user