diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e21b77 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Multi-stage build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/client/dist ./client/dist 2>/dev/null || true +EXPOSE 5000 +CMD ["npm", "run", "start"] diff --git a/package.json b/package.json index fa83fb0..4db08a6 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,10 @@ "db:push": "drizzle-kit push" }, "dependencies": { - "@google-cloud/storage": "^7.17.0", - "@google-cloud/video-intelligence": "^6.2.0", + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/s3-request-presigner": "^3.700.0", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", - "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -49,6 +48,7 @@ "@types/memoizee": "^0.4.12", "@types/multer": "^2.0.0", "@types/node-fetch": "^2.6.13", + "@types/pg": "^8.11.10", "@types/video.js": "^7.3.58", "@uppy/aws-s3": "^4.3.2", "@uppy/core": "^4.5.3", @@ -57,7 +57,7 @@ "@uppy/file-input": "^4.2.2", "@uppy/progress-bar": "^4.3.2", "@uppy/react": "^4.5.2", - "bcryptjs": "^3.0.2", + "bcryptjs": "^2.4.3", "canvas": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -76,16 +76,15 @@ "hls.js": "^1.6.7", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", - "memoizee": "^0.4.17", "memorystore": "^1.6.7", "multer": "^2.0.2", "next-themes": "^0.4.6", "node-fetch": "^2.0.0", "nodemon": "^3.1.10", "openai": "^5.16.0", - "openid-client": "^6.7.1", "passport": "^0.7.0", "passport-local": "^1.0.0", + "pg": "^8.13.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -103,7 +102,6 @@ "videojs-contrib-ads": "^7.5.2", "videojs-ima": "^2.4.0", "wouter": "^3.3.5", - "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" }, @@ -134,4 +132,4 @@ "optionalDependencies": { "bufferutil": "^4.0.8" } -} +} \ No newline at end of file diff --git a/server/db.ts b/server/db.ts index 66779a9..7b413f9 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,15 +1,10 @@ -import { Pool, neonConfig } from '@neondatabase/serverless'; -import { drizzle } from 'drizzle-orm/neon-serverless'; -import ws from "ws"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; import * as schema from "@shared/schema"; -neonConfig.webSocketConstructor = ws; - if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL must be set. Did you forget to provision a database?", - ); + throw new Error("DATABASE_URL must be set."); } export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -export const db = drizzle({ client: pool, schema }); +export const db = drizzle(pool, { schema }); diff --git a/server/objectStorage.ts b/server/objectStorage.ts index 24d474e..c9459b8 100644 --- a/server/objectStorage.ts +++ b/server/objectStorage.ts @@ -1,26 +1,22 @@ -import { Storage, File } from "@google-cloud/storage"; +import { S3Client, GetObjectCommand, PutObjectCommand, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { Response } from "express"; import { randomUUID } from "crypto"; +import { Readable } from "stream"; -const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106"; +const S3_ENDPOINT = process.env.S3_ENDPOINT || "https://fsn1.your-objectstorage.com"; +const S3_BUCKET = process.env.S3_BUCKET || "folxspeed"; +const S3_PREFIX = process.env.S3_PREFIX || "video-folx-tv"; // prefix within bucket +const S3_PUBLIC_BASE = process.env.S3_PUBLIC_BASE || "https://folxvideos.b-cdn.net"; -// Object storage client for Google Cloud Storage integration -export const objectStorageClient = new Storage({ +const s3 = new S3Client({ + region: process.env.S3_REGION || "fsn1", + endpoint: S3_ENDPOINT, + forcePathStyle: true, credentials: { - audience: "replit", - subject_token_type: "access_token", - token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`, - type: "external_account", - credential_source: { - url: `${REPLIT_SIDECAR_ENDPOINT}/credential`, - format: { - type: "json", - subject_token_field_name: "access_token", - }, - }, - universe_domain: "googleapis.com", + accessKeyId: process.env.S3_ACCESS_KEY_ID || "", + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "", }, - projectId: "", }); export class ObjectNotFoundError extends Error { @@ -34,187 +30,80 @@ export class ObjectNotFoundError extends Error { export class ObjectStorageService { constructor() {} - // Get public object search paths getPublicObjectSearchPaths(): Array { - const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || ""; - const paths = Array.from( - new Set( - pathsStr - .split(",") - .map((path) => path.trim()) - .filter((path) => path.length > 0) - ) - ); - if (paths.length === 0) { - throw new Error( - "PUBLIC_OBJECT_SEARCH_PATHS not set. Object storage not configured." - ); - } - return paths; + return [`${S3_PREFIX}/public`]; } - // Get private object directory getPrivateObjectDir(): string { - const dir = process.env.PRIVATE_OBJECT_DIR || ""; - if (!dir) { - throw new Error( - "PRIVATE_OBJECT_DIR not set. Object storage not configured." - ); - } - return dir; + return `${S3_PREFIX}/.private`; } - // Get upload URL for thumbnail upload async getThumbnailUploadURL(): Promise { - const privateObjectDir = this.getPrivateObjectDir(); const objectId = randomUUID(); - const fullPath = `${privateObjectDir}/thumbnails/${objectId}`; - - const { bucketName, objectName } = parseObjectPath(fullPath); - - return signObjectURL({ - bucketName, - objectName, - method: "PUT", - ttlSec: 900, // 15 minutes + const key = `${this.getPrivateObjectDir()}/thumbnails/${objectId}`; + const cmd = new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + ACL: "public-read", }); + return await getSignedUrl(s3, cmd, { expiresIn: 900 }); } - // Download object to response - async downloadObject(file: File, res: Response, cacheTtlSec: number = 3600) { + async downloadObject(objectKey: string, res: Response, cacheTtlSec: number = 3600) { try { - const [metadata] = await file.getMetadata(); - + const cmd = new GetObjectCommand({ Bucket: S3_BUCKET, Key: objectKey }); + const obj = await s3.send(cmd); res.set({ - "Content-Type": metadata.contentType || "application/octet-stream", - "Content-Length": metadata.size, + "Content-Type": obj.ContentType || "application/octet-stream", + "Content-Length": String(obj.ContentLength || ""), "Cache-Control": `public, max-age=${cacheTtlSec}`, }); - - const stream = file.createReadStream(); - + const stream = obj.Body as Readable; stream.on("error", (err) => { console.error("Stream error:", err); - if (!res.headersSent) { - res.status(500).json({ error: "Error streaming file" }); - } + if (!res.headersSent) res.status(500).json({ error: "Error streaming file" }); }); - stream.pipe(res); } catch (error) { console.error("Error downloading file:", error); - if (!res.headersSent) { - res.status(500).json({ error: "Error downloading file" }); + if (!res.headersSent) res.status(500).json({ error: "Error downloading file" }); + } + } + + async getObjectFile(objectPath: string): Promise { + if (!objectPath.startsWith("/objects/")) throw new ObjectNotFoundError(); + const parts = objectPath.slice(1).split("/"); + if (parts.length < 2) throw new ObjectNotFoundError(); + const entityId = parts.slice(1).join("/"); + const key = `${this.getPrivateObjectDir()}/${entityId}`; + try { + await s3.send(new HeadObjectCommand({ Bucket: S3_BUCKET, Key: key })); + return key; // returns S3 key instead of File object + } catch { + throw new ObjectNotFoundError(); + } + } + + normalizeObjectPath(rawPath: string): string { + // Convert old Replit/GCS URL or full S3 URL into /objects/{entityId} + const privateDir = this.getPrivateObjectDir(); + const patterns = [ + `https://storage.googleapis.com/`, + `${S3_PUBLIC_BASE}/`, + `${S3_ENDPOINT}/${S3_BUCKET}/`, + ]; + let path = rawPath; + for (const p of patterns) { + if (path.startsWith(p)) { + path = "/" + path.slice(p.length); + break; } } - } - - // Get object file from path - async getObjectFile(objectPath: string): Promise { - if (!objectPath.startsWith("/objects/")) { - throw new ObjectNotFoundError(); + const idx = path.indexOf(privateDir); + if (idx >= 0) { + const entityId = path.slice(idx + privateDir.length).replace(/^\//, ""); + return `/objects/${entityId}`; } - - const parts = objectPath.slice(1).split("/"); - if (parts.length < 2) { - throw new ObjectNotFoundError(); - } - - const entityId = parts.slice(1).join("/"); - let entityDir = this.getPrivateObjectDir(); - if (!entityDir.endsWith("/")) { - entityDir = `${entityDir}/`; - } - const objectEntityPath = `${entityDir}${entityId}`; - const { bucketName, objectName } = parseObjectPath(objectEntityPath); - const bucket = objectStorageClient.bucket(bucketName); - const objectFile = bucket.file(objectName); - const [exists] = await objectFile.exists(); - if (!exists) { - throw new ObjectNotFoundError(); - } - return objectFile; - } - - // Normalize object path from storage URL to local path - normalizeObjectPath(rawPath: string): string { - if (!rawPath.startsWith("https://storage.googleapis.com/")) { - return rawPath; - } - - const url = new URL(rawPath); - const rawObjectPath = url.pathname; - - let objectEntityDir = this.getPrivateObjectDir(); - if (!objectEntityDir.endsWith("/")) { - objectEntityDir = `${objectEntityDir}/`; - } - - if (!rawObjectPath.startsWith(objectEntityDir)) { - return rawObjectPath; - } - - const entityId = rawObjectPath.slice(objectEntityDir.length); - return `/objects/${entityId}`; + return rawPath; } } - -// Parse object path to bucket and object name -function parseObjectPath(path: string): { - bucketName: string; - objectName: string; -} { - if (!path.startsWith("/")) { - path = `/${path}`; - } - const pathParts = path.split("/"); - if (pathParts.length < 3) { - throw new Error("Invalid path: must contain at least a bucket name"); - } - - const bucketName = pathParts[1]; - const objectName = pathParts.slice(2).join("/"); - - return { - bucketName, - objectName, - }; -} - -// Sign object URL for upload/download -async function signObjectURL({ - bucketName, - objectName, - method, - ttlSec, -}: { - bucketName: string; - objectName: string; - method: "GET" | "PUT" | "DELETE" | "HEAD"; - ttlSec: number; -}): Promise { - const request = { - bucket_name: bucketName, - object_name: objectName, - method, - expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(), - }; - const response = await fetch( - `${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - if (!response.ok) { - throw new Error( - `Failed to sign object URL, errorcode: ${response.status}` - ); - } - - const { signed_url: signedURL } = await response.json(); - return signedURL; -} \ No newline at end of file diff --git a/server/replitAuth.ts b/server/replitAuth.ts index 9de7547..b6fadbf 100644 --- a/server/replitAuth.ts +++ b/server/replitAuth.ts @@ -1,31 +1,21 @@ -import * as client from "openid-client"; -import { Strategy, type VerifyFunction } from "openid-client/passport"; -import passport from "passport"; import session from "express-session"; -import type { Express, RequestHandler } from "express"; -import memoize from "memoizee"; import connectPg from "connect-pg-simple"; +import type { Express, RequestHandler, Request, Response, NextFunction } from "express"; +import bcrypt from "bcryptjs"; import { storage } from "./storage"; -import { createHash } from "crypto"; -if (!process.env.REPLIT_DOMAINS) { - throw new Error("Environment variable REPLIT_DOMAINS not provided"); +declare module "express-session" { + interface SessionData { + userId?: string; + isAdmin?: boolean; + isSuperAdmin?: boolean; + } } -const getOidcConfig = memoize( - async () => { - return await client.discovery( - new URL(process.env.ISSUER_URL ?? "https://replit.com/oidc"), - process.env.REPL_ID! - ); - }, - { maxAge: 3600 * 1000 } -); - export function getSession() { - const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week - const pgStore = connectPg(session); - const sessionStore = new pgStore({ + const sessionTtl = 7 * 24 * 60 * 60 * 1000; + const PgStore = connectPg(session); + const sessionStore = new PgStore({ conString: process.env.DATABASE_URL, createTableIfMissing: false, ttl: sessionTtl, @@ -38,155 +28,53 @@ export function getSession() { saveUninitialized: false, cookie: { httpOnly: true, - secure: true, + secure: process.env.NODE_ENV === "production", maxAge: sessionTtl, }, }); } -function updateUserSession( - user: any, - tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers -) { - user.claims = tokens.claims(); - user.access_token = tokens.access_token; - user.refresh_token = tokens.refresh_token; - user.expires_at = user.claims?.exp; -} - -function generateDeterministicUUID(replitId: string): string { - // Create a deterministic UUID from Replit ID - // This ensures the same Replit user always gets the same UUID - const hash = createHash('sha256').update(`replit_${replitId}`).digest('hex'); - // Format as UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - return [ - hash.substring(0, 8), - hash.substring(8, 12), - '4' + hash.substring(13, 16), - (parseInt(hash.substring(16, 17), 16) & 0x3 | 0x8).toString(16) + hash.substring(17, 20), - hash.substring(20, 32) - ].join('-'); -} - -async function upsertUser(claims: any) { - // Generate a deterministic UUID from Replit ID - const userId = generateDeterministicUUID(claims["sub"]); - - await storage.upsertUser({ - id: userId, - email: claims["email"], - firstName: claims["first_name"], - lastName: claims["last_name"], - profileImageUrl: claims["profile_image_url"], - }); -} - export async function setupAuth(app: Express) { app.set("trust proxy", 1); app.use(getSession()); - app.use(passport.initialize()); - app.use(passport.session()); - const config = await getOidcConfig(); - - const verify: VerifyFunction = async ( - tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers, - verified: passport.AuthenticateCallback - ) => { - const user = {}; - updateUserSession(user, tokens); - await upsertUser(tokens.claims()); - verified(null, user); - }; - - for (const domain of process.env.REPLIT_DOMAINS!.split(",")) { - const strategy = new Strategy( - { - name: `replitauth:${domain}`, - config, - scope: "openid email profile offline_access", - callbackURL: `https://${domain}/api/callback`, - }, - verify, - ); - passport.use(strategy); - } - - passport.serializeUser((user: Express.User, cb) => cb(null, user)); - passport.deserializeUser((user: Express.User, cb) => cb(null, user)); - - app.get("/api/login", (req, res, next) => { - passport.authenticate(`replitauth:${req.hostname}`, { - prompt: "login consent", - scope: ["openid", "email", "profile", "offline_access"], - })(req, res, next); + // POST /api/auth/login — email + password + app.post("/api/auth/login", async (req: Request, res: Response) => { + const { email, password } = req.body || {}; + if (!email || !password) return res.status(400).json({ error: "Email and password required" }); + try { + const user = await storage.getUserByEmail(email); + if (!user || !user.passwordHash) return res.status(401).json({ error: "Invalid credentials" }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok || !user.isActive) return res.status(401).json({ error: "Invalid credentials" }); + req.session.userId = user.id; + req.session.isAdmin = !!user.isAdmin || !!user.isSuperAdmin; + req.session.isSuperAdmin = !!user.isSuperAdmin; + return res.json({ user: { id: user.id, email: user.email, username: user.username, isAdmin: req.session.isAdmin } }); + } catch (e: any) { + console.error("Login err:", e); + return res.status(500).json({ error: "Login failed" }); + } }); - app.get("/api/callback", (req, res, next) => { - passport.authenticate(`replitauth:${req.hostname}`, { - successReturnToOrRedirect: "/admin", - failureRedirect: "/api/login", - })(req, res, next); + app.post("/api/auth/logout", (req: Request, res: Response) => { + req.session.destroy(() => res.json({ ok: true })); }); - app.get("/api/logout", (req, res) => { - req.logout(() => { - res.redirect( - client.buildEndSessionUrl(config, { - client_id: process.env.REPL_ID!, - post_logout_redirect_uri: `${req.protocol}://${req.hostname}`, - }).href - ); - }); - }); + app.get("/api/login", (_req, res) => res.redirect("/login")); + app.get("/api/callback", (_req, res) => res.redirect("/login")); + app.get("/api/logout", (req, res) => { req.session.destroy(() => res.redirect("/")); }); } -export const isAuthenticated: RequestHandler = async (req, res, next) => { - const user = req.user as any; - - if (!req.isAuthenticated() || !user.expires_at) { - return res.status(401).json({ message: "Unauthorized" }); - } - - const now = Math.floor(Date.now() / 1000); - if (now <= user.expires_at) { - return next(); - } - - const refreshToken = user.refresh_token; - if (!refreshToken) { - res.status(401).json({ message: "Unauthorized" }); - return; - } - - try { - const config = await getOidcConfig(); - const tokenResponse = await client.refreshTokenGrant(config, refreshToken); - updateUserSession(user, tokenResponse); - return next(); - } catch (error) { - res.status(401).json({ message: "Unauthorized" }); - return; - } +export const isAuthenticated: RequestHandler = (req: any, res, next) => { + if (!req.session?.userId) return res.status(401).json({ message: "Unauthorized" }); + req.user = { claims: { sub: req.session.userId } }; // compat with old code expecting req.user.claims.sub + next(); }; -export const isAdmin: RequestHandler = async (req, res, next) => { - const user = req.user as any; - - if (!req.isAuthenticated() || !user.claims?.sub) { - return res.status(401).json({ message: "Unauthorized" }); - } - - try { - // Generate the same deterministic UUID from Replit ID - const userId = generateDeterministicUUID(user.claims.sub); - const dbUser = await storage.getUser(userId); - if (!dbUser || !dbUser.isAdmin) { - return res.status(403).json({ message: "Admin access required" }); - } - next(); - } catch (error) { - console.error("Error checking admin status:", error); - res.status(500).json({ message: "Internal server error" }); - } -}; \ No newline at end of file +export const isAdmin: RequestHandler = (req: any, res, next) => { + if (!req.session?.userId) return res.status(401).json({ message: "Unauthorized" }); + if (!req.session.isAdmin) return res.status(403).json({ message: "Forbidden" }); + req.user = { claims: { sub: req.session.userId } }; + next(); +};