first commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.env
|
||||
.git
|
||||
.vscode
|
||||
logs
|
||||
coverage
|
||||
dist
|
||||
26
.env.example
Normal file
26
.env.example
Normal 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
62
.gitignore
vendored
Normal 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
13
Dockerfile
Normal 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
307
README.md
Normal 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 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=<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 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’ı.
|
||||
30
docker-compose.dev.yml
Normal file
30
docker-compose.dev.yml
Normal 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
27
docker-compose.yml
Normal 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
30
package.json
Normal 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
56
src/app.js
Normal 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
57
src/config/env.js
Normal 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
15
src/config/redis.js
Normal 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
8
src/config/supabase.js
Normal 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);
|
||||
124
src/controllers/auth.controller.js
Normal file
124
src/controllers/auth.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
38
src/controllers/meta.controller.js
Normal file
38
src/controllers/meta.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
8
src/middleware/adminRequired.js
Normal file
8
src/middleware/adminRequired.js
Normal 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();
|
||||
};
|
||||
35
src/middleware/authRequired.js
Normal file
35
src/middleware/authRequired.js
Normal 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);
|
||||
}
|
||||
};
|
||||
17
src/middleware/errorHandler.js
Normal file
17
src/middleware/errorHandler.js
Normal 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);
|
||||
};
|
||||
22
src/middleware/hostOnly.js
Normal file
22
src/middleware/hostOnly.js
Normal 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);
|
||||
};
|
||||
17
src/middleware/rateLimit.js
Normal file
17
src/middleware/rateLimit.js
Normal 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);
|
||||
8
src/middleware/userRequired.js
Normal file
8
src/middleware/userRequired.js
Normal 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
34
src/routes/auth.routes.js
Normal 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;
|
||||
9
src/routes/health.routes.js
Normal file
9
src/routes/health.routes.js
Normal 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
10
src/routes/meta.routes.js
Normal 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
85
src/server.js
Normal 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`);
|
||||
});
|
||||
14
src/services/adminLock.service.js
Normal file
14
src/services/adminLock.service.js
Normal 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');
|
||||
90
src/services/auth.service.js
Normal file
90
src/services/auth.service.js
Normal 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 };
|
||||
};
|
||||
39
src/services/mediaData.service.js
Normal file
39
src/services/mediaData.service.js
Normal 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;
|
||||
};
|
||||
69
src/services/meta.service.js
Normal file
69
src/services/meta.service.js
Normal 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');
|
||||
};
|
||||
74
src/services/session.service.js
Normal file
74
src/services/session.service.js
Normal 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
37
src/utils/crypto.js
Normal 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
10
src/utils/response.js
Normal 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 } : {})
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user