first commit

This commit is contained in:
2025-11-23 20:04:00 +03:00
commit 4c8c468acd
31 changed files with 1380 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
npm-debug.log
yarn-error.log
.env
.git
.vscode
logs
coverage
dist

26
.env.example Normal file
View File

@@ -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

62
.gitignore vendored Normal file
View File

@@ -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

13
Dockerfile Normal file
View File

@@ -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"]

307
README.md Normal file
View File

@@ -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 repodan 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=<token>` olarak set edilir.
**Me**
```
GET {{baseUrl}}/auth/me
Cookie: sid=<token>
```
**200**:
```
{
"user": {
"id": "...",
"email": "...",
"username": "...",
"roleEffective": "user"
}
}
```
**Logout**
```
POST {{baseUrl}}/auth/logout
Cookie: sid=<token>
```
**200**:
```
{ "message": "Çıkış yapıldı" }
```
**Meta scrape (Netflix/Prime)**
```
POST {{baseUrl}}/meta/scrape
Content-Type: application/json
Cookie: sid=<token> # 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 allowlistteki 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 cookiedeki 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 policylerle ek güvenlik.
- Testler (Jest/Supertest) ve CI pipelineı.

30
docker-compose.dev.yml Normal file
View File

@@ -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:

27
docker-compose.yml Normal file
View File

@@ -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:

30
package.json Normal file
View File

@@ -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"
}
}

56
src/app.js Normal file
View File

@@ -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;

57
src/config/env.js Normal file
View File

@@ -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';

15
src/config/redis.js Normal file
View File

@@ -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);
});

8
src/config/supabase.js Normal file
View File

@@ -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);

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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();
};

View File

@@ -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);
}
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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();
};

34
src/routes/auth.routes.js Normal file
View File

@@ -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;

View File

@@ -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;

10
src/routes/meta.routes.js Normal file
View File

@@ -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;

85
src/server.js Normal file
View File

@@ -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`);
});

View File

@@ -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');

View File

@@ -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 };
};

View File

@@ -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;
};

View File

@@ -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');
};

View File

@@ -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);
};

37
src/utils/crypto.js Normal file
View File

@@ -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);
};

10
src/utils/response.js Normal file
View File

@@ -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 } : {})
}
});