notifkit 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/README.md +29 -0
- package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
- package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
- package/dashboard/dist/favicon.svg +4 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/index.d.mts +447 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-CRPxM-O3.mjs → main-4Wyge9ms.mjs} +2 -2
- package/dist/{main-CRPxM-O3.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
- package/dist/{main-Bgi2wWck.mjs → main-AO0JaY5z.mjs} +2 -2
- package/dist/{main-Bgi2wWck.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
- package/dist/{main-DFMHcN_d.mjs → main-BMYSYcv2.mjs} +2 -2
- package/dist/{main-DFMHcN_d.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
- package/dist/{main-Yorxzw0Z.mjs → main-BPbPnBIG.mjs} +2 -2
- package/dist/{main-Yorxzw0Z.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
- package/dist/{main-pFAfCkVX.mjs → main-CyeQ_yDt.mjs} +2 -2
- package/dist/{main-pFAfCkVX.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
- package/dist/{main-CjbVdUqa.mjs → main-D19Wxs0x.mjs} +2 -2
- package/dist/{main-CjbVdUqa.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
- package/dist/{main-D6SG3Isk.mjs → main-DSmh1e-V.mjs} +2 -2
- package/dist/{main-D6SG3Isk.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
- package/dist/{main-CFSukWm8.mjs → main-JdOvJ70b.mjs} +495 -41
- package/dist/main-JdOvJ70b.mjs.map +1 -0
- package/dist/{src-CPMwsUCJ.mjs → src-CGmXRfsM.mjs} +86 -20
- package/dist/src-CGmXRfsM.mjs.map +1 -0
- package/drizzle/0005_admin_users.sql +11 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -3
- package/scripts/create-admin.mjs +67 -0
- package/src/config/index.ts +12 -0
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +225 -0
- package/src/services/api/handlers.ts +224 -33
- package/src/services/api/main.ts +183 -29
- package/src/services/api/router.ts +7 -1
- package/src/services/auth/index.ts +111 -0
- package/dist/main-CFSukWm8.mjs.map +0 -1
- package/dist/src-CPMwsUCJ.mjs.map +0 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "admin_users" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"email" varchar NOT NULL,
|
|
4
|
+
"username" varchar,
|
|
5
|
+
"password_hash" varchar NOT NULL,
|
|
6
|
+
"role" varchar DEFAULT 'admin' NOT NULL,
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
9
|
+
CONSTRAINT "admin_users_email_unique" UNIQUE("email"),
|
|
10
|
+
CONSTRAINT "admin_users_username_unique" UNIQUE("username")
|
|
11
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notifkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Self-hosted notification infrastructure. One call delivers to email, SMS, push, and webhook — routed by preference, quiet hours, and consent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "devkitshq",
|
|
@@ -48,23 +48,28 @@
|
|
|
48
48
|
"dist",
|
|
49
49
|
"src",
|
|
50
50
|
"drizzle",
|
|
51
|
+
"dashboard/dist",
|
|
51
52
|
"scripts/create-project.mjs",
|
|
53
|
+
"scripts/create-admin.mjs",
|
|
52
54
|
"README.md",
|
|
53
55
|
"LICENSE"
|
|
54
56
|
],
|
|
55
57
|
"bin": {
|
|
56
|
-
"notifkit-create-project": "./scripts/create-project.mjs"
|
|
58
|
+
"notifkit-create-project": "./scripts/create-project.mjs",
|
|
59
|
+
"notifkit-create-admin": "./scripts/create-admin.mjs"
|
|
57
60
|
},
|
|
58
61
|
"scripts": {
|
|
59
62
|
"build": "tsdown",
|
|
60
63
|
"dev": "tsdown --watch",
|
|
61
64
|
"create-project": "node scripts/create-project.mjs",
|
|
65
|
+
"create-admin": "node scripts/create-admin.mjs",
|
|
66
|
+
"build:dashboard": "npm --prefix dashboard run build",
|
|
62
67
|
"test": "vitest run --coverage",
|
|
63
68
|
"test:watch": "vitest",
|
|
64
69
|
"lint": "eslint . --fix",
|
|
65
70
|
"typecheck": "tsc --noEmit",
|
|
66
71
|
"prepare": "husky",
|
|
67
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
72
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run build:dashboard"
|
|
68
73
|
},
|
|
69
74
|
"dependencies": {
|
|
70
75
|
"ai": "^7.0.37",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Create or update an admin user for the Notifkit Dashboard.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret node scripts/create-admin.mjs
|
|
7
|
+
* node scripts/create-admin.mjs admin@example.com secret [username] [role]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { config } from "dotenv";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import postgres from "postgres";
|
|
13
|
+
import { scrypt, randomBytes } from "node:crypto";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
|
|
16
|
+
config({ path: resolve(process.cwd(), ".env") });
|
|
17
|
+
|
|
18
|
+
const scryptAsync = promisify(scrypt);
|
|
19
|
+
|
|
20
|
+
async function hashPassword(password) {
|
|
21
|
+
const salt = randomBytes(16).toString("hex");
|
|
22
|
+
const derivedKey = await scryptAsync(password, salt, 64);
|
|
23
|
+
return `${salt}:${derivedKey.toString("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const email = process.argv[2] || process.env.ADMIN_EMAIL;
|
|
27
|
+
const password = process.argv[3] || process.env.ADMIN_PASSWORD;
|
|
28
|
+
const username = process.argv[4] || process.env.ADMIN_USERNAME || null;
|
|
29
|
+
const role = process.argv[5] || "admin";
|
|
30
|
+
|
|
31
|
+
if (!email || !password) {
|
|
32
|
+
console.error("Usage: node scripts/create-admin.mjs <email> <password> [username] [role]");
|
|
33
|
+
console.error("Or set ADMIN_EMAIL and ADMIN_PASSWORD in environment.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const databaseUrl =
|
|
38
|
+
process.env.DATABASE_URL || "postgres://platform:platform@localhost:5432/notifkit";
|
|
39
|
+
const sql = postgres(databaseUrl);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const passwordHash = await hashPassword(password);
|
|
43
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
44
|
+
const normalizedUsername = username ? username.trim() : null;
|
|
45
|
+
|
|
46
|
+
const rows = await sql`
|
|
47
|
+
INSERT INTO admin_users (email, username, password_hash, role)
|
|
48
|
+
VALUES (${normalizedEmail}, ${normalizedUsername}, ${passwordHash}, ${role})
|
|
49
|
+
ON CONFLICT (email) DO UPDATE
|
|
50
|
+
SET password_hash = EXCLUDED.password_hash,
|
|
51
|
+
username = COALESCE(EXCLUDED.username, admin_users.username),
|
|
52
|
+
role = EXCLUDED.role,
|
|
53
|
+
updated_at = NOW()
|
|
54
|
+
RETURNING id, email, username, role, created_at, updated_at
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
console.log("\nAdmin user successfully saved:\n");
|
|
58
|
+
console.log(` ID: ${rows[0].id}`);
|
|
59
|
+
console.log(` Email: ${rows[0].email}`);
|
|
60
|
+
console.log(` Username: ${rows[0].username || "(none)"}`);
|
|
61
|
+
console.log(` Role: ${rows[0].role}\n`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`Failed to create admin: ${err.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
} finally {
|
|
66
|
+
await sql.end();
|
|
67
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -43,6 +43,9 @@ export const baseConfigSchema = z.object({
|
|
|
43
43
|
// Windows puts a trailing space in the variable, which otherwise turns every
|
|
44
44
|
// admin request into a 401 that reads like a wrong key.
|
|
45
45
|
ADMIN_API_KEY: z.string().trim().optional(),
|
|
46
|
+
ADMIN_EMAIL: z.string().trim().email().optional(),
|
|
47
|
+
ADMIN_PASSWORD: z.string().trim().min(6).optional(),
|
|
48
|
+
ADMIN_USERNAME: z.string().trim().optional(),
|
|
46
49
|
WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
|
|
47
50
|
QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
|
|
48
51
|
DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
|
|
@@ -62,6 +65,15 @@ export const baseConfigSchema = z.object({
|
|
|
62
65
|
* instead, which costs far more than the key ever protected.
|
|
63
66
|
*/
|
|
64
67
|
UNSUBSCRIBE_SECRET: z.string().min(16).optional(),
|
|
68
|
+
/**
|
|
69
|
+
* When true, trusts X-Forwarded-For headers from reverse proxies for client IP resolution.
|
|
70
|
+
* Defaults to false to prevent client-spoofed headers from bypassing rate limits.
|
|
71
|
+
*/
|
|
72
|
+
TRUST_PROXY: z.coerce.boolean().default(false),
|
|
73
|
+
/**
|
|
74
|
+
* Comma-separated list of allowed origins for CORS on session-authenticated admin routes.
|
|
75
|
+
*/
|
|
76
|
+
CORS_ORIGIN: z.string().optional(),
|
|
65
77
|
});
|
|
66
78
|
|
|
67
79
|
export type BaseConfig = z.infer<typeof baseConfigSchema>;
|
package/src/db/schema.ts
CHANGED
|
@@ -367,8 +367,21 @@ export const scheduledPayloads = pgTable("scheduled_payloads", {
|
|
|
367
367
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
export const adminUsers = pgTable("admin_users", {
|
|
371
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
372
|
+
email: varchar("email").notNull().unique(),
|
|
373
|
+
username: varchar("username").unique(),
|
|
374
|
+
passwordHash: varchar("password_hash").notNull(),
|
|
375
|
+
role: varchar("role").notNull().default("admin"),
|
|
376
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
377
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
378
|
+
});
|
|
379
|
+
|
|
370
380
|
// ─── Generated Zod Schemas ──────────────────────────────────────────────────
|
|
371
381
|
|
|
382
|
+
export const insertAdminUserSchema = createInsertSchema(adminUsers);
|
|
383
|
+
export const selectAdminUserSchema = createSelectSchema(adminUsers);
|
|
384
|
+
|
|
372
385
|
export const insertProjectSchema = createInsertSchema(projects);
|
|
373
386
|
export const selectProjectSchema = createSelectSchema(projects);
|
|
374
387
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
|
1
|
+
import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
|
2
2
|
import type { Db } from "@/index.js";
|
|
3
3
|
import type { Preferences, ContactChannel } from "@/contracts/index.js";
|
|
4
4
|
import {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
projectApiKeys,
|
|
19
19
|
suppressions,
|
|
20
20
|
messageLogs,
|
|
21
|
+
adminUsers,
|
|
21
22
|
} from "@/db/schema.js";
|
|
22
23
|
|
|
23
24
|
// ─── Domain types ───────────────────────────────────────────────────────────
|
|
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
|
|
|
1244
1245
|
return (rows as any[]).map((r) => r.segment);
|
|
1245
1246
|
}
|
|
1246
1247
|
}
|
|
1248
|
+
|
|
1249
|
+
// ─── AdminUserRepository ───────────────────────────────────────────────────
|
|
1250
|
+
|
|
1251
|
+
export interface AdminUserRecord {
|
|
1252
|
+
id: string;
|
|
1253
|
+
email: string;
|
|
1254
|
+
username: string | null;
|
|
1255
|
+
passwordHash: string;
|
|
1256
|
+
role: string;
|
|
1257
|
+
createdAt: Date;
|
|
1258
|
+
updatedAt: Date;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export class AdminUserRepository {
|
|
1262
|
+
constructor(private readonly db: Db) {}
|
|
1263
|
+
|
|
1264
|
+
async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
|
|
1265
|
+
const normalized = identifier.trim().toLowerCase();
|
|
1266
|
+
const rows = await this.db
|
|
1267
|
+
.select()
|
|
1268
|
+
.from(adminUsers)
|
|
1269
|
+
.where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
|
|
1270
|
+
.limit(1);
|
|
1271
|
+
return (rows[0] as AdminUserRecord) ?? null;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async findById(id: string): Promise<AdminUserRecord | null> {
|
|
1275
|
+
const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
|
|
1276
|
+
return (rows[0] as AdminUserRecord) ?? null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async create(data: {
|
|
1280
|
+
email: string;
|
|
1281
|
+
username?: string | null;
|
|
1282
|
+
passwordHash: string;
|
|
1283
|
+
role?: string;
|
|
1284
|
+
}): Promise<AdminUserRecord> {
|
|
1285
|
+
const rows = await this.db
|
|
1286
|
+
.insert(adminUsers)
|
|
1287
|
+
.values({
|
|
1288
|
+
email: data.email.trim().toLowerCase(),
|
|
1289
|
+
username: data.username ? data.username.trim() : null,
|
|
1290
|
+
passwordHash: data.passwordHash,
|
|
1291
|
+
role: data.role || "admin",
|
|
1292
|
+
})
|
|
1293
|
+
.returning();
|
|
1294
|
+
return rows[0] as AdminUserRecord;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async updatePassword(id: string, passwordHash: string): Promise<boolean> {
|
|
1298
|
+
const rows = await this.db
|
|
1299
|
+
.update(adminUsers)
|
|
1300
|
+
.set({ passwordHash, updatedAt: new Date() })
|
|
1301
|
+
.where(eq(adminUsers.id, id))
|
|
1302
|
+
.returning();
|
|
1303
|
+
return rows.length > 0;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
|
|
1307
|
+
const rows = await this.db
|
|
1308
|
+
.select({
|
|
1309
|
+
id: adminUsers.id,
|
|
1310
|
+
email: adminUsers.email,
|
|
1311
|
+
username: adminUsers.username,
|
|
1312
|
+
role: adminUsers.role,
|
|
1313
|
+
createdAt: adminUsers.createdAt,
|
|
1314
|
+
updatedAt: adminUsers.updatedAt,
|
|
1315
|
+
})
|
|
1316
|
+
.from(adminUsers)
|
|
1317
|
+
.orderBy(adminUsers.createdAt);
|
|
1318
|
+
return rows as Omit<AdminUserRecord, "passwordHash">[];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async count(): Promise<number> {
|
|
1322
|
+
const rows = await this.db
|
|
1323
|
+
.select({ count: drizzleSql<number>`count(*)::int` })
|
|
1324
|
+
.from(adminUsers);
|
|
1325
|
+
return rows[0]?.count ?? 0;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { resolve, extname, join, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { existsSync, statSync, createReadStream } from "node:fs";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
|
|
6
|
+
const MIME_TYPES: Record<string, string> = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".js": "application/javascript; charset=utf-8",
|
|
9
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".json": "application/json; charset=utf-8",
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".gif": "image/gif",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
".ico": "image/x-icon",
|
|
18
|
+
".woff": "font/woff",
|
|
19
|
+
".woff2": "font/woff2",
|
|
20
|
+
".ttf": "font/ttf",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
".txt": "text/plain; charset=utf-8",
|
|
23
|
+
".map": "application/json; charset=utf-8",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Possible output directories where built dashboard static files might reside
|
|
27
|
+
const POSSIBLE_DASHBOARD_DIRS = [
|
|
28
|
+
resolve(process.cwd(), "dashboard", "dist"),
|
|
29
|
+
resolve(process.cwd(), "dashboard", "out"),
|
|
30
|
+
resolve(process.cwd(), "dist", "admin"),
|
|
31
|
+
resolve(process.cwd(), "dist", "dashboard"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function getDashboardDir(): string | null {
|
|
35
|
+
for (const dir of POSSIBLE_DASHBOARD_DIRS) {
|
|
36
|
+
if (existsSync(dir) && existsSync(join(dir, "index.html"))) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Proxies request to frontend dev server if running.
|
|
45
|
+
*/
|
|
46
|
+
function proxyToDevServer(
|
|
47
|
+
req: IncomingMessage,
|
|
48
|
+
res: ServerResponse,
|
|
49
|
+
targetHost: string,
|
|
50
|
+
targetPort: number,
|
|
51
|
+
): Promise<boolean> {
|
|
52
|
+
return new Promise((resolvePromise) => {
|
|
53
|
+
const proxyReq = httpRequest(
|
|
54
|
+
{
|
|
55
|
+
host: targetHost,
|
|
56
|
+
port: targetPort,
|
|
57
|
+
path: req.url,
|
|
58
|
+
method: req.method,
|
|
59
|
+
headers: req.headers,
|
|
60
|
+
},
|
|
61
|
+
(proxyRes) => {
|
|
62
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
63
|
+
proxyRes.pipe(res);
|
|
64
|
+
resolvePromise(true);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
proxyReq.on("error", () => {
|
|
69
|
+
resolvePromise(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (req.readable) {
|
|
73
|
+
req.pipe(proxyReq);
|
|
74
|
+
} else {
|
|
75
|
+
proxyReq.end();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handles incoming HTTP requests for `/admin` and `/admin/*`.
|
|
82
|
+
* Serves static exported assets from Vite SPA build with index.html fallback,
|
|
83
|
+
* or proxies to Vite dev server if running in development mode.
|
|
84
|
+
*/
|
|
85
|
+
export async function handleAdminRequest(
|
|
86
|
+
req: IncomingMessage,
|
|
87
|
+
res: ServerResponse,
|
|
88
|
+
url: URL,
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Redirect /admin to /admin/
|
|
95
|
+
if (url.pathname === "/admin") {
|
|
96
|
+
res.writeHead(301, { Location: "/admin/" + (url.search || "") });
|
|
97
|
+
res.end();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// In development, attempt to proxy to local Vite dev server on port 5173 (or VITE_DEV_URL) if active
|
|
102
|
+
if (process.env.NODE_ENV !== "production") {
|
|
103
|
+
const devProxyUrl =
|
|
104
|
+
process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
|
|
105
|
+
const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
|
|
106
|
+
const targetHost = devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1";
|
|
107
|
+
|
|
108
|
+
const proxied = await proxyToDevServer(req, res, targetHost, targetPort);
|
|
109
|
+
if (proxied) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dashboardDir = getDashboardDir();
|
|
115
|
+
if (!dashboardDir) {
|
|
116
|
+
// If dashboard build not found, return friendly message
|
|
117
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
118
|
+
res.end(`
|
|
119
|
+
<!DOCTYPE html>
|
|
120
|
+
<html lang="en">
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="utf-8">
|
|
123
|
+
<title>Notifkit Admin Dashboard</title>
|
|
124
|
+
<style>
|
|
125
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
|
126
|
+
.card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
|
|
127
|
+
h1 { margin-top: 0; color: #fafafa; }
|
|
128
|
+
code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<div class="card">
|
|
133
|
+
<h1>Notifkit Admin Dashboard</h1>
|
|
134
|
+
<p>The dashboard static bundle has not been built yet.</p>
|
|
135
|
+
<p>Please build it by running:</p>
|
|
136
|
+
<p><code>npm run build:dashboard</code></p>
|
|
137
|
+
<p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
|
|
138
|
+
</div>
|
|
139
|
+
</body>
|
|
140
|
+
</html>
|
|
141
|
+
`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Strip /admin prefix to find relative path inside dashboardDir
|
|
146
|
+
let subPath = url.pathname.slice("/admin".length);
|
|
147
|
+
if (subPath.startsWith("/")) {
|
|
148
|
+
subPath = subPath.slice(1);
|
|
149
|
+
}
|
|
150
|
+
if (!subPath) {
|
|
151
|
+
subPath = "index.html";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const normalizedSubPath = subPath.replace(/\/$/, "");
|
|
155
|
+
let filePath = resolve(dashboardDir, subPath);
|
|
156
|
+
|
|
157
|
+
// Security check: ensure filePath is inside dashboardDir
|
|
158
|
+
const rel = relative(dashboardDir, filePath);
|
|
159
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
160
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
161
|
+
res.end("Forbidden");
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check direct file
|
|
166
|
+
let stat: ReturnType<typeof statSync> | null = null;
|
|
167
|
+
if (existsSync(filePath)) {
|
|
168
|
+
stat = statSync(filePath);
|
|
169
|
+
if (stat.isDirectory()) {
|
|
170
|
+
filePath = join(filePath, "index.html");
|
|
171
|
+
stat = existsSync(filePath) ? statSync(filePath) : null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check path.html if direct file not found
|
|
176
|
+
if (!stat) {
|
|
177
|
+
const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
|
|
178
|
+
if (existsSync(htmlPath)) {
|
|
179
|
+
filePath = htmlPath;
|
|
180
|
+
stat = statSync(filePath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check path/index.html
|
|
185
|
+
if (!stat) {
|
|
186
|
+
const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
|
|
187
|
+
if (existsSync(dirIndexPath)) {
|
|
188
|
+
filePath = dirIndexPath;
|
|
189
|
+
stat = statSync(filePath);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fallback to index.html for SPA client-side routing
|
|
194
|
+
if (!stat) {
|
|
195
|
+
filePath = resolve(dashboardDir, "index.html");
|
|
196
|
+
if (existsSync(filePath)) {
|
|
197
|
+
stat = statSync(filePath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!stat) {
|
|
202
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
203
|
+
res.end("Not Found");
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const ext = extname(filePath).toLowerCase();
|
|
208
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
209
|
+
|
|
210
|
+
// Cache static assets (_next/static) aggressively (immutable, 1 year), HTML files no-cache
|
|
211
|
+
const isImmutable = filePath.includes("_next") || ext === ".js" || ext === ".css";
|
|
212
|
+
const cacheControl = isImmutable
|
|
213
|
+
? "public, max-age=31536000, immutable"
|
|
214
|
+
: "public, max-age=0, must-revalidate";
|
|
215
|
+
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
"Content-Type": contentType,
|
|
218
|
+
"Content-Length": stat.size,
|
|
219
|
+
"Cache-Control": cacheControl,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const stream = createReadStream(filePath);
|
|
223
|
+
stream.pipe(res);
|
|
224
|
+
return true;
|
|
225
|
+
}
|