diff --git a/server/billing.mjs b/server/billing.mjs index 01fc0eb..9ddd281 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -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=,v1= + * 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 || '', }); diff --git a/server/entitlements.mjs b/server/entitlements.mjs new file mode 100644 index 0000000..6325f64 --- /dev/null +++ b/server/entitlements.mjs @@ -0,0 +1,98 @@ +/** + * Plan hakları ve kota uygulaması. + * Önceden billing.mjs'te `sinirlar` tanımlıydı ama HİÇBİR yerde kontrol + * edilmiyordu — herkes her şeyi kullanabiliyordu. Burada gerçekten uygulanır. + */ + +import { getUser, documentUsage, listApiKeys, getData } from './user-store.mjs'; + +export const PLAN_HAKLARI = { + ucretsiz: { + ad: 'Ücretsiz', + takip: 10, // takip edilebilecek ihale + ekip: 1, // ekip üyesi + bildirimKriteri: 1, // otomatik bildirim kriteri + belgeAdet: 5, + belgeBayt: 25 * 1024 * 1024, + apiAnahtari: 0, + ozellikler: ['arama', 'favori', 'push'], + }, + pro: { + ad: 'Pro', + takip: null, // sınırsız + ekip: 5, + bildirimKriteri: 10, + belgeAdet: 200, + belgeBayt: 2 * 1024 * 1024 * 1024, + apiAnahtari: 0, + ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta'], + }, + kurumsal: { + ad: 'Kurumsal', + takip: null, + ekip: null, + bildirimKriteri: null, + belgeAdet: null, + belgeBayt: 20 * 1024 * 1024 * 1024, + apiAnahtari: 10, + ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta', 'kirmizibayrak', 'kik', 'ted', 'api'], + }, +}; + +/** Kullanıcının etkin planı (süresi dolmuşsa ücretsize düşer). */ +export function planOf(email, { isAdmin = false } = {}) { + if (isAdmin) return 'kurumsal'; // yöneticiler tam erişimli + const u = getUser(email); + if (!u) return 'ucretsiz'; + if (u.plan_bitis && new Date(u.plan_bitis) < new Date()) return 'ucretsiz'; + return PLAN_HAKLARI[u.plan] ? u.plan : 'ucretsiz'; +} + +export function haklar(email, opts) { + return PLAN_HAKLARI[planOf(email, opts)] || PLAN_HAKLARI.ucretsiz; +} + +export function ozellikVar(email, ozellik, opts) { + return haklar(email, opts).ozellikler.includes(ozellik); +} + +/** Mevcut kullanım — arayüzde "3/10" göstermek için. */ +export function kullanim(email, opts) { + const h = haklar(email, opts); + const belge = documentUsage(email); + const ekip = (getData(email, 'ekip') || []).length; + const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null; + return { + plan: planOf(email, opts), + planAdi: h.ad, + haklar: h, + kullanilan: { + belgeAdet: belge.adet, + belgeBayt: belge.bayt, + ekip, + apiAnahtari: listApiKeys(email).length, + takip, + }, + }; +} + +/** + * Kota kontrolü. Aşılırsa açıklayıcı hata fırlatır. + * @param {string} tur 'ekip' | 'belgeAdet' | 'belgeBayt' | 'apiAnahtari' | 'bildirimKriteri' | 'takip' + */ +export function kotaKontrol(email, tur, mevcutSayi, ekleme = 1, opts) { + const h = haklar(email, opts); + const limit = h[tur]; + if (limit === null || limit === undefined) return; // sınırsız + if (mevcutSayi + ekleme > limit) { + const isim = { + ekip: 'ekip üyesi', belgeAdet: 'belge', belgeBayt: 'belge alanı', + apiAnahtari: 'API anahtarı', bildirimKriteri: 'bildirim kriteri', takip: 'ihale takibi', + }[tur] || tur; + const e = new Error(`${h.ad} planında en fazla ${limit} ${isim} hakkınız var. Yükseltmek için Planlar sekmesine bakın.`); + e.kod = 'KOTA_ASILDI'; + e.limit = limit; + e.plan = h.ad; + throw e; + } +} diff --git a/server/index.js b/server/index.js index 59b8158..1bad2b6 100644 --- a/server/index.js +++ b/server/index.js @@ -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() }); diff --git a/server/user-store.mjs b/server/user-store.mjs new file mode 100644 index 0000000..ac0adbf --- /dev/null +++ b/server/user-store.mjs @@ -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 }); +} diff --git a/src/App.jsx b/src/App.jsx index a068912..424f529 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { AppProvider, useApp } from './context/AppContext'; +import { KotaUyarisi } from './components/KotaUyarisi'; import { Header } from './components/Header'; import { LiveStatsBar } from './components/LiveStatsBar'; import { TopNavigation } from './components/TopNavigation'; @@ -39,6 +40,8 @@ const MainContent = () => { return (
{/* 3-Column Main View */} + + {activeTab === 'list' && ( <> diff --git a/src/components/KotaUyarisi.jsx b/src/components/KotaUyarisi.jsx new file mode 100644 index 0000000..30193a6 --- /dev/null +++ b/src/components/KotaUyarisi.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useApp } from '../context/AppContext'; +import { AlertTriangle, X, ArrowRight } from 'lucide-react'; + +/** Plan kotası aşıldığında sunucudan gelen uyarıyı gösterir. */ +export const KotaUyarisi = () => { + const { kotaUyarisi, setKotaUyarisi, setActiveTab } = useApp(); + if (!kotaUyarisi) return null; + return ( +
+ +

{kotaUyarisi}

+ + +
+ ); +}; diff --git a/src/components/tools/ApiWebhooks.jsx b/src/components/tools/ApiWebhooks.jsx index 562cf59..c331daf 100644 --- a/src/components/tools/ApiWebhooks.jsx +++ b/src/components/tools/ApiWebhooks.jsx @@ -1,174 +1,166 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApp } from '../../context/AppContext'; -import { Code, Key, Webhook, Send, CheckCircle2, Copy, Plus, Lock, Globe, MessageSquare, Terminal, RefreshCw } from 'lucide-react'; -import confetti from 'canvas-confetti'; +import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account'; +import { Webhook, Key, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, ShieldCheck, Lock } from 'lucide-react'; +/** + * Gerçek API anahtarı yönetimi. + * Anahtar sunucuda üretilir, YALNIZCA hash'i saklanır, ham değer bir kez gösterilir. + * (Önceden tarayıcıda Math.random ile üretiliyor ve hiçbir yerde doğrulanmıyordu.) + */ export const ApiWebhooks = () => { - const { apiKeys, setApiKeys, webhooksConfig, setWebhooksConfig } = useApp(); - const [copiedKey, setCopiedKey] = useState(false); - const [testSuccess, setTestSuccess] = useState(false); + const { authUser } = useApp(); + const [durum, setDurum] = useState(null); + const [busy, setBusy] = useState(false); + const [hata, setHata] = useState(null); + const [yeniAd, setYeniAd] = useState(''); + const [yeniAnahtar, setYeniAnahtar] = useState(null); // yalnızca bir kez gösterilir + const [kopyalandi, setKopyalandi] = useState(false); - const handleGenerateApiKey = () => { - const newKey = { - id: 'key-' + Date.now(), - name: 'Yeni Uygulama Anahtarı ' + (apiKeys.length + 1), - key: 'ih_live_' + Math.random().toString(36).substring(2, 18), - createdAt: new Date().toISOString().split('T')[0], - lastUsed: 'Henüz Kullanılmadı' - }; - setApiKeys(prev => [newKey, ...prev]); - confetti({ particleCount: 50 }); + const yukle = () => { + if (!authUser) return; + listApiKeys().then(setDurum).catch((e) => setHata(e.message)); + }; + useEffect(yukle, [authUser]); + + const olustur = async () => { + setBusy(true); setHata(null); setYeniAnahtar(null); + try { + const d = await createApiKey(yeniAd || 'API Anahtarı'); + setYeniAnahtar(d.anahtar); + setYeniAd(''); + yukle(); + } catch (e) { setHata(e.message); } + finally { setBusy(false); } }; - const handleSendTestWebhook = () => { - setTestSuccess(true); - confetti({ particleCount: 60 }); - setTimeout(() => setTestSuccess(false), 4000); + const iptal = async (id) => { + setBusy(true); setHata(null); + try { setDurum(await revokeApiKey(id)); } + catch (e) { setHata(e.message); } + finally { setBusy(false); } }; + const kopyala = (t) => { + navigator.clipboard?.writeText(t); + setKopyalandi(true); + setTimeout(() => setKopyalandi(false), 2000); + }; + + const limit = durum?.limit; + const kilitli = limit === 0; + return (
- - {/* Header */} -
+
-
+
-

API & Webhook Entegrasyon Yönetimi

-

ERP, SAP, Logo, Mikro muhasebe yazılımlarınız ve Slack/Teams kanallarınız ile çift yönlü canlı veri aktarımı.

+

API Erişimi

+

+ İhale verisini kendi sistemlerinize (ERP, CRM) taşımak için sunucu doğrulamalı API anahtarları. +

- {/* REST API Keys Section */} -
+ {kilitli && ( +
+ +

+ API erişimi Kurumsal planda sunulur. Mevcut planınızda API anahtarı oluşturulamaz — + Planlar sekmesinden yükseltebilirsiniz. +

+
+ )} + + {/* Yeni anahtar — yalnızca bir kez gösterilir */} + {yeniAnahtar && ( +
+

+ Anahtarınız oluşturuldu — bu değer bir daha gösterilmeyecek +

+
+ + {yeniAnahtar.anahtar} + + +
+

+ Sunucuda yalnızca anahtarın hash'i saklanır; ham değeri biz de göremeyiz. Güvenli bir yere kaydedin. +

+
+ )} + +
-
-

- REST API Canlı Erişim Anahtarları -

-

Geliştiricileriniz için RESTful JSON API anahtarları.

-
- -
- -
- {apiKeys.map(k => ( -
-
- {k.name} -
- - {k.key} - - -
-
- -
-

Oluşturulma: {k.createdAt}

-

Son Kullanım: {k.lastUsed}

-
-
- ))} -
-
- - {/* Webhook Configuration & Slack / Teams Section */} -
-

- Slack & Microsoft Teams Webhook Ayarları + API Anahtarları

-

İhale etkinlikleri gerçekleştiğinde anında bildirim alacağınız HTTP Webhook URL adresleri.

-
- -
-
- - setWebhooksConfig({ ...webhooksConfig, slackWebhookUrl: e.target.value })} - className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-mono text-slate-800" - /> -
- -
- - setWebhooksConfig({ ...webhooksConfig, teamsWebhookUrl: e.target.value })} - className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-mono text-slate-800" - /> -
-
- - {/* Event Trigger Checkboxes */} -
- -
- {[ - { key: 'newTenderMatch', title: '🟢 Yeni Uyumlu İhale Yayınlandığında' }, - { key: 'tenderWon', title: '🏆 İhale Kazanıldığında ERP/Muhasebeye Gönder' }, - { key: 'bidDeadlineAlert', title: '⚠️ İhale Son Başvurusuna 3 Saat Kala Uyar' }, - { key: 'competitorActivity', title: '🛡️ İzlenen Rakip Firma Teklif Verince Bildir' } - ].map(item => ( - - ))} -
-
- - {/* Test Webhook Button */} -
- - - {testSuccess && ( - - - Test Webhook Başarıyla Slack & Teams Kanallarına İletildi! (HTTP 200 OK) + {durum && ( + + {durum.anahtarlar.length}{limit != null ? ` / ${limit}` : ''} anahtar )}
+ {!durum ? ( +
yükleniyor…
+ ) : durum.anahtarlar.length === 0 ? ( +

Henüz anahtar yok.

+ ) : ( +
+ {durum.anahtarlar.map((k) => ( +
+
+

{k.ad}

+

{k.onek}••••••••••••••••

+

+ Oluşturma: {new Date(k.olusturma).toLocaleDateString('tr-TR')} + {' · '} + {k.son_kullanim ? `son kullanım: ${new Date(k.son_kullanim).toLocaleString('tr-TR')}` : 'hiç kullanılmadı'} +

+
+ +
+ ))} +
+ )} + +
+ setYeniAd(e.target.value)} + placeholder="Anahtar adı (ör. ERP Entegrasyonu)" disabled={kilitli} + className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs disabled:opacity-50" /> + +
+ + {hata && ( +
+ +

{hata}

+
+ )}
+
+

+ Kullanım: İsteklerinize Authorization: Bearer ih_live_… + {' '}başlığını ekleyin. Anahtar sunucuda doğrulanır, her kullanımda son kullanım zamanı güncellenir, + iptal ettiğinizde anında geçersiz olur. +

+
); }; diff --git a/src/components/tools/DocumentVault.jsx b/src/components/tools/DocumentVault.jsx index c04133e..4a0a8e3 100644 --- a/src/components/tools/DocumentVault.jsx +++ b/src/components/tools/DocumentVault.jsx @@ -1,147 +1,156 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useApp } from '../../context/AppContext'; -import { FolderKanban, FileText, Upload, Plus, ShieldCheck, AlertCircle, CheckCircle2, Download, Trash2, Tag, Calendar } from 'lucide-react'; -import confetti from 'canvas-confetti'; +import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account'; +import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive } from 'lucide-react'; +const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer']; + +/** + * Gerçek belge deposu — dosyalar sunucudaki kalıcı diske yazılır, + * boyut gerçek bayt cinsindendir ve plan kotası sunucu tarafında uygulanır. + * (Önceden dosya hiç yüklenmiyordu; boyut Math.random ile uyduruluyordu.) + */ export const DocumentVault = () => { - const { vaultDocuments, setVaultDocuments, userProfile } = useApp(); - const [docName, setDocName] = useState(''); - const [category, setCategory] = useState('Kalite Belgeleri'); + const { authUser } = useApp(); + const [durum, setDurum] = useState(null); + const [busy, setBusy] = useState(false); + const [hata, setHata] = useState(null); + const [kategori, setKategori] = useState(KATEGORILER[0]); + const dosyaRef = useRef(null); - const handleAddDocument = (e) => { - e.preventDefault(); - if (!docName.trim()) return; + const yukle = () => { + if (!authUser) return; + listDocuments().then(setDurum).catch((e) => setHata(e.message)); + }; + useEffect(yukle, [authUser]); - const newDoc = { - id: 'doc-' + Date.now(), - name: docName.endsWith('.pdf') ? docName : docName + '.pdf', - category, - size: (Math.random() * 2 + 0.5).toFixed(1) + ' MB', - updatedAt: new Date().toISOString().split('T')[0], - expiresAt: '2027-12-31', - status: 'Geçerli' - }; - - setVaultDocuments(prev => [newDoc, ...prev]); - setDocName(''); - confetti({ particleCount: 60 }); + const dosyaSecildi = async (e) => { + const f = e.target.files?.[0]; + if (!f) return; + setBusy(true); setHata(null); + try { + setDurum(await uploadDocument(f, kategori)); + } catch (err) { + setHata(err.message); + } finally { + setBusy(false); + if (dosyaRef.current) dosyaRef.current.value = ''; + } }; - const deleteDoc = (id) => { - setVaultDocuments(prev => prev.filter(d => d.id !== id)); + const sil = async (id) => { + setBusy(true); setHata(null); + try { setDurum(await deleteDocument(id)); } + catch (e) { setHata(e.message); } + finally { setBusy(false); } }; + const k = durum?.kullanim; + const h = durum?.haklar; + const doluluk = h?.bayt ? Math.min(100, Math.round(((k?.bayt || 0) / h.bayt) * 100)) : 0; + return (
- - {/* Header */} -
+
-
+
-
-

Doküman ve Teklif Deposu

- - Resmi Evrak Kütüphanesi - -
-

Sık kullanılan resmi belgelerinizi (İmza sirküleri, ISO, ÜTS, Faaliyet Belgesi) tek yerde saklayın; ihale teklif paketine saniyeler içinde ekleyin.

+

Belge Kasası

+

+ İhale dosyalarınıza her cihazdan erişin — belgeler sunucuda saklanır, tarayıcınızda değil. +

- {/* Add Document Form */} -
-
- - setDocName(e.target.value)} - placeholder="Örn: 2026 ISO 9001 Kalite Belgesi.pdf" - className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-semibold text-slate-900 focus:ring-2 focus:ring-blue-500" - /> -
- -
- - -
- - -
- - {/* Documents Grid */} -
- {vaultDocuments.map((doc) => ( -
-
-
- - {doc.category} - - - {doc.status} - -
- -
-
- -
-
-

{doc.name}

-

Boyut: {doc.size}

-
-
+ {/* Kullanım */} + {durum && ( +
+
+ + Kullanım + + + {k.adet}{h?.adet != null ? ` / ${h.adet}` : ''} belge · {formatBayt(k.bayt)} + {h?.bayt != null && ` / ${formatBayt(h.bayt)}`} + +
+ {h?.bayt != null && ( +
+
90 ? 'bg-red-500' : doluluk > 70 ? 'bg-amber-500' : 'bg-teal-600'}`} + style={{ width: `${doluluk}%` }} />
+ )} +
+ )} -
- - Son Geçerlilik: {doc.expiresAt} - + {/* Yükleme */} +
+
+ +
+
+

Belge Yükleyin

+

İmza sirküleri, sertifikalar, vergi levhası… (en fazla 8 MB)

+
+
+ + +
+
-
- -
- -
- ))} -
- + ))} +
+ )}
); }; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 40b47a4..6157f00 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; import { restoreSession, clearSession, getStoredUser } from '../services/auth'; +import { getAllUserData, setUserData, getPlan } from '../services/account'; const AppContext = createContext(); @@ -43,6 +44,39 @@ export const AppProvider = ({ children }) => { .finally(() => setAuthReady(true)); }, []); + // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) + useEffect(() => { + if (!authUser) { setVeriSenkron(false); setPlan(null); return; } + planYenile(); + getAllUserData() + .then((v) => { + if (v.favori_kurumlar) setFavoriteKurumlar(v.favori_kurumlar); + if (v.ekip) setTeamMembers(v.ekip); + if (v.saved_filters) setSavedFilters(v.saved_filters); + if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder })); + if (v.rakip_izleme) setCompetitorWatchlist(v.rakip_izleme); + }) + .catch((e) => console.warn('[veri] sunucudan alınamadı:', e.message)) + .finally(() => setVeriSenkron(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authUser?.email]); + + // --- Sunucu tarafı kullanıcı verisi --- + // localStorage yalnızca çevrimdışı önbellek; kaynak doğruluk sunucuda. + const [plan, setPlan] = useState(null); + const [veriSenkron, setVeriSenkron] = useState(false); + + const sunucuyaYaz = (anahtar, deger) => { + if (!authUser) return; + setUserData(anahtar, deger).catch((e) => { + if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message); + else console.warn('[veri] sunucuya yazılamadı:', e.message); + }); + }; + const [kotaUyarisi, setKotaUyarisi] = useState(null); + + const planYenile = () => getPlan().then(setPlan).catch(() => {}); + const handleLogin = (user) => setAuthUser(user); const handleLogout = () => { clearSession(); @@ -109,7 +143,8 @@ export const AppProvider = ({ children }) => { }); useEffect(() => { localStorage.setItem('ihaleist_rakip_izleme', JSON.stringify(competitorWatchlist)); - }, [competitorWatchlist]); + if (veriSenkron) sunucuyaYaz('rakip_izleme', competitorWatchlist); + }, [competitorWatchlist, veriSenkron]); // --- Favori kurumlar (localStorage, gerçek) --- const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => { @@ -117,7 +152,8 @@ export const AppProvider = ({ children }) => { }); useEffect(() => { localStorage.setItem('ihaleist_favori_kurumlar', JSON.stringify(favoriteKurumlar)); - }, [favoriteKurumlar]); + if (veriSenkron) sunucuyaYaz('favori_kurumlar', favoriteKurumlar); + }, [favoriteKurumlar, veriSenkron]); const toggleFavoriteKurum = (kurum) => { const ad = (kurum || '').trim(); if (!ad) return; @@ -136,7 +172,8 @@ export const AppProvider = ({ children }) => { }); useEffect(() => { localStorage.setItem('ihaleist_ekip', JSON.stringify(teamMembers)); - }, [teamMembers]); + if (veriSenkron) sunucuyaYaz('ekip', teamMembers); + }, [teamMembers, veriSenkron]); const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]); const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id)); @@ -162,7 +199,8 @@ export const AppProvider = ({ children }) => { }); useEffect(() => { localStorage.setItem('ihaleist_reminder', JSON.stringify(reminderSettings)); - }, [reminderSettings]); + if (veriSenkron) sunucuyaYaz('reminder', reminderSettings); + }, [reminderSettings, veriSenkron]); // Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler) const isQuietNow = () => { @@ -321,7 +359,8 @@ export const AppProvider = ({ children }) => { useEffect(() => { localStorage.setItem('ihaleist_saved_filters', JSON.stringify(savedFilters)); - }, [savedFilters]); + if (veriSenkron) sunucuyaYaz('saved_filters', savedFilters); + }, [savedFilters, veriSenkron]); // (Sahte "online kullanıcı" rastgele sayacı kaldırıldı — canlı istatistikler // gerçek /api/stats verisinden gelir.) @@ -447,7 +486,12 @@ export const AppProvider = ({ children }) => { removeTeamMember, toggleTrackTender, updateKanbanStatus, - clearAllFilters + clearAllFilters, + plan, + planYenile, + kotaUyarisi, + setKotaUyarisi, + veriSenkron }}> {children} diff --git a/src/services/account.js b/src/services/account.js new file mode 100644 index 0000000..dcc5e0d --- /dev/null +++ b/src/services/account.js @@ -0,0 +1,76 @@ +/** + * Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler. + * (Önceden bunların hepsi localStorage'daydı veya sahteydi.) + */ +import { getToken } from './auth'; + +const auth = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' }); + +async function iste(url, opts = {}) { + const res = await fetch(url, { ...opts, headers: { ...auth(), ...(opts.headers || {}) } }); + const d = await res.json().catch(() => ({})); + if (!d.success) { + const e = new Error(d.error || `İstek başarısız (HTTP ${res.status})`); + e.kod = d.kod; + throw e; + } + return d; +} + +// --- Plan & kota --- +export const getPlan = () => iste('/api/me/plan'); + +// --- Kullanıcı verisi --- +export const getAllUserData = () => iste('/api/me/data').then((d) => d.veri || {}); +export const setUserData = (anahtar, deger) => + iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) }); + +// --- API anahtarları --- +export const listApiKeys = () => iste('/api/me/apikeys'); +export const createApiKey = (ad) => iste('/api/me/apikeys', { method: 'POST', body: JSON.stringify({ ad }) }); +export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + +// --- Belgeler --- +export const listDocuments = () => iste('/api/me/documents'); +export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + +export async function uploadDocument(file, kategori) { + const icerikBase64 = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result).split(',')[1]); + r.onerror = reject; + r.readAsDataURL(file); + }); + return iste('/api/me/documents', { + method: 'POST', + body: JSON.stringify({ ad: file.name, mime: file.type, kategori, icerikBase64 }), + }); +} + +/** Auth başlığı gerektiği için yerine blob ile indirilir. */ +export async function downloadDocument(id, ad) { + const res = await fetch(`/api/me/documents/download?id=${encodeURIComponent(id)}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.error || `İndirilemedi (HTTP ${res.status})`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = ad || 'belge'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +export function formatBayt(b) { + if (b == null) return '—'; + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; + if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`; + return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`; +}