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:
+204
-1
@@ -13,13 +13,16 @@ import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readDtTenders } from './dt-store.mjs';
|
||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
||||
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
|
||||
import { billingConfigured, planCatalog, createCheckoutSession } from './billing.mjs';
|
||||
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice } from './billing.mjs';
|
||||
import * as store from './user-store.mjs';
|
||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||
import { searchTed } from './ted.mjs';
|
||||
import { readHistory, historyStats } from './history-store.mjs';
|
||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||
@@ -380,6 +383,18 @@ function json(res, code, body) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readRaw(req, limit = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
req.on('data', (c) => {
|
||||
buf += c;
|
||||
if (buf.length > limit) { reject(new Error('Gövde çok büyük')); req.destroy(); }
|
||||
});
|
||||
req.on('end', () => resolve(buf));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req, limit = 100 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
@@ -545,6 +560,194 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Belge deposu (GERÇEK dosya, GERÇEK boyut, kota uygulanır) ---
|
||||
if (url.pathname === '/api/me/documents') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
const opts = { isAdmin: user.isAdmin };
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req, 12 * 1024 * 1024); // base64 şişmesi payı
|
||||
if (!body.ad || !body.icerikBase64) {
|
||||
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
|
||||
}
|
||||
const buf = Buffer.from(body.icerikBase64, 'base64');
|
||||
const kullanimDurumu = store.documentUsage(user.email);
|
||||
kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts);
|
||||
kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts);
|
||||
|
||||
const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf);
|
||||
const id = store.addDocument(user.email, {
|
||||
ad: String(body.ad).slice(0, 200),
|
||||
kategori: body.kategori || null,
|
||||
boyut: buf.length, // GERÇEK bayt — rastgele değil
|
||||
mime: body.mime || null,
|
||||
dosya: dosyaAdi,
|
||||
sonKullanma: body.sonKullanma || null,
|
||||
});
|
||||
return json(res, 200, { success: true, id, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = store.deleteDocument(user.email, url.searchParams.get('id'));
|
||||
return json(res, ok ? 200 : 404, { success: ok, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
|
||||
}
|
||||
return json(res, 200, {
|
||||
success: true,
|
||||
belgeler: store.listDocuments(user.email),
|
||||
kullanim: store.documentUsage(user.email),
|
||||
haklar: { adet: haklar(user.email, opts).belgeAdet, bayt: haklar(user.email, opts).belgeBayt },
|
||||
});
|
||||
}
|
||||
// Belge indirme
|
||||
if (url.pathname === '/api/me/documents/download') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
const rec = store.getDocument(user.email, url.searchParams.get('id'));
|
||||
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': rec.mime || 'application/octet-stream',
|
||||
'Content-Length': buf.length,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rec.ad)}`,
|
||||
});
|
||||
return res.end(buf);
|
||||
} catch (e) {
|
||||
return json(res, 500, { success: false, error: 'dosya okunamadı' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
|
||||
if (url.pathname.startsWith('/api/me/data')) {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
const anahtar = url.searchParams.get('anahtar');
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (anahtar) return json(res, 200, { success: true, anahtar, deger: store.getData(user.email, anahtar) });
|
||||
return json(res, 200, { success: true, veri: store.getAllData(user.email) });
|
||||
}
|
||||
if (req.method === 'PUT' || req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req, 512 * 1024);
|
||||
const k = body.anahtar || anahtar;
|
||||
if (!k) return json(res, 400, { success: false, error: 'anahtar gerekli' });
|
||||
// Ekip kotası sunucu tarafında uygulanır
|
||||
if (k === 'ekip' && Array.isArray(body.deger)) {
|
||||
kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin });
|
||||
}
|
||||
store.setData(user.email, k, body.deger);
|
||||
return json(res, 200, { success: true });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
return json(res, 405, { success: false, error: 'desteklenmeyen metot' });
|
||||
}
|
||||
|
||||
// --- API anahtarları (gerçek, hash'li, sunucu doğrulamalı) ---
|
||||
if (url.pathname === '/api/me/apikeys') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const mevcut = store.listApiKeys(user.email).length;
|
||||
kotaKontrol(user.email, 'apiAnahtari', mevcut, 1, { isAdmin: user.isAdmin });
|
||||
const body = await readBody(req).catch(() => ({}));
|
||||
const k = store.createApiKey(user.email, body.ad);
|
||||
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
|
||||
return json(res, 200, { success: true, anahtar: k });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
|
||||
return json(res, ok ? 200 : 404, { success: ok, anahtarlar: store.listApiKeys(user.email) });
|
||||
}
|
||||
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||
}
|
||||
|
||||
// --- Stripe webhook (imza doğrulamalı) ---
|
||||
// NOT: Bu uç kimlik doğrulaması İSTEMEZ; güvenlik imza doğrulamasıyla sağlanır.
|
||||
if (url.pathname === '/api/billing/webhook' && req.method === 'POST') {
|
||||
if (!webhookConfigured()) {
|
||||
return json(res, 503, { success: false, error: 'STRIPE_WEBHOOK_SECRET tanımlı değil' });
|
||||
}
|
||||
let olay;
|
||||
try {
|
||||
const ham = await readRaw(req);
|
||||
olay = verifyWebhook(ham, req.headers['stripe-signature']);
|
||||
} catch (e) {
|
||||
console.warn('[stripe-webhook] imza reddedildi:', e.message);
|
||||
return json(res, 400, { success: false, error: e.message });
|
||||
}
|
||||
try {
|
||||
const o = olay.data?.object || {};
|
||||
switch (olay.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const email = (o.client_reference_id || o.customer_email || o.metadata?.email || '').toLowerCase();
|
||||
const planId = o.metadata?.planId || 'pro';
|
||||
if (email) {
|
||||
store.setPlan(email, {
|
||||
plan: planId, kaynak: 'stripe',
|
||||
customerId: o.customer || null, subId: o.subscription || null,
|
||||
});
|
||||
console.log(`[stripe-webhook] ${email} → ${planId} planına geçti`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.created': {
|
||||
const priceId = o.items?.data?.[0]?.price?.id;
|
||||
const planId = planForPrice(priceId) || 'pro';
|
||||
const aktif = ['active', 'trialing'].includes(o.status);
|
||||
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
|
||||
if (k) {
|
||||
store.setPlan(k.email, {
|
||||
plan: aktif ? planId : 'ucretsiz', kaynak: 'stripe',
|
||||
bitis: o.current_period_end ? new Date(o.current_period_end * 1000).toISOString() : null,
|
||||
customerId: o.customer, subId: o.id,
|
||||
});
|
||||
console.log(`[stripe-webhook] ${k.email} aboneliği ${o.status} → ${aktif ? planId : 'ucretsiz'}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
|
||||
if (k) {
|
||||
store.setPlan(k.email, { plan: 'ucretsiz', kaynak: 'stripe', customerId: o.customer, subId: o.id });
|
||||
console.log(`[stripe-webhook] ${k.email} aboneliği iptal edildi`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // ilgilenmediğimiz olaylar sessizce onaylanır
|
||||
}
|
||||
return json(res, 200, { received: true });
|
||||
} catch (e) {
|
||||
console.error('[stripe-webhook] işleme hatası:', e.message);
|
||||
return json(res, 500, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcının planı ve kullanımı ---
|
||||
if (url.pathname === '/api/me/plan') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
return json(res, 200, { success: true, ...kullanim(user.email, { isAdmin: user.isAdmin }) });
|
||||
}
|
||||
|
||||
// --- Abonelik / ödeme ---
|
||||
if (url.pathname === '/api/billing/plans') {
|
||||
return json(res, 200, { configured: billingConfigured(), plans: planCatalog() });
|
||||
|
||||
Reference in New Issue
Block a user