Migrate from Replit infrastructure: Neon→PostgreSQL, GCS→S3, ReplitAuth→email/password, add Dockerfile
This commit is contained in:
parent
cc2445c4d9
commit
bcbc76bf43
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -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"]
|
||||||
12
package.json
12
package.json
@ -11,11 +11,10 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.17.0",
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
"@google-cloud/video-intelligence": "^6.2.0",
|
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
@ -49,6 +48,7 @@
|
|||||||
"@types/memoizee": "^0.4.12",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"@uppy/aws-s3": "^4.3.2",
|
"@uppy/aws-s3": "^4.3.2",
|
||||||
"@uppy/core": "^4.5.3",
|
"@uppy/core": "^4.5.3",
|
||||||
@ -57,7 +57,7 @@
|
|||||||
"@uppy/file-input": "^4.2.2",
|
"@uppy/file-input": "^4.2.2",
|
||||||
"@uppy/progress-bar": "^4.3.2",
|
"@uppy/progress-bar": "^4.3.2",
|
||||||
"@uppy/react": "^4.5.2",
|
"@uppy/react": "^4.5.2",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^2.4.3",
|
||||||
"canvas": "^3.2.0",
|
"canvas": "^3.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -76,16 +76,15 @@
|
|||||||
"hls.js": "^1.6.7",
|
"hls.js": "^1.6.7",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memoizee": "^0.4.17",
|
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^2.0.0",
|
"node-fetch": "^2.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"openai": "^5.16.0",
|
"openai": "^5.16.0",
|
||||||
"openid-client": "^6.7.1",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@ -103,7 +102,6 @@
|
|||||||
"videojs-contrib-ads": "^7.5.2",
|
"videojs-contrib-ads": "^7.5.2",
|
||||||
"videojs-ima": "^2.4.0",
|
"videojs-ima": "^2.4.0",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zod-validation-error": "^3.4.0"
|
"zod-validation-error": "^3.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
13
server/db.ts
13
server/db.ts
@ -1,15 +1,10 @@
|
|||||||
import { Pool, neonConfig } from '@neondatabase/serverless';
|
import { Pool } from "pg";
|
||||||
import { drizzle } from 'drizzle-orm/neon-serverless';
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import ws from "ws";
|
|
||||||
import * as schema from "@shared/schema";
|
import * as schema from "@shared/schema";
|
||||||
|
|
||||||
neonConfig.webSocketConstructor = ws;
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error(
|
throw new Error("DATABASE_URL must be set.");
|
||||||
"DATABASE_URL must be set. Did you forget to provision a database?",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
export const db = drizzle({ client: pool, schema });
|
export const db = drizzle(pool, { schema });
|
||||||
|
|||||||
@ -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 { Response } from "express";
|
||||||
import { randomUUID } from "crypto";
|
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
|
const s3 = new S3Client({
|
||||||
export const objectStorageClient = new Storage({
|
region: process.env.S3_REGION || "fsn1",
|
||||||
|
endpoint: S3_ENDPOINT,
|
||||||
|
forcePathStyle: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
audience: "replit",
|
accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
|
||||||
subject_token_type: "access_token",
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
|
||||||
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",
|
|
||||||
},
|
|
||||||
projectId: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export class ObjectNotFoundError extends Error {
|
export class ObjectNotFoundError extends Error {
|
||||||
@ -34,187 +30,80 @@ export class ObjectNotFoundError extends Error {
|
|||||||
export class ObjectStorageService {
|
export class ObjectStorageService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
// Get public object search paths
|
|
||||||
getPublicObjectSearchPaths(): Array<string> {
|
getPublicObjectSearchPaths(): Array<string> {
|
||||||
const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || "";
|
return [`${S3_PREFIX}/public`];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get private object directory
|
|
||||||
getPrivateObjectDir(): string {
|
getPrivateObjectDir(): string {
|
||||||
const dir = process.env.PRIVATE_OBJECT_DIR || "";
|
return `${S3_PREFIX}/.private`;
|
||||||
if (!dir) {
|
|
||||||
throw new Error(
|
|
||||||
"PRIVATE_OBJECT_DIR not set. Object storage not configured."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get upload URL for thumbnail upload
|
|
||||||
async getThumbnailUploadURL(): Promise<string> {
|
async getThumbnailUploadURL(): Promise<string> {
|
||||||
const privateObjectDir = this.getPrivateObjectDir();
|
|
||||||
const objectId = randomUUID();
|
const objectId = randomUUID();
|
||||||
const fullPath = `${privateObjectDir}/thumbnails/${objectId}`;
|
const key = `${this.getPrivateObjectDir()}/thumbnails/${objectId}`;
|
||||||
|
const cmd = new PutObjectCommand({
|
||||||
const { bucketName, objectName } = parseObjectPath(fullPath);
|
Bucket: S3_BUCKET,
|
||||||
|
Key: key,
|
||||||
return signObjectURL({
|
ACL: "public-read",
|
||||||
bucketName,
|
|
||||||
objectName,
|
|
||||||
method: "PUT",
|
|
||||||
ttlSec: 900, // 15 minutes
|
|
||||||
});
|
});
|
||||||
|
return await getSignedUrl(s3, cmd, { expiresIn: 900 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download object to response
|
async downloadObject(objectKey: string, res: Response, cacheTtlSec: number = 3600) {
|
||||||
async downloadObject(file: File, res: Response, cacheTtlSec: number = 3600) {
|
|
||||||
try {
|
try {
|
||||||
const [metadata] = await file.getMetadata();
|
const cmd = new GetObjectCommand({ Bucket: S3_BUCKET, Key: objectKey });
|
||||||
|
const obj = await s3.send(cmd);
|
||||||
res.set({
|
res.set({
|
||||||
"Content-Type": metadata.contentType || "application/octet-stream",
|
"Content-Type": obj.ContentType || "application/octet-stream",
|
||||||
"Content-Length": metadata.size,
|
"Content-Length": String(obj.ContentLength || ""),
|
||||||
"Cache-Control": `public, max-age=${cacheTtlSec}`,
|
"Cache-Control": `public, max-age=${cacheTtlSec}`,
|
||||||
});
|
});
|
||||||
|
const stream = obj.Body as Readable;
|
||||||
const stream = file.createReadStream();
|
|
||||||
|
|
||||||
stream.on("error", (err) => {
|
stream.on("error", (err) => {
|
||||||
console.error("Stream error:", err);
|
console.error("Stream error:", err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) res.status(500).json({ error: "Error streaming file" });
|
||||||
res.status(500).json({ error: "Error streaming file" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error downloading file:", error);
|
console.error("Error downloading file:", error);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) res.status(500).json({ error: "Error downloading file" });
|
||||||
res.status(500).json({ error: "Error downloading file" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get object file from path
|
async getObjectFile(objectPath: string): Promise<string> {
|
||||||
async getObjectFile(objectPath: string): Promise<File> {
|
if (!objectPath.startsWith("/objects/")) throw new ObjectNotFoundError();
|
||||||
if (!objectPath.startsWith("/objects/")) {
|
|
||||||
throw new ObjectNotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = objectPath.slice(1).split("/");
|
const parts = objectPath.slice(1).split("/");
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) throw new ObjectNotFoundError();
|
||||||
throw new ObjectNotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityId = parts.slice(1).join("/");
|
const entityId = parts.slice(1).join("/");
|
||||||
let entityDir = this.getPrivateObjectDir();
|
const key = `${this.getPrivateObjectDir()}/${entityId}`;
|
||||||
if (!entityDir.endsWith("/")) {
|
try {
|
||||||
entityDir = `${entityDir}/`;
|
await s3.send(new HeadObjectCommand({ Bucket: S3_BUCKET, Key: key }));
|
||||||
}
|
return key; // returns S3 key instead of File object
|
||||||
const objectEntityPath = `${entityDir}${entityId}`;
|
} catch {
|
||||||
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();
|
throw new ObjectNotFoundError();
|
||||||
}
|
}
|
||||||
return objectFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize object path from storage URL to local path
|
|
||||||
normalizeObjectPath(rawPath: string): string {
|
normalizeObjectPath(rawPath: string): string {
|
||||||
if (!rawPath.startsWith("https://storage.googleapis.com/")) {
|
// Convert old Replit/GCS URL or full S3 URL into /objects/{entityId}
|
||||||
return rawPath;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(rawPath);
|
|
||||||
const rawObjectPath = url.pathname;
|
|
||||||
|
|
||||||
let objectEntityDir = this.getPrivateObjectDir();
|
|
||||||
if (!objectEntityDir.endsWith("/")) {
|
|
||||||
objectEntityDir = `${objectEntityDir}/`;
|
|
||||||
}
|
}
|
||||||
|
const idx = path.indexOf(privateDir);
|
||||||
if (!rawObjectPath.startsWith(objectEntityDir)) {
|
if (idx >= 0) {
|
||||||
return rawObjectPath;
|
const entityId = path.slice(idx + privateDir.length).replace(/^\//, "");
|
||||||
}
|
|
||||||
|
|
||||||
const entityId = rawObjectPath.slice(objectEntityDir.length);
|
|
||||||
return `/objects/${entityId}`;
|
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<string> {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@ -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 session from "express-session";
|
||||||
import type { Express, RequestHandler } from "express";
|
|
||||||
import memoize from "memoizee";
|
|
||||||
import connectPg from "connect-pg-simple";
|
import connectPg from "connect-pg-simple";
|
||||||
|
import type { Express, RequestHandler, Request, Response, NextFunction } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
if (!process.env.REPLIT_DOMAINS) {
|
declare module "express-session" {
|
||||||
throw new Error("Environment variable REPLIT_DOMAINS not provided");
|
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() {
|
export function getSession() {
|
||||||
const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week
|
const sessionTtl = 7 * 24 * 60 * 60 * 1000;
|
||||||
const pgStore = connectPg(session);
|
const PgStore = connectPg(session);
|
||||||
const sessionStore = new pgStore({
|
const sessionStore = new PgStore({
|
||||||
conString: process.env.DATABASE_URL,
|
conString: process.env.DATABASE_URL,
|
||||||
createTableIfMissing: false,
|
createTableIfMissing: false,
|
||||||
ttl: sessionTtl,
|
ttl: sessionTtl,
|
||||||
@ -38,155 +28,53 @@ export function getSession() {
|
|||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: sessionTtl,
|
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) {
|
export async function setupAuth(app: Express) {
|
||||||
app.set("trust proxy", 1);
|
app.set("trust proxy", 1);
|
||||||
app.use(getSession());
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/callback", (req, res, next) => {
|
|
||||||
passport.authenticate(`replitauth:${req.hostname}`, {
|
|
||||||
successReturnToOrRedirect: "/admin",
|
|
||||||
failureRedirect: "/api/login",
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const config = await getOidcConfig();
|
const user = await storage.getUserByEmail(email);
|
||||||
const tokenResponse = await client.refreshTokenGrant(config, refreshToken);
|
if (!user || !user.passwordHash) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
updateUserSession(user, tokenResponse);
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
return next();
|
if (!ok || !user.isActive) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
} catch (error) {
|
req.session.userId = user.id;
|
||||||
res.status(401).json({ message: "Unauthorized" });
|
req.session.isAdmin = !!user.isAdmin || !!user.isSuperAdmin;
|
||||||
return;
|
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" });
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
export const isAdmin: RequestHandler = async (req, res, next) => {
|
app.post("/api/auth/logout", (req: Request, res: Response) => {
|
||||||
const user = req.user as any;
|
req.session.destroy(() => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
if (!req.isAuthenticated() || !user.claims?.sub) {
|
app.get("/api/login", (_req, res) => res.redirect("/login"));
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
app.get("/api/callback", (_req, res) => res.redirect("/login"));
|
||||||
|
app.get("/api/logout", (req, res) => { req.session.destroy(() => res.redirect("/")); });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
export const isAuthenticated: RequestHandler = (req: any, res, next) => {
|
||||||
// Generate the same deterministic UUID from Replit ID
|
if (!req.session?.userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
const userId = generateDeterministicUUID(user.claims.sub);
|
req.user = { claims: { sub: req.session.userId } }; // compat with old code expecting req.user.claims.sub
|
||||||
const dbUser = await storage.getUser(userId);
|
next();
|
||||||
if (!dbUser || !dbUser.isAdmin) {
|
};
|
||||||
return res.status(403).json({ message: "Admin access required" });
|
|
||||||
}
|
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();
|
next();
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking admin status:", error);
|
|
||||||
res.status(500).json({ message: "Internal server error" });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue
Block a user