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:
+50
-1
@@ -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 || '',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user