Skip to content

Commit a86e9e5

Browse files
committed
Save current main work to dev-ai branch
1 parent de2d4d3 commit a86e9e5

File tree

19 files changed

+894
-174
lines changed

19 files changed

+894
-174
lines changed

apps/api/Dockerfile

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build stage
2-
FROM node:18-alpine AS builder
2+
FROM node:20-alpine AS builder
33

44
WORKDIR /app
55

@@ -8,42 +8,60 @@ RUN npm install -g pnpm
88

99
# Copy workspace files
1010
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
11-
COPY apps/api/package.json ./apps/api/
12-
COPY packages/typescript-config ./packages/typescript-config/
1311
COPY turbo.json ./
1412

15-
# Install dependencies
13+
# Copy all package.json files to establish workspace structure
14+
COPY apps/api/package.json ./apps/api/
15+
COPY apps/web/package.json ./apps/web/
16+
COPY packages/ ./packages/
17+
18+
# Install all dependencies
1619
RUN pnpm install --frozen-lockfile
1720

18-
# Copy source code
21+
# Copy source code for both API and web app
1922
COPY apps/api ./apps/api
23+
COPY apps/web ./apps/web
2024

2125
# Generate Prisma Client
2226
WORKDIR /app/apps/api
2327
RUN pnpm prisma generate
2428

25-
# Build application
26-
RUN pnpm build
29+
# Build both API and web app from the monorepo root to handle dependencies properly
30+
WORKDIR /app
31+
RUN pnpm turbo build --filter=api --filter=web
2732

2833
# Production stage
29-
FROM node:18-alpine AS production
34+
FROM node:20-alpine AS production
3035

3136
WORKDIR /app
3237

3338
# Install pnpm
3439
RUN npm install -g pnpm
3540

36-
# Copy package files
37-
COPY --from=builder /app/apps/api/package.json ./
41+
# Create minimal workspace structure for production
42+
COPY --from=builder /app/package.json ./
3843
COPY --from=builder /app/pnpm-lock.yaml ./
44+
COPY --from=builder /app/pnpm-workspace.yaml ./
3945

40-
# Install production dependencies only
41-
RUN pnpm install --prod --frozen-lockfile
46+
# Copy API package.json and any required workspace packages
47+
COPY --from=builder /app/apps/api/package.json ./apps/api/
48+
COPY --from=builder /app/packages/typescript-config ./packages/typescript-config/
4249

43-
# Copy built files and prisma
50+
# Install only production dependencies for the API and its workspace dependencies
51+
RUN pnpm install --prod --frozen-lockfile --filter=api
52+
53+
# Switch to the api directory for runtime
54+
WORKDIR /app/apps/api
55+
56+
# Copy built application files to the correct location
4457
COPY --from=builder /app/apps/api/dist ./dist
4558
COPY --from=builder /app/apps/api/prisma ./prisma
59+
# Ensure generated Prisma client is available in both locations for compatibility
4660
COPY --from=builder /app/apps/api/src/generated ./src/generated
61+
RUN mkdir -p ./dist/generated && cp -r ./src/generated/* ./dist/generated/
62+
63+
# Copy built web app files so the API can serve them
64+
COPY --from=builder /app/apps/web/dist ./web/dist
4765

4866
# Copy entrypoint script
4967
COPY apps/api/docker-entrypoint.sh /usr/local/bin/

apps/api/src/config/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const envSchema = z.object({
3535
.default("info"),
3636

3737
// CORS
38-
CORS_ORIGIN: z.string().default("http://localhost:5173"),
38+
CORS_ORIGIN: z.string().default("http://localhost:3000"),
3939
WEB_URL: z.string().optional(),
4040
APP_URL: z.string().default("http://localhost:3000"),
4141

apps/api/src/index.ts

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import compression from "compression";
55
import morgan from "morgan";
66
import swaggerUi from "swagger-ui-express";
77
import cookieParser from "cookie-parser";
8+
import path from "path";
9+
import { fileURLToPath } from "url";
10+
import { networkInterfaces } from "os";
811

912
// Configuration
1013
import { env } from "./config/env.js";
@@ -92,22 +95,56 @@ app.use(
9295
// Specific routes below will have their own more restrictive limits
9396
app.use("/api/", rateLimiters.standard);
9497

95-
// CORS configuration
96-
app.use(
97-
cors({
98-
origin: env.CORS_ORIGIN.split(",").map((origin) => origin.trim()),
99-
credentials: true,
100-
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
101-
allowedHeaders: [
102-
"Content-Type",
103-
"Authorization",
104-
"x-csrf-token",
105-
"cookie",
106-
"set-cookie",
107-
],
108-
exposedHeaders: ["set-cookie"],
109-
})
110-
);
98+
// CORS configuration - allow localhost and LAN access
99+
const corsOrigins = env.CORS_ORIGIN.split(",").map((origin) => origin.trim());
100+
101+
// Add dynamic origin checking for LAN access
102+
const corsOptions = {
103+
origin: (
104+
origin: string | undefined,
105+
callback: (err: Error | null, allow?: boolean) => void
106+
) => {
107+
// Allow requests with no origin (like mobile apps, Postman, etc.)
108+
if (!origin) return callback(null, true);
109+
110+
// Check if origin matches any of our configured origins
111+
const isConfiguredOrigin = corsOrigins.some((configuredOrigin) => {
112+
if (configuredOrigin === origin) return true;
113+
// Allow any localhost or LAN IP on our port
114+
if (
115+
configuredOrigin.includes("localhost") &&
116+
origin.includes("localhost")
117+
)
118+
return true;
119+
if (configuredOrigin.includes(":3000") && origin.includes(":3000")) {
120+
// Allow any IP address on port 3000 (LAN access)
121+
const lanIpRegex = /^https?:\/\/([0-9]{1,3}\.){3}[0-9]{1,3}:3000$/;
122+
return lanIpRegex.test(origin);
123+
}
124+
return false;
125+
});
126+
127+
if (isConfiguredOrigin) {
128+
callback(null, true);
129+
} else {
130+
// Log the blocked origin for debugging
131+
logger.info(`CORS blocked origin: ${origin}`);
132+
callback(new Error("Not allowed by CORS"));
133+
}
134+
},
135+
credentials: true,
136+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
137+
allowedHeaders: [
138+
"Content-Type",
139+
"Authorization",
140+
"x-csrf-token",
141+
"cookie",
142+
"set-cookie",
143+
],
144+
exposedHeaders: ["set-cookie"],
145+
};
146+
147+
app.use(cors(corsOptions));
111148

112149
// Compression - Reduce response size
113150
app.use(compression());
@@ -213,6 +250,28 @@ app.get("/metrics", metricsHandler);
213250
// CSRF token endpoint (for web clients)
214251
app.get("/api/v1/csrf-token", csrfTokenHandler);
215252

253+
// Server info endpoint for LAN discovery
254+
app.get("/api/v1/server-info", (_req, res) => {
255+
const interfaces = networkInterfaces();
256+
const lanIps: string[] = [];
257+
258+
// Find all LAN IP addresses
259+
Object.values(interfaces).forEach((netInterface) => {
260+
netInterface?.forEach((details) => {
261+
if (details.family === "IPv4" && !details.internal) {
262+
lanIps.push(details.address);
263+
}
264+
});
265+
});
266+
267+
res.json({
268+
port: env.PORT,
269+
lanIps,
270+
urls: lanIps.map((ip) => `http://${ip}:${env.PORT}`),
271+
localhost: `http://localhost:${env.PORT}`,
272+
});
273+
});
274+
216275
// ────────────────────────────────────────────────────────────────────────────
217276
// Health Check Routes (no versioning, no rate limiting)
218277
// ────────────────────────────────────────────────────────────────────────────
@@ -249,6 +308,55 @@ app.use(`${API_V1}/comics`, csrfProtection, comicsRouter);
249308
app.use(`${API_V1}/search`, rateLimiters.search, searchRouter); // GET requests, no CSRF needed
250309
app.use(`${API_V1}/bulk`, csrfProtection, bulkRouter);
251310

311+
// ────────────────────────────────────────────────────────────────────────────
312+
// Static File Serving (Web App)
313+
// ────────────────────────────────────────────────────────────────────────────
314+
315+
// Get the current file directory for path resolution
316+
const __filename = fileURLToPath(import.meta.url);
317+
const __dirname = path.dirname(__filename);
318+
319+
// Path to the built web app static files
320+
// In development: ../../web/dist (relative to src/)
321+
// In production Docker: ../web/dist (relative to dist/)
322+
const webDistPath =
323+
process.env.NODE_ENV === "production"
324+
? path.join(__dirname, "../web/dist")
325+
: path.join(__dirname, "../../../web/dist");
326+
327+
// Serve static files from the web app build directory
328+
app.use(
329+
express.static(webDistPath, {
330+
maxAge: env.NODE_ENV === "production" ? "1y" : "0", // Cache for 1 year in production
331+
etag: true,
332+
lastModified: true,
333+
setHeaders: (res, path) => {
334+
// Set proper MIME types for JavaScript and CSS files
335+
if (path.endsWith(".js")) {
336+
res.setHeader("Content-Type", "application/javascript");
337+
} else if (path.endsWith(".css")) {
338+
res.setHeader("Content-Type", "text/css");
339+
}
340+
},
341+
})
342+
);
343+
344+
// Handle client-side routing - serve index.html for all non-API routes
345+
app.get("*", (req, res, next) => {
346+
// Skip if this is an API route, health check, metrics, or docs
347+
if (
348+
req.path.startsWith("/api") ||
349+
req.path.startsWith("/health") ||
350+
req.path.startsWith("/metrics") ||
351+
req.path.startsWith("/api-docs")
352+
) {
353+
return next();
354+
}
355+
356+
// Serve the web app index.html for all other routes (client-side routing)
357+
res.sendFile(path.join(webDistPath, "index.html"));
358+
});
359+
252360
// ────────────────────────────────────────────────────────────────────────────
253361
// Error Handlers
254362
// ────────────────────────────────────────────────────────────────────────────
@@ -268,17 +376,21 @@ async function startServer() {
268376
logger.info("Verifying database connection...");
269377
await verifyDatabaseConnection();
270378

271-
// Start the HTTP server
272-
const server = app.listen(env.PORT, () => {
379+
// Start the HTTP server - bind to all interfaces for LAN access
380+
const server = app.listen(env.PORT, "0.0.0.0", () => {
273381
logger.info(`🚀 Server started successfully`);
274382
logger.info(` Environment: ${env.NODE_ENV}`);
275383
logger.info(` Port: ${env.PORT}`);
384+
logger.info(` Web App: http://localhost:${env.PORT}`);
276385
logger.info(` API: http://localhost:${env.PORT}/api/v1`);
277386
logger.info(` Docs: http://localhost:${env.PORT}/api-docs`);
278387
logger.info(` Health: http://localhost:${env.PORT}/health`);
279388
logger.info(` Metrics: http://localhost:${env.PORT}/metrics`);
280389
logger.info(` WebSocket: ws://localhost:${env.PORT}/ws`);
281390
logger.info(` CSRF Protection: Enabled`);
391+
logger.info(
392+
` 🌐 Server is accessible on LAN - check your local IP address`
393+
);
282394
});
283395

284396
// Initialize WebSocket server

apps/api/src/routes/media/media.service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,9 @@ export class MediaService {
393393
private addStreamingUrls(media: any): any {
394394
if (!media) return media;
395395

396+
// Convert BigInt fields to strings to prevent serialization errors
397+
this.convertBigIntToString(media);
398+
396399
// Add streaming URL for movies
397400
if (media.movie && media.movie.filePath) {
398401
media.movie.streamUrl = `${API_BASE_URL}/api/media/stream/movie/${media.id}`;
@@ -420,6 +423,21 @@ export class MediaService {
420423
private addStreamingUrlsToArray(mediaArray: any[]): any[] {
421424
return mediaArray.map((media) => this.addStreamingUrls(media));
422425
}
426+
427+
/**
428+
* Convert BigInt fields to strings recursively
429+
*/
430+
private convertBigIntToString(obj: any): void {
431+
if (!obj || typeof obj !== "object") return;
432+
433+
for (const key in obj) {
434+
if (typeof obj[key] === "bigint") {
435+
obj[key] = obj[key].toString();
436+
} else if (typeof obj[key] === "object" && obj[key] !== null) {
437+
this.convertBigIntToString(obj[key]);
438+
}
439+
}
440+
}
423441
}
424442

425443
// Export singleton instance

0 commit comments

Comments
 (0)