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:
2026-08-29 10:40:05 +03:00
parent f73c7bd6cb
commit f04401031c
10 changed files with 1008 additions and 268 deletions
+50 -1
View File
@@ -12,6 +12,8 @@
* döner; uygulama ücretsiz katmanda çalışmaya devam eder.
*/
import crypto from 'node:crypto';
export const PLANS = [
{
id: 'ucretsiz',
@@ -71,7 +73,53 @@ export const PLANS = [
];
export function billingConfigured() {
return Boolean(process.env.STRIPE_SECRET_KEY);
const k = (process.env.STRIPE_SECRET_KEY || '').trim();
return k.startsWith('sk_') && k.length > 12;
}
export function webhookConfigured() {
return Boolean((process.env.STRIPE_WEBHOOK_SECRET || '').trim().startsWith('whsec_'));
}
/**
* Stripe webhook imzasını SDK olmadan doğrular.
* Stripe-Signature: t=<zaman>,v1=<hmac>
* imzalanan yük = `${t}.${hamGovde}` ; HMAC-SHA256(whsec)
*/
export function verifyWebhook(hamGovde, imzaBasligi, { tolerans = 300 } = {}) {
const secret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET tanımlı değil');
if (!imzaBasligi) throw new Error('Stripe-Signature başlığı yok');
const parcalar = Object.fromEntries(
String(imzaBasligi).split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
})
);
const t = parcalar.t;
const v1 = parcalar.v1;
if (!t || !v1) throw new Error('İmza başlığı biçimi geçersiz');
// Tekrar saldırısına karşı zaman penceresi
const yas = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(yas) || yas > tolerans) throw new Error('İmza zaman aşımına uğradı');
const beklenen = crypto.createHmac('sha256', secret).update(`${t}.${hamGovde}`, 'utf8').digest('hex');
const a = Buffer.from(beklenen, 'utf8');
const b = Buffer.from(v1, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('İmza doğrulanamadı');
return JSON.parse(hamGovde);
}
/** Stripe Price ID → plan kimliği */
export function planForPrice(priceId) {
if (!priceId) return null;
for (const p of PLANS) {
if (p.priceEnv && process.env[p.priceEnv] === priceId) return p.id;
}
return null;
}
export function planCatalog() {
@@ -110,6 +158,7 @@ export async function createCheckoutSession({ planId, email }) {
cancel_url: `${appUrl}/?odeme=iptal`,
customer_email: email,
allow_promotion_codes: 'true',
client_reference_id: email || '',
'metadata[planId]': planId,
'metadata[email]': email || '',
});