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