JWT, server modüler hale getirildi, Torrent durumu kalıcı hale getirildi.
This commit is contained in:
7
.env-example
Normal file
7
.env-example
Normal file
@@ -0,0 +1,7 @@
|
||||
USERNAME=madafaka
|
||||
PASSWORD=superpassword
|
||||
VITE_API=http://localhost:3001
|
||||
TMDB_API_KEY="..."
|
||||
TVDB_API_KEY="..."
|
||||
VIDEO_THUMBNAIL_TIME=10
|
||||
FANART_TV_API_KEY=".."
|
||||
@@ -9,12 +9,12 @@
|
||||
import Movies from "./routes/Movies.svelte";
|
||||
import TvShows from "./routes/TvShows.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import { API } from "./utils/api.js";
|
||||
import { API, getAccessToken } from "./utils/api.js";
|
||||
import { refreshMovieCount } from "./stores/movieStore.js";
|
||||
import { refreshTvShowCount } from "./stores/tvStore.js";
|
||||
import { fetchTrashItems } from "./stores/trashStore.js";
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const token = getAccessToken();
|
||||
|
||||
let menuOpen = false;
|
||||
let wsCounts;
|
||||
@@ -47,7 +47,7 @@
|
||||
refreshMovieCount();
|
||||
refreshTvShowCount();
|
||||
fetchTrashItems();
|
||||
const authToken = localStorage.getItem("token");
|
||||
const authToken = getAccessToken();
|
||||
if (authToken) {
|
||||
const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`;
|
||||
try {
|
||||
|
||||
@@ -759,6 +759,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceLabel = cleanFileName(source.displayName || source.name || normalizedSource);
|
||||
const targetLabel = cleanFileName(target.displayName || target.name || normalizedTarget);
|
||||
if (!confirm(`"${sourceLabel}" öğesini "${targetLabel}" içine taşımak istiyor musun?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await moveEntry(normalizedSource, normalizedTarget);
|
||||
if (!result?.success) {
|
||||
@@ -1588,6 +1594,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceLabel = cleanFileName(clipboardItem.displayName || clipboardItem.name || normalizedSource);
|
||||
const targetLabel = cleanFileName(currentPath || normalizedTarget);
|
||||
const actionLabel = clipboardOperation === 'cut' ? 'taşı' : 'kopyala';
|
||||
if (!confirm(`"${sourceLabel}" öğesini "${targetLabel}" konumuna ${actionLabel}mak istiyor musun?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (clipboardOperation === 'cut') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { API } from "../utils/api.js";
|
||||
import { API, persistTokens, clearTokens } from "../utils/api.js";
|
||||
import logo from "../assets/image/logo.png";
|
||||
|
||||
let username = "";
|
||||
@@ -14,11 +14,14 @@
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const { token } = await res.json();
|
||||
localStorage.setItem("token", token);
|
||||
const { accessToken, refreshToken, user } = await res.json();
|
||||
persistTokens({ accessToken, refreshToken });
|
||||
if (accessToken) localStorage.setItem("token", accessToken); // Geçiş dönemi uyumluluğu
|
||||
if (user) localStorage.setItem("user", JSON.stringify(user));
|
||||
window.location.reload();
|
||||
} else {
|
||||
error = "Kullanıcı adı veya şifre hatalı.";
|
||||
clearTokens();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { API, apiFetch } from "../utils/api.js"; // ✅ apiFetch eklendi
|
||||
import { API, apiFetch, getAccessToken, withToken } from "../utils/api.js"; // ✅ apiFetch eklendi
|
||||
|
||||
let torrents = [];
|
||||
let ws;
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
// --- WebSocket & API ---
|
||||
function wsConnect() {
|
||||
const token = localStorage.getItem("token"); // 🔒 token ekle
|
||||
const url = `${API.replace("http", "ws")}?token=${token}`;
|
||||
const token = getAccessToken();
|
||||
const url = `${API.replace("http", "ws")}?token=${token || ""}`;
|
||||
ws = new WebSocket(url);
|
||||
ws.onmessage = (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
@@ -157,8 +157,8 @@
|
||||
}
|
||||
|
||||
function streamURL(hash, index = 0) {
|
||||
const token = localStorage.getItem("token");
|
||||
return `${API}/stream/${hash}?index=${index}&token=${token}`;
|
||||
const base = `${API}/stream/${hash}?index=${index}`;
|
||||
return withToken(base);
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec) {
|
||||
@@ -463,7 +463,7 @@
|
||||
<div class="torrent" on:click={() => openModal(t)}>
|
||||
{#if t.thumbnail}
|
||||
<img
|
||||
src={`${API}${t.thumbnail}?token=${localStorage.getItem("token")}`}
|
||||
src={withToken(`${API}${t.thumbnail}`)}
|
||||
alt="thumb"
|
||||
class="thumb"
|
||||
on:load={(e) => e.target.classList.add("loaded")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount, tick } from "svelte";
|
||||
import { API } from "../utils/api.js";
|
||||
import { API, getAccessToken } from "../utils/api.js";
|
||||
import { cleanFileName } from "../utils/filename.js";
|
||||
import { refreshMovieCount } from "../stores/movieStore.js";
|
||||
import { refreshTvShowCount } from "../stores/tvStore.js";
|
||||
@@ -81,6 +81,14 @@
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function buildThumbnailUrl(item) {
|
||||
const token = getAccessToken();
|
||||
const cacheBuster = `t=${Date.now()}`;
|
||||
const authPart = token ? `token=${token}` : null;
|
||||
const query = [authPart, cacheBuster].filter(Boolean).join("&");
|
||||
return `${API}${item.thumbnail}?${query}`;
|
||||
}
|
||||
|
||||
function toggleView() {
|
||||
viewMode = viewMode === "grid" ? "list" : "grid";
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -410,7 +418,7 @@
|
||||
{:else}
|
||||
{#if item.thumbnail}
|
||||
<img
|
||||
src={`${API}${item.thumbnail}?token=${localStorage.getItem("token")}&t=${Date.now()}`}
|
||||
src={buildThumbnailUrl(item)}
|
||||
alt={item.name}
|
||||
class="thumb"
|
||||
on:load={(e) => e.target.classList.add("loaded")}
|
||||
|
||||
@@ -1,22 +1,72 @@
|
||||
const apiBase = import.meta.env.VITE_API;
|
||||
export const API = apiBase || window.location.origin;
|
||||
|
||||
const ACCESS_TOKEN_KEY = "accessToken";
|
||||
const REFRESH_TOKEN_KEY = "refreshToken";
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function persistTokens({ accessToken, refreshToken }) {
|
||||
if (accessToken) localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
export function withToken(url) {
|
||||
const token = getAccessToken();
|
||||
if (!token) return url;
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}token=${token}`;
|
||||
}
|
||||
|
||||
// 🔐 Ortak kimlik doğrulama başlığı (token varsa ekler)
|
||||
export function authHeaders() {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = getAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 🔧 Yardımcı fetch (otomatik token ekler, hata durumunda logout)
|
||||
export async function apiFetch(path, options = {}) {
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
const res = await fetch(`${API}/api/token/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const { accessToken } = await res.json();
|
||||
if (accessToken) {
|
||||
persistTokens({ accessToken });
|
||||
}
|
||||
return accessToken || null;
|
||||
}
|
||||
|
||||
// 🔧 Yardımcı fetch (otomatik token ekler, 401'de refresh dener)
|
||||
export async function apiFetch(path, options = {}, retry = true) {
|
||||
const headers = { ...(options.headers || {}), ...authHeaders() };
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers });
|
||||
|
||||
// Token süresi dolmuşsa veya yanlışsa kullanıcıyı çıkışa yönlendir
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
if (res.status === 401 && retry) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
const retryHeaders = { ...(options.headers || {}), ...authHeaders() };
|
||||
return fetch(`${API}${path}`, { ...options, headers: retryHeaders });
|
||||
}
|
||||
clearTokens();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
221
server/modules/auth.js
Normal file
221
server/modules/auth.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import express from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
|
||||
const DEFAULT_ACCESS_TTL = process.env.JWT_TTL || "15m";
|
||||
const DEFAULT_REFRESH_TTL = process.env.JWT_REFRESH_TTL || "30d";
|
||||
const ITERATIONS = 120000;
|
||||
const KEY_LEN = 64;
|
||||
const DIGEST = "sha512";
|
||||
|
||||
function ensureDir(target) {
|
||||
const dir = path.dirname(target);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonSafe(filePath, fallback = null) {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JSON okunamadı (${filePath}): ${err.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
ensureDir(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JSON yazılamadı (${filePath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPasswordHash(password, salt = crypto.randomBytes(16).toString("hex")) {
|
||||
const hash = crypto
|
||||
.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST)
|
||||
.toString("hex");
|
||||
return { hash, salt, iterations: ITERATIONS, keylen: KEY_LEN, digest: DIGEST };
|
||||
}
|
||||
|
||||
function verifyPassword(password, user) {
|
||||
if (!user?.password) return false;
|
||||
const { salt, iterations, keylen, digest, hash } = user.password;
|
||||
const candidate = crypto
|
||||
.pbkdf2Sync(password, salt, iterations, keylen, digest)
|
||||
.toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(hash));
|
||||
}
|
||||
|
||||
function loadSecret(secretPath) {
|
||||
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
||||
if (secretPath && fs.existsSync(secretPath)) {
|
||||
try {
|
||||
return fs.readFileSync(secretPath, "utf-8").trim();
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JWT secret okunamadı (${secretPath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
const generated = crypto.randomBytes(48).toString("hex");
|
||||
if (secretPath) {
|
||||
try {
|
||||
ensureDir(secretPath);
|
||||
fs.writeFileSync(secretPath, generated, "utf-8");
|
||||
console.log("🔑 Yeni JWT secret oluşturuldu (diskte saklandı).");
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JWT secret yazılamadı (${secretPath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
function loadUsers(usersPath, defaultUser) {
|
||||
let users = readJsonSafe(usersPath, []);
|
||||
if (!Array.isArray(users)) users = [];
|
||||
|
||||
if (defaultUser?.username && defaultUser?.password) {
|
||||
const exists = users.some((u) => u.username === defaultUser.username);
|
||||
if (!exists) {
|
||||
const password = buildPasswordHash(defaultUser.password);
|
||||
users.push({
|
||||
username: defaultUser.username,
|
||||
role: defaultUser.role || "admin",
|
||||
password
|
||||
});
|
||||
writeJsonSafe(usersPath, users);
|
||||
console.log(`👤 Varsayılan kullanıcı eklendi: ${defaultUser.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
export function createAuth({ usersPath, secretPath }) {
|
||||
const secret = loadSecret(secretPath);
|
||||
const users = loadUsers(usersPath, {
|
||||
username: process.env.USERNAME || "admin",
|
||||
password: process.env.PASSWORD || "dupe",
|
||||
role: "admin"
|
||||
});
|
||||
|
||||
function signToken(payload, opts = {}) {
|
||||
const jwtOpts = {
|
||||
issuer: "dupe",
|
||||
audience: "dupe-clients",
|
||||
expiresIn: opts.expiresIn || DEFAULT_ACCESS_TTL
|
||||
};
|
||||
// subject zaten payload.sub içinde ise tekrar opsiyonlara eklemeyelim
|
||||
if (!payload.sub && payload.username) {
|
||||
jwtOpts.subject = payload.username;
|
||||
}
|
||||
return jwt.sign(payload, secret, jwtOpts);
|
||||
}
|
||||
|
||||
function verifyToken(token, expectedType = "access") {
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
issuer: "dupe",
|
||||
audience: "dupe-clients"
|
||||
});
|
||||
if (expectedType && decoded.type !== expectedType) return null;
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function issueTokens(user) {
|
||||
const base = { sub: user.username, role: user.role || "user" };
|
||||
const accessToken = signToken({ ...base, type: "access" }, { expiresIn: DEFAULT_ACCESS_TTL });
|
||||
const refreshToken = signToken({ ...base, type: "refresh" }, { expiresIn: DEFAULT_REFRESH_TTL });
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || "";
|
||||
const bearer = header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
const token = bearer || req.query.token;
|
||||
if (!token) return res.status(401).json({ error: "Unauthorized" });
|
||||
const decoded = verifyToken(token, req.path.startsWith("/media/") ? null : "access");
|
||||
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
|
||||
req.user = decoded;
|
||||
next();
|
||||
}
|
||||
|
||||
function requireRole(role) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
const roles = Array.isArray(role) ? role : [role];
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function issueMediaToken(targetPath, ttlSeconds = 3600) {
|
||||
const expiresIn = Math.min(Math.max(Number(ttlSeconds) || 3600, 60), 72 * 3600);
|
||||
return signToken(
|
||||
{ type: "media", path: targetPath || "*", role: "media" },
|
||||
{ expiresIn }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyMediaToken(token, requestedPath) {
|
||||
const decoded = verifyToken(token, "media");
|
||||
if (!decoded) return null;
|
||||
if (decoded.path && decoded.path !== "*" && requestedPath) {
|
||||
if (!requestedPath.startsWith(decoded.path)) return null;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/api/login", (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: "username ve password gerekli" });
|
||||
}
|
||||
const user = users.find((u) => u.username === username);
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
const { accessToken, refreshToken } = issueTokens(user);
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { username: user.username, role: user.role || "user" }
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/api/token/refresh", (req, res) => {
|
||||
const { refreshToken } = req.body || {};
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: "refreshToken gerekli" });
|
||||
}
|
||||
const decoded = verifyToken(refreshToken, "refresh");
|
||||
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
|
||||
const accessToken = signToken(
|
||||
{ sub: decoded.sub, role: decoded.role, type: "access" },
|
||||
{ expiresIn: DEFAULT_ACCESS_TTL }
|
||||
);
|
||||
res.json({ accessToken });
|
||||
});
|
||||
|
||||
router.get("/api/auth/profile", requireAuth, (req, res) => {
|
||||
res.json({ user: { username: req.user.sub, role: req.user.role } });
|
||||
});
|
||||
|
||||
return {
|
||||
router,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
issueMediaToken,
|
||||
verifyMediaToken,
|
||||
verifyToken
|
||||
};
|
||||
}
|
||||
38
server/modules/health.js
Normal file
38
server/modules/health.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function checkBinary(binary) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`which ${binary}`);
|
||||
const location = stdout.trim();
|
||||
return { name: binary, ok: Boolean(location), location: location || null };
|
||||
} catch (err) {
|
||||
return { name: binary, ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildHealthReport({ ffmpegPath = "ffmpeg", ffprobePath = "ffprobe", tmdbKey, tvdbKey, fanartKey }) {
|
||||
const binaries = await Promise.all([
|
||||
checkBinary(ffmpegPath),
|
||||
checkBinary(ffprobePath)
|
||||
]);
|
||||
|
||||
return {
|
||||
binaries,
|
||||
apis: {
|
||||
tmdb: { configured: Boolean(tmdbKey) },
|
||||
tvdb: { configured: Boolean(tvdbKey) },
|
||||
fanart: { configured: Boolean(fanartKey) }
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function healthRouter(getReport) {
|
||||
return (req, res) => {
|
||||
const report = typeof getReport === "function" ? getReport() : null;
|
||||
res.json(report || { status: "unknown" });
|
||||
};
|
||||
}
|
||||
61
server/modules/state.js
Normal file
61
server/modules/state.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function discoverSavedTorrents(downloadDir) {
|
||||
if (!downloadDir || !fs.existsSync(downloadDir)) return [];
|
||||
const entries = fs.readdirSync(downloadDir, { withFileTypes: true });
|
||||
const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
const candidates = [];
|
||||
|
||||
for (const name of folders) {
|
||||
const savePath = path.join(downloadDir, name);
|
||||
const infoPath = path.join(savePath, "info.json");
|
||||
if (!fs.existsSync(infoPath)) continue;
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
|
||||
const magnetURI = info.magnetURI || info.magnet || null;
|
||||
const infoHash = info.infoHash || null;
|
||||
candidates.push({
|
||||
folder: name,
|
||||
savePath,
|
||||
infoPath,
|
||||
infoHash,
|
||||
magnetURI,
|
||||
added: info.added || fs.statSync(infoPath).mtimeMs,
|
||||
name: info.name || name
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ info.json okunamadı (${infoPath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function restoreTorrentsFromDisk({ downloadDir, client, register }) {
|
||||
const candidates = discoverSavedTorrents(downloadDir);
|
||||
const restored = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.magnetURI) {
|
||||
console.warn(`⚠️ ${candidate.folder} için magnetURI yok, atlanıyor.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const torrent = client.add(candidate.magnetURI, {
|
||||
path: candidate.savePath,
|
||||
announce: []
|
||||
});
|
||||
if (typeof register === "function") {
|
||||
register(torrent, {
|
||||
savePath: candidate.savePath,
|
||||
added: candidate.added,
|
||||
restored: true
|
||||
});
|
||||
}
|
||||
restored.push({ infoHash: torrent.infoHash || candidate.infoHash, folder: candidate.folder });
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ ${candidate.folder} yeniden eklenemedi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
35
server/modules/websocket.js
Normal file
35
server/modules/websocket.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { WebSocketServer } from "ws";
|
||||
import url from "url";
|
||||
|
||||
function parseTokenFromRequest(req) {
|
||||
try {
|
||||
const parsed = url.parse(req.url, true);
|
||||
return parsed.query?.token || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebsocketServer(server, { verifyToken, onMessage }) {
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const token = parseTokenFromRequest(req);
|
||||
const decoded = token && verifyToken ? verifyToken(token, "access") : null;
|
||||
if (!decoded) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.user = decoded;
|
||||
ws.on("message", (msg) => onMessage && onMessage(msg, ws));
|
||||
ws.on("error", (err) => console.warn("🔌 WebSocket error:", err.message));
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
export function broadcastJson(wss, payload) {
|
||||
if (!wss) return;
|
||||
const data = JSON.stringify(payload);
|
||||
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
|
||||
201
server/server.js
201
server/server.js
@@ -5,11 +5,14 @@ import WebTorrent from "webtorrent";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import mime from "mime-types";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { fileURLToPath } from "url";
|
||||
import { exec } from "child_process";
|
||||
import crypto from "crypto"; // 🔒 basit token üretimi için
|
||||
import { getSystemDiskInfo } from "./utils/diskSpace.js";
|
||||
import { createAuth } from "./modules/auth.js";
|
||||
import { buildHealthReport, healthRouter } from "./modules/health.js";
|
||||
import { restoreTorrentsFromDisk } from "./modules/state.js";
|
||||
import { createWebsocketServer, broadcastJson } from "./modules/websocket.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -148,6 +151,37 @@ function ensureDirForFile(filePath) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const AUTH_DATA_DIR = path.join(__dirname, "data");
|
||||
const USERS_FILE = path.join(AUTH_DATA_DIR, "users.json");
|
||||
const JWT_SECRET_FILE = path.join(CACHE_DIR, "jwt-secret");
|
||||
|
||||
let healthSnapshot = null;
|
||||
const auth = createAuth({ usersPath: USERS_FILE, secretPath: JWT_SECRET_FILE });
|
||||
const { router: authRouter, requireAuth, requireRole, issueMediaToken, verifyToken } = auth;
|
||||
|
||||
app.use(authRouter);
|
||||
|
||||
buildHealthReport({
|
||||
ffmpegPath: "ffmpeg",
|
||||
ffprobePath: FFPROBE_PATH,
|
||||
tmdbKey: TMDB_API_KEY,
|
||||
tvdbKey: TVDB_API_KEY,
|
||||
fanartKey: FANART_TV_API_KEY
|
||||
})
|
||||
.then((report) => {
|
||||
healthSnapshot = report;
|
||||
const missing = report.binaries.filter((b) => !b.ok);
|
||||
if (missing.length) {
|
||||
console.warn("⚠️ Eksik bağımlılıklar:", missing.map((m) => m.name).join(", "));
|
||||
}
|
||||
if (!TMDB_API_KEY || !TVDB_API_KEY) {
|
||||
console.warn("⚠️ TMDB/TVDB anahtarları eksik, metadata özellikleri sınırlı olacak.");
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn("⚠️ Sağlık kontrolü çalıştırılamadı:", err.message));
|
||||
|
||||
app.get("/api/health", requireAuth, healthRouter(() => healthSnapshot));
|
||||
|
||||
function tvdbImageUrl(pathSegment) {
|
||||
if (!pathSegment) return null;
|
||||
if (pathSegment.startsWith("http")) return pathSegment;
|
||||
@@ -3268,74 +3302,7 @@ function snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Basit kimlik doğrulama sistemi ---
|
||||
const USERNAME = process.env.USERNAME;
|
||||
const PASSWORD = process.env.PASSWORD;
|
||||
let activeTokens = new Set();
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (username === USERNAME && password === PASSWORD) {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
activeTokens.add(token);
|
||||
return res.json({ token });
|
||||
}
|
||||
res.status(401).json({ error: "Invalid credentials" });
|
||||
});
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const token = req.headers.authorization?.split(" ")[1] || req.query.token;
|
||||
if (!token || !activeTokens.has(token))
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
next();
|
||||
}
|
||||
// --- Güvenli medya URL'i (TV için) ---
|
||||
// Dönen URL segmentleri ayrı ayrı encode eder, slash'ları korur ve tam hostlu URL döner
|
||||
app.get("/api/media-url", requireAuth, (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) return res.status(400).json({ error: "path parametresi gerekli" });
|
||||
|
||||
// TTL saniye olarak (default 3600 = 1 saat). Min 60s, max 72h
|
||||
const ttl = Math.min(Math.max(Number(req.query.ttl) || 3600, 60), 72 * 3600);
|
||||
|
||||
// Medya token oluştur
|
||||
const mediaToken = crypto.randomBytes(16).toString("hex");
|
||||
activeTokens.add(mediaToken);
|
||||
setTimeout(() => activeTokens.delete(mediaToken), ttl * 1000);
|
||||
|
||||
// Her path segmentini ayrı encode et (slash korunur)
|
||||
const encodedPath = String(filePath)
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => encodeURIComponent(s))
|
||||
.join("/");
|
||||
|
||||
const host = req.get("host") || "localhost";
|
||||
const protocol = req.protocol || (req.secure ? "https" : "http");
|
||||
|
||||
const absoluteUrl = `${protocol}://${host}/media/${encodedPath}?token=${mediaToken}`;
|
||||
|
||||
console.log("Generated media URL:", { original: filePath, url: absoluteUrl, ttl });
|
||||
res.json({ url: absoluteUrl, token: mediaToken, expiresIn: ttl });
|
||||
});
|
||||
|
||||
// --- Torrent veya magnet ekleme ---
|
||||
app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
try {
|
||||
let source = req.body.magnet;
|
||||
if (req.file) source = fs.readFileSync(req.file.path);
|
||||
if (!source)
|
||||
return res.status(400).json({ error: "magnet veya .torrent gerekli" });
|
||||
|
||||
// Her torrent için ayrı klasör
|
||||
const savePath = path.join(DOWNLOAD_DIR, Date.now().toString());
|
||||
fs.mkdirSync(savePath, { recursive: true });
|
||||
|
||||
const torrent = client.add(source, { announce: [], path: savePath });
|
||||
|
||||
// 🆕 Torrent eklendiği anda tarih kaydedelim
|
||||
const added = Date.now();
|
||||
|
||||
function wireTorrent(torrent, { savePath, added, respond, restored = false }) {
|
||||
torrents.set(torrent.infoHash, {
|
||||
torrent,
|
||||
selectedIndex: 0,
|
||||
@@ -3344,8 +3311,16 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
paused: false
|
||||
});
|
||||
|
||||
// --- Metadata geldiğinde ---
|
||||
torrent.on("ready", () => {
|
||||
onTorrentReady({ torrent, savePath, added, respond, restored });
|
||||
});
|
||||
|
||||
torrent.on("done", () => {
|
||||
onTorrentDone({ torrent });
|
||||
});
|
||||
}
|
||||
|
||||
function onTorrentReady({ torrent, savePath, added, respond }) {
|
||||
const selectedIndex = pickBestVideoFile(torrent);
|
||||
torrents.set(torrent.infoHash, {
|
||||
torrent,
|
||||
@@ -3360,11 +3335,13 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
name: torrent.name,
|
||||
tracker: torrent.announce?.[0] || null,
|
||||
added,
|
||||
magnetURI: torrent.magnetURI,
|
||||
createdAt: added,
|
||||
folder: rootFolder
|
||||
});
|
||||
broadcastFileUpdate(rootFolder);
|
||||
res.json({
|
||||
|
||||
const payload = {
|
||||
ok: true,
|
||||
infoHash: torrent.infoHash,
|
||||
name: torrent.name,
|
||||
@@ -3376,12 +3353,13 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
name: f.name,
|
||||
length: f.length
|
||||
}))
|
||||
});
|
||||
broadcastSnapshot();
|
||||
});
|
||||
};
|
||||
|
||||
// --- İndirme tamamlandığında thumbnail oluştur ---
|
||||
torrent.on("done", async () => {
|
||||
if (typeof respond === "function") respond(payload);
|
||||
broadcastSnapshot();
|
||||
}
|
||||
|
||||
async function onTorrentDone({ torrent }) {
|
||||
const entry = torrents.get(torrent.infoHash);
|
||||
if (!entry) return;
|
||||
|
||||
@@ -3511,7 +3489,8 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
completedAt: Date.now(),
|
||||
totalBytes: torrent.downloaded,
|
||||
fileCount: torrent.files.length,
|
||||
files: perFileMetadata
|
||||
files: perFileMetadata,
|
||||
magnetURI: torrent.magnetURI
|
||||
};
|
||||
if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath;
|
||||
if (Object.keys(seriesEpisodes).length) {
|
||||
@@ -3573,6 +3552,58 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
}
|
||||
|
||||
broadcastSnapshot();
|
||||
}
|
||||
|
||||
// Auth router ve middleware createAuth ile yüklendi
|
||||
// --- Güvenli medya URL'i (TV için) ---
|
||||
// Dönen URL segmentleri ayrı ayrı encode eder, slash'ları korur ve tam hostlu URL döner
|
||||
app.get("/api/media-url", requireAuth, (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) return res.status(400).json({ error: "path parametresi gerekli" });
|
||||
|
||||
// TTL saniye olarak (default 3600 = 1 saat). Min 60s, max 72h
|
||||
const ttl = Math.min(Math.max(Number(req.query.ttl) || 3600, 60), 72 * 3600);
|
||||
|
||||
// Medya token oluştur
|
||||
const mediaToken = issueMediaToken(filePath, ttl);
|
||||
|
||||
// Her path segmentini ayrı encode et (slash korunur)
|
||||
const encodedPath = String(filePath)
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => encodeURIComponent(s))
|
||||
.join("/");
|
||||
|
||||
const host = req.get("host") || "localhost";
|
||||
const protocol = req.protocol || (req.secure ? "https" : "http");
|
||||
|
||||
const absoluteUrl = `${protocol}://${host}/media/${encodedPath}?token=${mediaToken}`;
|
||||
|
||||
console.log("Generated media URL:", { original: filePath, url: absoluteUrl, ttl });
|
||||
res.json({ url: absoluteUrl, token: mediaToken, expiresIn: ttl });
|
||||
});
|
||||
|
||||
// --- Torrent veya magnet ekleme ---
|
||||
app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
|
||||
try {
|
||||
let source = req.body.magnet;
|
||||
if (req.file) source = fs.readFileSync(req.file.path);
|
||||
if (!source)
|
||||
return res.status(400).json({ error: "magnet veya .torrent gerekli" });
|
||||
|
||||
// Her torrent için ayrı klasör
|
||||
const savePath = path.join(DOWNLOAD_DIR, Date.now().toString());
|
||||
fs.mkdirSync(savePath, { recursive: true });
|
||||
|
||||
const torrent = client.add(source, { announce: [], path: savePath });
|
||||
|
||||
// 🆕 Torrent eklendiği anda tarih kaydedelim
|
||||
const added = Date.now();
|
||||
|
||||
wireTorrent(torrent, {
|
||||
savePath,
|
||||
added,
|
||||
respond: (payload) => res.json(payload)
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -5525,6 +5556,16 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
|
||||
|
||||
console.log("🗄️ Download path:", DOWNLOAD_DIR);
|
||||
|
||||
// Sunucu açılışında mevcut torrentleri yeniden ekle
|
||||
const restored = restoreTorrentsFromDisk({
|
||||
downloadDir: DOWNLOAD_DIR,
|
||||
client,
|
||||
register: (torrent, ctx) => wireTorrent(torrent, ctx)
|
||||
});
|
||||
if (restored.length) {
|
||||
console.log(`♻️ ${restored.length} torrent yeniden eklendi.`);
|
||||
}
|
||||
|
||||
|
||||
// --- ✅ Client build (frontend) dosyalarını sun ---
|
||||
const publicDir = path.join(__dirname, "public");
|
||||
@@ -5540,15 +5581,11 @@ const server = app.listen(PORT, () =>
|
||||
console.log(`🐔 du.pe server ${PORT} portunda çalışıyor`)
|
||||
);
|
||||
|
||||
wss = new WebSocketServer({ server });
|
||||
wss = createWebsocketServer(server, { verifyToken });
|
||||
wss.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
|
||||
// Bağlantı kurulduğunda disk space bilgisi gönder
|
||||
broadcastDiskSpace();
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error("🔌 WebSocket error:", error);
|
||||
});
|
||||
});
|
||||
|
||||
// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla ---
|
||||
|
||||
Reference in New Issue
Block a user