From 4c8c468acd508241f911aeccdf2c60f53e5cd4f7 Mon Sep 17 00:00:00 2001 From: sbilketay Date: Sun, 23 Nov 2025 20:04:00 +0300 Subject: [PATCH] first commit --- .dockerignore | 9 + .env.example | 26 +++ .gitignore | 62 ++++++ Dockerfile | 13 ++ README.md | 307 +++++++++++++++++++++++++++++ docker-compose.dev.yml | 30 +++ docker-compose.yml | 27 +++ package.json | 30 +++ src/app.js | 56 ++++++ src/config/env.js | 57 ++++++ src/config/redis.js | 15 ++ src/config/supabase.js | 8 + src/controllers/auth.controller.js | 124 ++++++++++++ src/controllers/meta.controller.js | 38 ++++ src/middleware/adminRequired.js | 8 + src/middleware/authRequired.js | 35 ++++ src/middleware/errorHandler.js | 17 ++ src/middleware/hostOnly.js | 22 +++ src/middleware/rateLimit.js | 17 ++ src/middleware/userRequired.js | 8 + src/routes/auth.routes.js | 34 ++++ src/routes/health.routes.js | 9 + src/routes/meta.routes.js | 10 + src/server.js | 85 ++++++++ src/services/adminLock.service.js | 14 ++ src/services/auth.service.js | 90 +++++++++ src/services/mediaData.service.js | 39 ++++ src/services/meta.service.js | 69 +++++++ src/services/session.service.js | 74 +++++++ src/utils/crypto.js | 37 ++++ src/utils/response.js | 10 + 31 files changed, 1380 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/config/env.js create mode 100644 src/config/redis.js create mode 100644 src/config/supabase.js create mode 100644 src/controllers/auth.controller.js create mode 100644 src/controllers/meta.controller.js create mode 100644 src/middleware/adminRequired.js create mode 100644 src/middleware/authRequired.js create mode 100644 src/middleware/errorHandler.js create mode 100644 src/middleware/hostOnly.js create mode 100644 src/middleware/rateLimit.js create mode 100644 src/middleware/userRequired.js create mode 100644 src/routes/auth.routes.js create mode 100644 src/routes/health.routes.js create mode 100644 src/routes/meta.routes.js create mode 100644 src/server.js create mode 100644 src/services/adminLock.service.js create mode 100644 src/services/auth.service.js create mode 100644 src/services/mediaData.service.js create mode 100644 src/services/meta.service.js create mode 100644 src/services/session.service.js create mode 100644 src/utils/crypto.js create mode 100644 src/utils/response.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b479629 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +yarn-error.log +.env +.git +.vscode +logs +coverage +dist diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..001e17e --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +NODE_ENV=development +PORT=3000 +TRUST_PROXY=loopback + +SUPABASE_URL=https://supabase.wisecolt.net +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + +REDIS_URL=redis://redis:6379 + +JWT_SECRET=supersecretjwtsecretkey +JWT_EXPIRES_IN=7d +COOKIE_NAME=sid +COOKIE_SECURE=false + +ADMIN_EMAIL=wisecolt@gmail.com +ADMIN_USERNAME=admin +ADMIN_NAME=Wise Colt +ADMIN_ROLE_LOCK=true + +HOST_ONLY_ALLOWLIST=127.0.0.1,::1 + +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 + +LOGIN_RATE_LIMIT_WINDOW_MS=60000 +LOGIN_RATE_LIMIT_MAX=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cebc8e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ + +# Node.js +node_modules/ +.svelte-kit/ +.serena/ +.claude/ +.vscode +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +package-lock.json +.pnpm-debug.log + +# Build output +/build +/.svelte-kit +/dist +/public/build +/.output + +# Environment files +.env +.env.* +!.env.example + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace + +# OS generated files +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# Misc +coverage/ +.cache/ +.sass-cache/ +.eslintcache +.stylelintcache + +# SvelteKit specific +.vercel +.netlify + +# Database files +*.db +db/*.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..657fb4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS base +ENV NODE_ENV=production +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN apk add --no-cache git && npm install --omit=dev + +COPY . . +EXPOSE ${PORT:-3000} + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD node -e "require('http').get('http://localhost:'+(process.env.PORT||3000)+'/health',res=>{if(res.statusCode===200)process.exit(0);process.exit(1);}).on('error',()=>process.exit(1))" + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9129708 --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ +# Comment API iskeleti + +Node.js + Express + Socket.io + Supabase + Redis tabanlı login/register API iskeleti. Güvenlik odaklı, host allowlist, rate limit, admin rol kilidi ve Redis oturum yönetimi içerir. Docker-compose ile Redis + API birlikte ayağa kalkar. + +## Dizın yapısı + +``` +src/ + app.js // Express app + server.js // HTTP + Socket.io bootstrap + config/ + env.js // .env yükleme ve parse + supabase.js // Supabase client + redis.js // Redis client + middleware/ + authRequired.js + adminRequired.js + rateLimit.js + hostOnly.js + errorHandler.js + routes/ + auth.routes.js + health.routes.js + controllers/ + auth.controller.js + services/ + auth.service.js + session.service.js + adminLock.service.js + utils/ + crypto.js // bcrypt + JWT yardımcıları + response.js // standart JSON çıktı +``` + +## Çalıştırma + +1. Bağımlılıklar: + ```bash + npm install + ``` + > Not: `metascraper` bağımlılığı özel git repo’dan gelir (`git+https://gitea.wisecolt-panda.net/wisecolt/metascraper.git`). Erişim için uygun token/SSH anahtarı olan bir ortamda `npm install` çalıştırın. + > Docker build için base imaja `git` eklenmiştir; özel repo erişimi için gerekirse `GIT_SSH_COMMAND` veya `GITHUB_TOKEN` benzeri auth ekleyin. +2. Ortam değişkenleri: `.env.example` dosyasını `.env` olarak kopyalayıp doldurun. +3. Geliştirme: + ```bash + npm run dev + ``` +4. Üretim: + ```bash + npm start + ``` + +## Docker + +Yerelde (dev, hot-reload) Redis ile birlikte çalıştırmak için: + +```bash +docker compose -f docker-compose.dev.yml up --build +# durdurmak için +docker compose -f docker-compose.dev.yml down +``` + +- API: `http://localhost:${PORT}` +- Redis: `redis://localhost:6379` (container içinde `redis://redis:6379`) + +### Prod modunda Docker + +1. `.env` içinde `NODE_ENV=production` ve mümkünse `COOKIE_SECURE=true`, `TRUST_PROXY=loopback` (veya proxy sayısı) olarak ayarlayın. +2. Çalıştırın: + ```bash + docker compose up --build -d + ``` +3. Durdurun: + ```bash + docker compose down + ``` + +## .env örneği + +``` +NODE_ENV=development +PORT=3000 +TRUST_PROXY=loopback + +SUPABASE_URL=https://YOUR-SUPABASE-PROJECT.supabase.co +SUPABASE_SERVICE_ROLE_KEY=YOUR_SUPABASE_SERVICE_ROLE_KEY + +REDIS_URL=redis://redis:6379 + +JWT_SECRET=supersecretjwt +JWT_EXPIRES_IN=7d +COOKIE_NAME=sid +COOKIE_SECURE=false + +ADMIN_EMAIL=admin@example.com +ADMIN_USERNAME=admin +ADMIN_NAME=Admin User +ADMIN_ROLE_LOCK=true + +HOST_ONLY_ALLOWLIST=127.0.0.1,::1 + +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 + +LOGIN_RATE_LIMIT_WINDOW_MS=60000 +LOGIN_RATE_LIMIT_MAX=5 +``` + +## Endpointler + +- `GET /health` — `{ ok: true, time }` +- `POST /auth/register` — body `{ email, name, username, password }` +- `POST /auth/login` — body `{ emailOrUsername, password }` +- `POST /auth/logout` — cookie tabanlı JWT siler +- `GET /auth/me` — aktif kullanıcıyı döndürür +- `POST /auth/refresh` — yeni JWT üretir (iskelet) +- `POST /meta/scrape` — body `{ url }`, destekli domainler: `netflix.com`, `primevideo.com` (yalnızca `role: user`) + - Başarılı sorgu Supabase `media_data` tablosuna kaydedilir: `media_id, media_name, year, type(movie/tv), thumbnail_url, info, genre, media_provider (netflix/primevideo)` + +### Postman örnekleri + +**Register** +``` +POST {{baseUrl}}/auth/register +Content-Type: application/json +Body: +{ + "email": "user@example.com", + "name": "Test User", + "username": "testuser", + "password": "password123" +} +``` +**200**: +``` +{ + "user": { + "id": "...", + "email": "user@example.com", + "name": "Test User", + "username": "testuser", + "roleEffective": "user" + } +} +``` + +**Login** +``` +POST {{baseUrl}}/auth/login +Content-Type: application/json +Body: +{ + "emailOrUsername": "testuser", + "password": "password123" +} +``` +**200**: +``` +{ + "user": { + "id": "...", + "email": "user@example.com", + "name": "Test User", + "username": "testuser", + "roleEffective": "user" + } +} +``` +JWT otomatik olarak `Cookie: sid=` olarak set edilir. + +**Me** +``` +GET {{baseUrl}}/auth/me +Cookie: sid= +``` +**200**: +``` +{ + "user": { + "id": "...", + "email": "...", + "username": "...", + "roleEffective": "user" + } +} +``` + +**Logout** +``` +POST {{baseUrl}}/auth/logout +Cookie: sid= +``` +**200**: +``` +{ "message": "Çıkış yapıldı" } +``` + +**Meta scrape (Netflix/Prime)** +``` +POST {{baseUrl}}/meta/scrape +Content-Type: application/json +Cookie: sid= # roleEffective=user +Body: +{ "url": "https://www.netflix.com/tr/title/81507921" } +``` +Prime örneği: +``` +{ "url": "https://www.primevideo.com/-/tr/detail/0NHIN3TGAI9L7VZ45RS52RHUPL/ref=share_ios_movie" } +``` +**200** (örnek): +``` +{ + "provider": "netflix", + "type": "movie", + "media": { + "url": "https://www.netflix.com/title/82123114", + "id": "82123114", + "name": "ONE SHOT with Ed Sheeran", + "year": "2025", + "seasons": null, + "thumbnail": "...", + "info": "...", + "genre": "..." + }, + "saved": { + "media_id": "82123114", + "media_name": "ONE SHOT with Ed Sheeran", + "year": "2025", + "type": "movie", + "thumbnail_url": "...", + "info": "...", + "genre": "...", + "media_provider": "netflix", + "created_at": "..." + } +} +``` + +Hata çıktıları tek tip: +``` +{ + "error": { "code": "ERROR_CODE", "message": "Açıklama", "details": [...] } +} +``` + +## Supabase şeması (SQL) + +```sql +create extension if not exists "pgcrypto"; + +create table if not exists public.users ( + id uuid primary key default gen_random_uuid(), + email text unique not null, + name text not null, + username text unique not null, + password_hash text not null, + role text not null default 'user' check (role in ('user','admin')), + created_at timestamp with time zone default now() +); + +create table if not exists public.media_data ( + media_id varchar, + media_name text, + year numeric, + type text, + thumbnail_url varchar, + info text, + genre text, + media_provider text, + created_at timestamp with time zone default now() +); +``` + +> Admin rol kilidi: Sadece `.env` içindeki `ADMIN_EMAIL` ve `ADMIN_USERNAME` ile birebir eşleşen kullanıcı API tarafından `admin` kabul edilir. Supabase üzerinde rol elle değiştirilse bile etkisizdir. + +## Güvenlik notları + +- `hostOnly` middleware ve CORS sadece allowlist’teki host/ip erişimine izin verir. +- Global rate limit + login özel rate limit aktiftir. +- JWT httpOnly cookie olarak set edilir; Redis içinde `session:{jti}` anahtarıyla oturum metadata tutulur. + +## Socket.io + +- Handshake sırasında cookie’deki JWT doğrulanır, Redis oturum kontrol edilir. +- Başarılı bağlantıda `welcome` eventi döner. +- Örnek ping/pong: + - Client: `socket.emit('ping')` + - Server cevabı: `pong` içinde zaman damgası. + +## Docker notları + +- `docker-compose.yml` içinde `api` servisi `redis` servisine bağımlıdır. +- Geliştirme için kod klasörü container içine mount edilerek canlı yenileme (`npm run dev`) sağlanır. +- Sağlık kontrolü: container içinde `GET /health` çağrısı ile yapılır. + +## Yapılandırılabilir noktalar + +- Rate limit penceresi ve max değerleri `.env` ile ayarlanır. +- `HOST_ONLY_ALLOWLIST` ile izin verilen host/ip listesi yönetilir. +- `ADMIN_ROLE_LOCK` kapatılmadıkça tek admin `.env`’deki kullanıcıdır. + +## TODO / Genişletme fikirleri + +- Refresh token için ayrı bir anahtar ve whitelist mekanizması. +- Audit logları (morgan loglarını dosyaya yazma). +- Supabase RPC veya policy’lerle ek güvenlik. +- Testler (Jest/Supertest) ve CI pipeline’ı. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..35eda4b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +version: "3.9" + +services: + api: + build: . + container_name: comment-api-dev + env_file: + - .env + environment: + - NODE_ENV=${NODE_ENV:-development} + ports: + - "${PORT:-3000}:${PORT:-3000}" + depends_on: + - redis + restart: unless-stopped + volumes: + - .:/app + - /app/node_modules + command: sh -c "npm install && npm run dev" + redis: + image: redis:7-alpine + container_name: comment-redis-dev + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + +volumes: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d265eb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3.9" + +services: + api: + build: . + container_name: comment-api + env_file: + - .env + environment: + - NODE_ENV=production + ports: + - "${PORT:-3000}:${PORT:-3000}" + depends_on: + - redis + restart: unless-stopped + command: ["npm", "start"] + redis: + image: redis:7-alpine + container_name: comment-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + +volumes: + redis-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..5944754 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "comment-api", + "version": "0.1.0", + "description": "Node.js + Express + Socket.io + Supabase + Redis login/register API iskeleti", + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "nodemon src/server.js", + "start": "node src/server.js" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.4", + "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "express-validator": "^7.0.1", + "helmet": "^7.0.0", + "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "socket.io": "^4.7.5", + "metascraper": "git+https://gitea.wisecolt-panda.net/wisecolt/metascraper.git" + }, + "devDependencies": { + "nodemon": "^3.0.3" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..5ebae33 --- /dev/null +++ b/src/app.js @@ -0,0 +1,56 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; +import { env, isProduction } from './config/env.js'; +import { hostOnly } from './middleware/hostOnly.js'; +import { globalRateLimiter } from './middleware/rateLimit.js'; +import authRoutes from './routes/auth.routes.js'; +import healthRoutes from './routes/health.routes.js'; +import metaRoutes from './routes/meta.routes.js'; +import { errorHandler } from './middleware/errorHandler.js'; + +const app = express(); + +app.set('trust proxy', env.trustProxy); + +const corsOptions = { + origin: (origin, callback) => { + if (!origin) return callback(null, true); + try { + const hostname = new URL(origin).hostname; + if (env.hostOnlyAllowlist.includes(hostname)) { + return callback(null, true); + } + return callback(new Error('CORS engellendi')); + } catch (err) { + return callback(new Error('CORS engellendi')); + } + }, + credentials: true +}; + +app.use(helmet()); +app.use( + morgan(isProduction ? 'combined' : 'dev', { + skip: (req) => req.path === '/health' + }) +); +app.use(cors(corsOptions)); +app.use(express.json()); +app.use(cookieParser()); +app.use(hostOnly); +app.use(globalRateLimiter); + +app.use(healthRoutes); +app.use('/auth', authRoutes); +app.use(metaRoutes); + +app.use((req, res) => { + res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Kaynak bulunamadı' } }); +}); + +app.use(errorHandler); + +export default app; diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..e45d9a6 --- /dev/null +++ b/src/config/env.js @@ -0,0 +1,57 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +const toBool = (value, fallback = false) => { + if (value === undefined) return fallback; + return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase()); +}; + +const toInt = (value, fallback) => { + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +}; + +const parseAllowlist = (value) => { + const base = ['127.0.0.1', '::1', 'localhost']; + if (!value) return base; + const items = value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + return Array.from(new Set([...base, ...items])); +}; + +const parseTrustProxy = (value) => { + if (value === undefined) return 'loopback'; + const lowered = String(value).toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(lowered)) return 'loopback'; + if (['false', '0', 'no', 'off'].includes(lowered)) return false; + const num = Number(value); + if (!Number.isNaN(num)) return num; + return value; // express ve rate-limit string/array için kendi parse'ını yapar +}; + +export const env = { + nodeEnv: process.env.NODE_ENV || 'development', + port: toInt(process.env.PORT, 3000), + trustProxy: parseTrustProxy(process.env.TRUST_PROXY), + supabaseUrl: process.env.SUPABASE_URL, + supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, + redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', + jwtSecret: process.env.JWT_SECRET || 'changeme', + jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', + cookieName: process.env.COOKIE_NAME || 'sid', + cookieSecure: toBool(process.env.COOKIE_SECURE, false), + adminEmail: process.env.ADMIN_EMAIL, + adminUsername: process.env.ADMIN_USERNAME, + adminName: process.env.ADMIN_NAME, + adminRoleLock: toBool(process.env.ADMIN_ROLE_LOCK, true), + hostOnlyAllowlist: parseAllowlist(process.env.HOST_ONLY_ALLOWLIST), + rateLimitWindowMs: toInt(process.env.RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), + rateLimitMax: toInt(process.env.RATE_LIMIT_MAX, 100), + loginRateLimitWindowMs: toInt(process.env.LOGIN_RATE_LIMIT_WINDOW_MS, 60 * 1000), + loginRateLimitMax: toInt(process.env.LOGIN_RATE_LIMIT_MAX, 5) +}; + +export const isProduction = env.nodeEnv === 'production'; diff --git a/src/config/redis.js b/src/config/redis.js new file mode 100644 index 0000000..1af0b1e --- /dev/null +++ b/src/config/redis.js @@ -0,0 +1,15 @@ +import Redis from 'ioredis'; +import { env } from './env.js'; + +export const redis = new Redis(env.redisUrl); + +redis.on('connect', () => { + // Basit bağlantı log'u; üretimde detaylı loglama eklenebilir. + // eslint-disable-next-line no-console + console.log('Redis bağlantısı kuruldu'); +}); + +redis.on('error', (err) => { + // eslint-disable-next-line no-console + console.error('Redis hatası:', err); +}); diff --git a/src/config/supabase.js b/src/config/supabase.js new file mode 100644 index 0000000..71411d7 --- /dev/null +++ b/src/config/supabase.js @@ -0,0 +1,8 @@ +import { createClient } from '@supabase/supabase-js'; +import { env } from './env.js'; + +if (!env.supabaseUrl || !env.supabaseServiceRoleKey) { + throw new Error('Supabase yapılandırması eksik (SUPABASE_URL veya SUPABASE_SERVICE_ROLE_KEY).'); +} + +export const supabase = createClient(env.supabaseUrl, env.supabaseServiceRoleKey); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 0000000..44b0020 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,124 @@ +import { validationResult } from 'express-validator'; +import { env } from '../config/env.js'; +import { success, fail } from '../utils/response.js'; +import { registerUser, loginUser } from '../services/auth.service.js'; +import { removeSession, refreshSession, createSession } from '../services/session.service.js'; +import { verifyToken } from '../utils/crypto.js'; +import { getEffectiveRole } from '../services/adminLock.service.js'; + +const handleValidation = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return fail( + res, + { code: 'VALIDATION_ERROR', message: 'Geçersiz giriş', details: errors.array() }, + 400 + ); + } + return null; +}; + +export const register = async (req, res, next) => { + const validationError = handleValidation(req, res); + if (validationError) return; + + const { email, name, username, password } = req.body; + try { + const { user, token, cookieOptions } = await registerUser({ + email, + name, + username, + password, + userAgent: req.headers['user-agent'], + ip: req.ip + }); + res.cookie(env.cookieName, token, cookieOptions); + return success(res, { user }, 201); + } catch (err) { + return next(err); + } +}; + +export const login = async (req, res, next) => { + const validationError = handleValidation(req, res); + if (validationError) return; + + const { emailOrUsername, password } = req.body; + try { + const { user, token, cookieOptions } = await loginUser({ + emailOrUsername, + password, + userAgent: req.headers['user-agent'], + ip: req.ip + }); + res.cookie(env.cookieName, token, cookieOptions); + return success(res, { user }); + } catch (err) { + return next(err); + } +}; + +export const logout = async (req, res, next) => { + try { + if (req.auth?.jti) { + await removeSession(req.auth.jti); + } else { + // Cookie içindeki token varsa sonlandırmaya çalış + const token = req.cookies[env.cookieName]; + if (token) { + try { + const decoded = verifyToken(token); + if (decoded?.jti) await removeSession(decoded.jti); + } catch (err) { + // token bozuksa sessizce devam + } + } + } + res.clearCookie(env.cookieName); + return success(res, { message: 'Çıkış yapıldı' }); + } catch (err) { + return next(err); + } +}; + +export const me = async (req, res) => { + return success(res, { user: req.user }); +}; + +export const refresh = async (req, res, next) => { + try { + const token = req.cookies[env.cookieName]; + if (!token) { + return fail(res, { code: 'UNAUTHORIZED', message: 'Oturum bulunamadı' }, 401); + } + let decoded; + try { + decoded = verifyToken(token); + } catch (err) { + return fail(res, { code: 'UNAUTHORIZED', message: 'Token geçersiz' }, 401); + } + + await refreshSession(decoded.jti); + + const newSession = await createSession( + { + id: decoded.sub, + email: decoded.email, + username: decoded.username, + role: decoded.roleEffective ? decoded.roleEffective : 'user' + }, + { userAgent: req.headers['user-agent'], ip: req.ip } + ); + res.cookie(env.cookieName, newSession.token, newSession.cookieOptions); + return success(res, { + user: { + id: decoded.sub, + email: decoded.email, + username: decoded.username, + roleEffective: getEffectiveRole(decoded) + } + }); + } catch (err) { + return next(err); + } +}; diff --git a/src/controllers/meta.controller.js b/src/controllers/meta.controller.js new file mode 100644 index 0000000..841bdc3 --- /dev/null +++ b/src/controllers/meta.controller.js @@ -0,0 +1,38 @@ +import { body, validationResult } from 'express-validator'; +import { success, fail } from '../utils/response.js'; +import { scrapeMedia } from '../services/meta.service.js'; +import { saveMediaData } from '../services/mediaData.service.js'; + +export const validateMetaRequest = [ + body('url').isURL().withMessage('Geçerli bir URL giriniz') +]; + +export const handleMeta = async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return fail(res, { code: 'VALIDATION_ERROR', message: 'Geçersiz giriş', details: errors.array() }, 400); + } + + try { + const result = await scrapeMedia(req.body.url); + + const media = result?.data || {}; + const type = media?.seasons ? 'tv' : 'movie'; + const provider = result?.provider; + + const saved = await saveMediaData({ + media_id: media.id ?? null, + media_name: media.name ?? null, + year: media.year ?? null, + type, + thumbnail_url: media.thumbnail ?? null, + info: media.info ?? null, + genre: media.genre ?? null, + media_provider: provider + }); + + return success(res, { provider, type, media, saved }); + } catch (err) { + return next(err); + } +}; diff --git a/src/middleware/adminRequired.js b/src/middleware/adminRequired.js new file mode 100644 index 0000000..d6bc187 --- /dev/null +++ b/src/middleware/adminRequired.js @@ -0,0 +1,8 @@ +import { fail } from '../utils/response.js'; + +export const adminRequired = (req, res, next) => { + if (req.user?.roleEffective !== 'admin') { + return fail(res, { code: 'FORBIDDEN', message: 'Bu işlem için yetkiniz yok' }, 403); + } + return next(); +}; diff --git a/src/middleware/authRequired.js b/src/middleware/authRequired.js new file mode 100644 index 0000000..953bdd5 --- /dev/null +++ b/src/middleware/authRequired.js @@ -0,0 +1,35 @@ +import { env } from '../config/env.js'; +import { verifyToken } from '../utils/crypto.js'; +import { fail } from '../utils/response.js'; +import { getSession } from '../services/session.service.js'; +import { getEffectiveRole } from '../services/adminLock.service.js'; + +export const authRequired = async (req, res, next) => { + try { + const token = req.cookies[env.cookieName]; + if (!token) { + return fail(res, { code: 'UNAUTHORIZED', message: 'Oturum bulunamadı' }, 401); + } + const decoded = verifyToken(token); + if (!decoded?.jti) { + return fail(res, { code: 'UNAUTHORIZED', message: 'Token geçersiz' }, 401); + } + + const session = await getSession(decoded.jti); + if (!session) { + return fail(res, { code: 'SESSION_EXPIRED', message: 'Oturum süresi doldu veya bulunamadı' }, 401); + } + + const roleEffective = getEffectiveRole({ email: decoded.email, username: decoded.username }); + req.user = { + id: decoded.sub, + email: decoded.email, + username: decoded.username, + roleEffective + }; + req.auth = { jti: decoded.jti, session }; + return next(); + } catch (err) { + return fail(res, { code: 'UNAUTHORIZED', message: 'Kimlik doğrulama başarısız' }, 401); + } +}; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..bf3d08d --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,17 @@ +export const errorHandler = (err, req, res, next) => { + // eslint-disable-next-line no-console + console.error('Hata:', err); + const status = err.status || 500; + const code = err.code || 'INTERNAL_ERROR'; + const message = err.message || 'Sunucu hatası'; + const response = { + error: { + code, + message + } + }; + if (err.details) { + response.error.details = err.details; + } + return res.status(status).json(response); +}; diff --git a/src/middleware/hostOnly.js b/src/middleware/hostOnly.js new file mode 100644 index 0000000..dd49be9 --- /dev/null +++ b/src/middleware/hostOnly.js @@ -0,0 +1,22 @@ +import { env } from '../config/env.js'; +import { fail } from '../utils/response.js'; + +const normalizeHost = (value) => { + if (!value) return ''; + return value.split(':')[0]; +}; + +const isAllowedHost = (req) => { + const hostHeader = normalizeHost(req.get('host')); + const hostname = normalizeHost(req.hostname); + const ip = normalizeHost(req.ip); + return env.hostOnlyAllowlist.some((allowed) => { + const normalized = normalizeHost(allowed); + return normalized === hostHeader || normalized === hostname || normalized === ip; + }); +}; + +export const hostOnly = (req, res, next) => { + if (isAllowedHost(req)) return next(); + return fail(res, { code: 'FORBIDDEN', message: 'Bu host yetkili değil' }, 403); +}; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..e7954f5 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,17 @@ +import rateLimit from 'express-rate-limit'; +import { env } from '../config/env.js'; +import { fail } from '../utils/response.js'; + +const createLimiter = (windowMs, max) => + rateLimit({ + windowMs, + max, + standardHeaders: true, + legacyHeaders: false, + trustProxy: env.trustProxy, + handler: (req, res) => + fail(res, { code: 'RATE_LIMITED', message: 'Çok fazla istek yaptınız, lütfen bekleyin.' }, 429) + }); + +export const globalRateLimiter = createLimiter(env.rateLimitWindowMs, env.rateLimitMax); +export const loginRateLimiter = createLimiter(env.loginRateLimitWindowMs, env.loginRateLimitMax); diff --git a/src/middleware/userRequired.js b/src/middleware/userRequired.js new file mode 100644 index 0000000..8e199c8 --- /dev/null +++ b/src/middleware/userRequired.js @@ -0,0 +1,8 @@ +import { fail } from '../utils/response.js'; + +export const userRequired = (req, res, next) => { + if (req.user?.roleEffective !== 'user') { + return fail(res, { code: 'FORBIDDEN', message: 'Bu işlem için yalnızca user rolü geçerli' }, 403); + } + return next(); +}; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 0000000..1ba7f4a --- /dev/null +++ b/src/routes/auth.routes.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { body } from 'express-validator'; +import { register, login, logout, me, refresh } from '../controllers/auth.controller.js'; +import { authRequired } from '../middleware/authRequired.js'; +import { loginRateLimiter } from '../middleware/rateLimit.js'; + +const router = Router(); + +router.post( + '/register', + [ + body('email').isEmail().withMessage('Geçerli bir email girin'), + body('name').notEmpty().withMessage('İsim zorunlu'), + body('username').isLength({ min: 3 }).withMessage('Kullanıcı adı en az 3 karakter olmalı'), + body('password').isLength({ min: 8 }).withMessage('Parola en az 8 karakter olmalı') + ], + register +); + +router.post( + '/login', + loginRateLimiter, + [ + body('emailOrUsername').notEmpty().withMessage('Email veya kullanıcı adı gerekli'), + body('password').isLength({ min: 8 }).withMessage('Parola en az 8 karakter olmalı') + ], + login +); + +router.post('/logout', authRequired, logout); +router.get('/me', authRequired, me); +router.post('/refresh', authRequired, refresh); + +export default router; diff --git a/src/routes/health.routes.js b/src/routes/health.routes.js new file mode 100644 index 0000000..bc07a47 --- /dev/null +++ b/src/routes/health.routes.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; + +const router = Router(); + +router.get('/health', (req, res) => { + res.json({ ok: true, time: new Date().toISOString() }); +}); + +export default router; diff --git a/src/routes/meta.routes.js b/src/routes/meta.routes.js new file mode 100644 index 0000000..ae9eaa4 --- /dev/null +++ b/src/routes/meta.routes.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { authRequired } from '../middleware/authRequired.js'; +import { userRequired } from '../middleware/userRequired.js'; +import { handleMeta, validateMetaRequest } from '../controllers/meta.controller.js'; + +const router = Router(); + +router.post('/meta/scrape', authRequired, userRequired, validateMetaRequest, handleMeta); + +export default router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f5124cf --- /dev/null +++ b/src/server.js @@ -0,0 +1,85 @@ +import http from 'http'; +import { Server } from 'socket.io'; +import app from './app.js'; +import { env } from './config/env.js'; +import { verifyToken } from './utils/crypto.js'; +import { getSession } from './services/session.service.js'; +import { getEffectiveRole } from './services/adminLock.service.js'; + +const server = http.createServer(app); + +const corsOriginCheck = (origin, callback) => { + if (!origin) return callback(null, true); + try { + const hostname = new URL(origin).hostname; + if (env.hostOnlyAllowlist.includes(hostname)) { + return callback(null, true); + } + return callback(new Error('CORS engellendi')); + } catch (err) { + return callback(new Error('CORS engellendi')); + } +}; + +const io = new Server(server, { + cors: { + origin: corsOriginCheck, + credentials: true + } +}); + +const parseCookie = (cookieHeader) => { + if (!cookieHeader) return {}; + return cookieHeader.split(';').reduce((acc, part) => { + const [key, ...rest] = part.trim().split('='); + acc[key] = rest.join('='); + return acc; + }, {}); +}; + +io.use(async (socket, next) => { + try { + const host = socket.handshake.headers.host?.split(':')[0]; + if (host && !env.hostOnlyAllowlist.includes(host)) { + return next(new Error('HOST_FORBIDDEN')); + } + + const cookies = parseCookie(socket.handshake.headers.cookie || ''); + const token = cookies[env.cookieName]; + if (!token) { + return next(new Error('UNAUTHORIZED')); + } + const decoded = verifyToken(token); + if (!decoded?.jti) { + return next(new Error('UNAUTHORIZED')); + } + const session = await getSession(decoded.jti); + if (!session) { + return next(new Error('SESSION_EXPIRED')); + } + const roleEffective = getEffectiveRole({ email: decoded.email, username: decoded.username }); + socket.user = { + id: decoded.sub, + email: decoded.email, + username: decoded.username, + roleEffective + }; + socket.auth = { jti: decoded.jti }; + return next(); + } catch (err) { + return next(new Error('UNAUTHORIZED')); + } +}); + +io.on('connection', (socket) => { + socket.emit('welcome', { message: 'Socket bağlantısı doğrulandı', user: socket.user }); + + socket.on('ping', () => { + socket.emit('pong', { time: Date.now() }); + }); +}); + +server.listen(env.port, () => { + // eslint-disable-next-line no-console + console.log(`API ${env.port} portunda çalışıyor`); +}); diff --git a/src/services/adminLock.service.js b/src/services/adminLock.service.js new file mode 100644 index 0000000..60f28ac --- /dev/null +++ b/src/services/adminLock.service.js @@ -0,0 +1,14 @@ +import { env } from '../config/env.js'; + +export const isEnvAdmin = (user) => { + if (!env.adminRoleLock) return false; + if (!user) return false; + return ( + Boolean(env.adminEmail) && + Boolean(env.adminUsername) && + user.email === env.adminEmail && + user.username === env.adminUsername + ); +}; + +export const getEffectiveRole = (user) => (isEnvAdmin(user) ? 'admin' : 'user'); diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 0000000..c2c59ed --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,90 @@ +import { supabase } from '../config/supabase.js'; +import { comparePassword, hashPassword } from '../utils/crypto.js'; +import { createSession } from './session.service.js'; +import { getEffectiveRole } from './adminLock.service.js'; + +const USERS_TABLE = 'users'; + +const buildError = (status, code, message, details) => { + const err = new Error(message); + err.status = status; + err.code = code; + if (details) err.details = details; + return err; +}; + +const normalizeUser = (record) => { + if (!record) return null; + return { + id: record.id, + email: record.email, + name: record.name, + username: record.username, + role: record.role || 'user' + }; +}; + +const fetchExistingUser = async (email, username) => { + const { data, error } = await supabase + .from(USERS_TABLE) + .select('id,email,username') + .or(`email.eq.${email},username.eq.${username}`); + if (error) throw buildError(500, 'SUPABASE_ERROR', 'Kullanıcı kontrolü başarısız', error.message); + return data; +}; + +const fetchByEmailOrUsername = async (emailOrUsername) => { + const { data, error } = await supabase + .from(USERS_TABLE) + .select('id,email,name,username,password_hash,role') + .or(`email.eq.${emailOrUsername},username.eq.${emailOrUsername}`) + .single(); + if (error) { + if (error.code === 'PGRST116') { + // kayıt bulunamadı + return null; + } + throw buildError(500, 'SUPABASE_ERROR', 'Kullanıcı sorgusu başarısız', error.message); + } + return data; +}; + +export const registerUser = async ({ email, name, username, password, userAgent, ip }) => { + const existing = await fetchExistingUser(email, username); + if (existing && existing.length > 0) { + throw buildError(400, 'USER_EXISTS', 'Email veya kullanıcı adı zaten kullanılıyor'); + } + + const password_hash = await hashPassword(password); + const { data, error } = await supabase + .from(USERS_TABLE) + .insert([{ email, name, username, password_hash, role: 'user' }]) + .select('id,email,name,username,role') + .single(); + + if (error) { + throw buildError(500, 'SUPABASE_ERROR', 'Kullanıcı oluşturulamadı', error.message); + } + + const user = normalizeUser(data); + const { token, cookieOptions, jti, roleEffective } = await createSession(user, { userAgent, ip }); + + return { user: { ...user, roleEffective }, token, cookieOptions, jti }; +}; + +export const loginUser = async ({ emailOrUsername, password, userAgent, ip }) => { + const record = await fetchByEmailOrUsername(emailOrUsername); + if (!record) { + throw buildError(401, 'INVALID_CREDENTIALS', 'Kullanıcı bulunamadı veya parola hatalı'); + } + + const passwordOk = await comparePassword(password, record.password_hash); + if (!passwordOk) { + throw buildError(401, 'INVALID_CREDENTIALS', 'Kullanıcı bulunamadı veya parola hatalı'); + } + + const user = normalizeUser(record); + const roleEffective = getEffectiveRole(user); + const { token, cookieOptions, jti } = await createSession(user, { userAgent, ip }); + return { user: { ...user, roleEffective }, token, cookieOptions, jti }; +}; diff --git a/src/services/mediaData.service.js b/src/services/mediaData.service.js new file mode 100644 index 0000000..8099b93 --- /dev/null +++ b/src/services/mediaData.service.js @@ -0,0 +1,39 @@ +import { supabase } from '../config/supabase.js'; + +const TABLE = 'media_data'; + +const buildError = (status, code, message, details) => { + const err = new Error(message); + err.status = status; + err.code = code; + if (details) err.details = details; + return err; +}; + +export const saveMediaData = async ({ + media_id, + media_name, + year, + type, + thumbnail_url, + info, + genre, + media_provider +}) => { + const payload = { + media_id, + media_name, + year, + type, + thumbnail_url, + info, + genre, + media_provider + }; + + const { data, error } = await supabase.from(TABLE).insert([payload]).select('*').single(); + if (error) { + throw buildError(500, 'SUPABASE_ERROR', 'Media kaydedilemedi', error.message); + } + return data; +}; diff --git a/src/services/meta.service.js b/src/services/meta.service.js new file mode 100644 index 0000000..acc133c --- /dev/null +++ b/src/services/meta.service.js @@ -0,0 +1,69 @@ +import { scraperNetflix, scraperPrime } from 'metascraper'; +import net from 'net'; + +const ALLOWED_HOSTS = ['netflix.com', 'www.netflix.com', 'primevideo.com', 'www.primevideo.com']; +const MAX_URL_LENGTH = 2048; + +const buildError = (status, code, message) => { + const err = new Error(message); + err.status = status; + err.code = code; + return err; +}; + +const getHost = (urlString) => { + if (!urlString || urlString.length > MAX_URL_LENGTH) return null; + try { + const parsed = new URL(urlString); + return parsed.hostname; + } catch (err) { + return null; + } +}; + +const isIpHost = (hostname) => Boolean(net.isIP(hostname)); + +const isLocalhost = (hostname) => + ['localhost', '127.0.0.1', '::1'].includes(hostname.toLowerCase()); + +const isAllowedHost = (hostname) => ALLOWED_HOSTS.includes(hostname.toLowerCase()); + +const isAllowedProtocol = (urlString) => { + try { + const parsed = new URL(urlString); + return ['https:', 'http:'].includes(parsed.protocol); + } catch (err) { + return false; + } +}; + +export const scrapeMedia = async (url) => { + const hostname = getHost(url); + if (!hostname) { + throw buildError(400, 'INVALID_URL', 'Geçersiz URL'); + } + + if (!isAllowedProtocol(url)) { + throw buildError(400, 'INVALID_PROTOCOL', 'Sadece http/https desteklenir'); + } + + if (isIpHost(hostname) || isLocalhost(hostname)) { + throw buildError(400, 'UNSUPPORTED_DOMAIN', 'Desteklenmeyen domain'); + } + + if (!isAllowedHost(hostname)) { + throw buildError(400, 'UNSUPPORTED_DOMAIN', 'Desteklenmeyen domain'); + } + + if (hostname.endsWith('netflix.com')) { + const data = await scraperNetflix(url); + return { provider: 'netflix', data }; + } + + if (hostname.endsWith('primevideo.com')) { + const data = await scraperPrime(url); + return { provider: 'primevideo', data }; + } + + throw buildError(400, 'UNSUPPORTED_DOMAIN', 'Desteklenmeyen domain'); +}; diff --git a/src/services/session.service.js b/src/services/session.service.js new file mode 100644 index 0000000..42b8168 --- /dev/null +++ b/src/services/session.service.js @@ -0,0 +1,74 @@ +import crypto from 'crypto'; +import { redis } from '../config/redis.js'; +import { env } from '../config/env.js'; +import { signToken, expiresInToMs } from '../utils/crypto.js'; +import { getEffectiveRole } from './adminLock.service.js'; + +const SESSION_PREFIX = 'session:'; + +const sessionKey = (jti) => `${SESSION_PREFIX}${jti}`; + +export const createSession = async (user, { userAgent, ip } = {}) => { + const jti = crypto.randomUUID(); + const issuedAt = Date.now(); + const maxAgeMs = expiresInToMs(env.jwtExpiresIn); + const expiresAt = maxAgeMs ? issuedAt + maxAgeMs : undefined; + const roleEffective = getEffectiveRole(user); + + const token = signToken( + { + sub: user.id, + email: user.email, + username: user.username, + roleEffective + }, + { jwtid: jti } + ); + + const sessionData = { + userId: user.id, + issuedAt, + expiresAt, + userAgent, + ip + }; + + const ttlSeconds = maxAgeMs ? Math.ceil(maxAgeMs / 1000) : undefined; + if (ttlSeconds) { + await redis.set(sessionKey(jti), JSON.stringify(sessionData), 'EX', ttlSeconds); + } else { + await redis.set(sessionKey(jti), JSON.stringify(sessionData)); + } + + const cookieOptions = { + httpOnly: true, + sameSite: 'lax', + secure: env.cookieSecure || env.nodeEnv === 'production', + ...(maxAgeMs ? { maxAge: maxAgeMs } : {}) + }; + + return { token, cookieOptions, jti, roleEffective, sessionData }; +}; + +export const getSession = async (jti) => { + if (!jti) return null; + const raw = await redis.get(sessionKey(jti)); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (err) { + return null; + } +}; + +export const removeSession = async (jti) => { + if (!jti) return; + await redis.del(sessionKey(jti)); +}; + +export const refreshSession = async (jti) => { + const maxAgeMs = expiresInToMs(env.jwtExpiresIn); + if (!maxAgeMs || !jti) return; + const ttlSeconds = Math.ceil(maxAgeMs / 1000); + await redis.expire(sessionKey(jti), ttlSeconds); +}; diff --git a/src/utils/crypto.js b/src/utils/crypto.js new file mode 100644 index 0000000..fc0894e --- /dev/null +++ b/src/utils/crypto.js @@ -0,0 +1,37 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { env } from '../config/env.js'; + +const TIME_MULTIPLIERS = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000 +}; + +export const hashPassword = async (password) => bcrypt.hash(password, 10); + +export const comparePassword = async (password, hash) => bcrypt.compare(password, hash); + +export const signToken = (payload, options = {}) => + jwt.sign(payload, env.jwtSecret, { + expiresIn: env.jwtExpiresIn, + ...options + }); + +export const verifyToken = (token) => jwt.verify(token, env.jwtSecret); + +export const expiresInToMs = (value) => { + if (!value) return 0; + if (typeof value === 'number') return value * 1000; + const trimmed = String(value).trim(); + const directNumber = Number(trimmed); + if (!Number.isNaN(directNumber)) return directNumber * 1000; + + const match = trimmed.match(/^(\d+)(ms|s|m|h|d)$/i); + if (!match) return 0; + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + return amount * (TIME_MULTIPLIERS[unit] || 0); +}; diff --git a/src/utils/response.js b/src/utils/response.js new file mode 100644 index 0000000..3bda27f --- /dev/null +++ b/src/utils/response.js @@ -0,0 +1,10 @@ +export const success = (res, data = {}, status = 200) => res.status(status).json(data); + +export const fail = (res, { code = 'error', message = 'Beklenmeyen hata', details }, status = 400) => + res.status(status).json({ + error: { + code, + message, + ...(details ? { details } : {}) + } + });