Initialize project scaffold with full web and API setup.
Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack. Made-with: Cursor
This commit is contained in:
249
server/auth.ts
Normal file
249
server/auth.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { RowDataPacket } from "mysql2";
|
||||
import type mysql from "mysql2/promise";
|
||||
import argon2 from "argon2";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const IDENTITY_CANDIDATES = [
|
||||
"email",
|
||||
"unique_name",
|
||||
"phone",
|
||||
"mobile",
|
||||
"username",
|
||||
"user_name",
|
||||
"login_name",
|
||||
"account",
|
||||
"name",
|
||||
"user_unique_id",
|
||||
];
|
||||
|
||||
const PASSWORD_CANDIDATES = [
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"user_password",
|
||||
"password_hash",
|
||||
"user_pwd",
|
||||
"enc_password",
|
||||
];
|
||||
|
||||
function safeIdent(raw: string): string {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(raw)) {
|
||||
throw new Error("Invalid SQL identifier");
|
||||
}
|
||||
return `\`${raw}\``;
|
||||
}
|
||||
|
||||
/** Logical name from env; defaults to MySQL table `user` in database DB_NAME / MYSQL_DATABASE. */
|
||||
function authTable(): string {
|
||||
return process.env.AUTH_USER_TABLE?.trim() || process.env.DB_USER_TABLE?.trim() || "user";
|
||||
}
|
||||
|
||||
/** Match INFORMATION_SCHEMA even when MySQL stores the name as User / USER. */
|
||||
async function resolveActualTableName(
|
||||
pool: mysql.Pool,
|
||||
schema: string,
|
||||
configured: string,
|
||||
): Promise<string> {
|
||||
const [exact] = await pool.query<RowDataPacket[]>(
|
||||
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1",
|
||||
[schema, configured],
|
||||
);
|
||||
if (exact.length > 0) return String(exact[0].TABLE_NAME);
|
||||
|
||||
const [folded] = await pool.query<RowDataPacket[]>(
|
||||
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND LOWER(TABLE_NAME) = LOWER(?) LIMIT 1",
|
||||
[schema, configured],
|
||||
);
|
||||
if (folded.length > 0) {
|
||||
const actual = String(folded[0].TABLE_NAME);
|
||||
if (actual !== configured) {
|
||||
console.log(`[auth] resolved table name "${configured}" -> "${actual}"`);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
throw new Error(`No table matching "${configured}" in database "${schema}"`);
|
||||
}
|
||||
|
||||
async function resolveColumns(
|
||||
pool: mysql.Pool,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<{ identityCols: string[]; passwordCol: string | null }> {
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
||||
[schema, table],
|
||||
);
|
||||
const cols = new Set(rows.map((r) => String(r.COLUMN_NAME)));
|
||||
|
||||
const envLogin = process.env.AUTH_LOGIN_COLUMNS?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const envPass = process.env.AUTH_PASSWORD_COLUMN?.trim();
|
||||
|
||||
const identityCols = envLogin?.length
|
||||
? envLogin.filter((c) => cols.has(c))
|
||||
: IDENTITY_CANDIDATES.filter((c) => cols.has(c));
|
||||
|
||||
const passwordCol =
|
||||
envPass && cols.has(envPass)
|
||||
? envPass
|
||||
: PASSWORD_CANDIDATES.find((c) => cols.has(c)) ?? null;
|
||||
|
||||
return { identityCols, passwordCol };
|
||||
}
|
||||
|
||||
let cachedColumns: { identityCols: string[]; passwordCol: string | null } | null = null;
|
||||
let cachedTable = "";
|
||||
|
||||
async function getAuthColumns(pool: mysql.Pool, schema: string, table: string) {
|
||||
if (cachedColumns && cachedTable === `${schema}.${table}`) return cachedColumns;
|
||||
cachedColumns = await resolveColumns(pool, schema, table);
|
||||
cachedTable = `${schema}.${table}`;
|
||||
console.log(
|
||||
`[auth] table=${table} identity=${cachedColumns.identityCols.join(",") || "(none)"} password=${cachedColumns.passwordCol ?? "(none)"}`,
|
||||
);
|
||||
return cachedColumns;
|
||||
}
|
||||
|
||||
async function verifyPassword(plain: string, stored: string | null | undefined): Promise<boolean> {
|
||||
if (stored == null) return false;
|
||||
const s = String(stored);
|
||||
if (s.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2.verify(s, plain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (s.length >= 59 && /^\$2[aby]\$\d{2}\$/.test(s)) {
|
||||
try {
|
||||
return await bcrypt.compare(plain, s);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return plain === s;
|
||||
}
|
||||
|
||||
function pickDisplayName(row: RowDataPacket, login: string): string {
|
||||
const nick =
|
||||
row.nickname ??
|
||||
row.nick_name ??
|
||||
row.name ??
|
||||
row.user_name ??
|
||||
row.username ??
|
||||
row.unique_name;
|
||||
if (nick != null && String(nick).trim() !== "") return String(nick);
|
||||
return login;
|
||||
}
|
||||
|
||||
function pickUserId(row: RowDataPacket): string | number | null {
|
||||
const v = row.id ?? row.user_id ?? row.uid;
|
||||
if (v != null && v !== "") return v as string | number;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: Express, getPool: () => mysql.Pool): void {
|
||||
app.get("/api/auth/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
|
||||
const configured = authTable();
|
||||
const resolved = await resolveActualTableName(pool, schema, configured);
|
||||
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, resolved);
|
||||
res.json({
|
||||
ok: true,
|
||||
database: schema,
|
||||
userTable: { configured, resolved },
|
||||
loginColumns: identityCols,
|
||||
passwordColumn: passwordCol,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[auth/status]", err);
|
||||
res.status(503).json({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Auth status unavailable",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (req: Request, res: Response) => {
|
||||
const login = typeof req.body?.login === "string" ? req.body.login.trim() : "";
|
||||
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
||||
if (!login || !password) {
|
||||
res.status(400).json({ ok: false, error: "Missing login or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pool = getPool();
|
||||
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
|
||||
const configuredTable = authTable();
|
||||
const table = await resolveActualTableName(pool, schema, configuredTable);
|
||||
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, table);
|
||||
|
||||
if (!passwordCol) {
|
||||
console.error(
|
||||
`[auth/login] Table "${table}" has no known password column; set AUTH_PASSWORD_COLUMN in .env`,
|
||||
);
|
||||
res.status(503).json({ ok: false, error: "Server auth is not configured" });
|
||||
return;
|
||||
}
|
||||
if (identityCols.length === 0) {
|
||||
console.error(
|
||||
`[auth/login] Table "${table}" has no known login columns; set AUTH_LOGIN_COLUMNS in .env`,
|
||||
);
|
||||
res.status(503).json({ ok: false, error: "Server auth is not configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const t = safeIdent(table);
|
||||
const orClause = identityCols.map((c) => `${safeIdent(c)} = ?`).join(" OR ");
|
||||
const params = identityCols.map(() => login);
|
||||
const sql = `SELECT * FROM ${t} WHERE ${orClause} LIMIT 1`;
|
||||
|
||||
const [found] = await pool.query<RowDataPacket[]>(sql, params);
|
||||
const row = found[0];
|
||||
if (!row) {
|
||||
res.status(401).json({ ok: false, error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
|
||||
const storedHash = row[passwordCol] as string | null | undefined;
|
||||
const ok = await verifyPassword(password, storedHash);
|
||||
if (!ok) {
|
||||
res.status(401).json({ ok: false, error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: pickUserId(row),
|
||||
displayName: pickDisplayName(row, login),
|
||||
login,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[auth/login]", err);
|
||||
res.status(503).json({ ok: false, error: "Login temporarily unavailable" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/auth/admin", async (req: Request, res: Response) => {
|
||||
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
||||
const expected = process.env.ADMIN_PASSWORD ?? "admin123";
|
||||
if (!password) {
|
||||
res.status(400).json({ ok: false, error: "Missing password" });
|
||||
return;
|
||||
}
|
||||
const match = await verifyPassword(password, expected);
|
||||
if (!match) {
|
||||
res.status(401).json({ ok: false, error: "Invalid admin password" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
225
server/courseVideos.ts
Normal file
225
server/courseVideos.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { RowDataPacket, ResultSetHeader } from "mysql2";
|
||||
import type mysql from "mysql2/promise";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
|
||||
const TABLE = "youwei_course_video";
|
||||
const UPLOAD_SUBDIR = "videos";
|
||||
|
||||
function uploadsRoot(): string {
|
||||
return path.join(process.cwd(), "uploads");
|
||||
}
|
||||
|
||||
function videosDir(): string {
|
||||
return path.join(uploadsRoot(), UPLOAD_SUBDIR);
|
||||
}
|
||||
|
||||
export async function ensureCourseVideoTable(pool: mysql.Pool): Promise<void> {
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(512) NOT NULL,
|
||||
course_key VARCHAR(32) NOT NULL,
|
||||
course_label_zh VARCHAR(128) NOT NULL,
|
||||
course_label_en VARCHAR(128) NOT NULL,
|
||||
module_name VARCHAR(256) NOT NULL DEFAULT '',
|
||||
file_relpath VARCHAR(1024) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
views INT NOT NULL DEFAULT 0,
|
||||
duration VARCHAR(32) NOT NULL DEFAULT '00:00',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
}
|
||||
|
||||
function mapRow(r: RowDataPacket) {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
courseKey: r.course_key,
|
||||
courseLabelZh: r.course_label_zh,
|
||||
courseLabelEn: r.course_label_en,
|
||||
moduleName: r.module_name,
|
||||
videoUrl: r.file_relpath,
|
||||
status: r.status,
|
||||
views: r.views,
|
||||
duration: r.duration,
|
||||
uploadDate: r.created_at instanceof Date ? r.created_at.toISOString().split("T")[0] : String(r.created_at).split(" ")[0],
|
||||
};
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = videosDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || ".mp4";
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMw = multer({
|
||||
storage,
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith("video/")) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
cb(new Error("Only video files are allowed"));
|
||||
},
|
||||
});
|
||||
|
||||
export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Pool): void {
|
||||
app.get("/api/course-videos", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await ensureCourseVideoTable(pool);
|
||||
const courseKey = typeof req.query.courseKey === "string" ? req.query.courseKey.trim() : "";
|
||||
const scope = typeof req.query.scope === "string" ? req.query.scope : "";
|
||||
const isAdmin = scope === "admin";
|
||||
|
||||
let sql = `SELECT * FROM ${TABLE}`;
|
||||
const params: string[] = [];
|
||||
const where: string[] = [];
|
||||
|
||||
if (!isAdmin) {
|
||||
where.push("status = ?");
|
||||
params.push("published");
|
||||
}
|
||||
if (courseKey) {
|
||||
where.push("course_key = ?");
|
||||
params.push(courseKey);
|
||||
}
|
||||
if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
|
||||
sql += " ORDER BY created_at DESC";
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
|
||||
res.json({ ok: true, videos: rows.map(mapRow) });
|
||||
} catch (err) {
|
||||
console.error("[course-videos GET]", err);
|
||||
res.status(503).json({ ok: false, error: "Could not load videos" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/course-videos",
|
||||
(req, res, next) => {
|
||||
uploadMw.single("file")(req, res, (e) => {
|
||||
if (e) {
|
||||
res.status(400).json({ ok: false, error: e instanceof Error ? e.message : "Upload failed" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await ensureCourseVideoTable(pool);
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({ ok: false, error: "Video file is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = String(req.body.title || "").trim();
|
||||
const courseKey = String(req.body.courseKey || "").trim();
|
||||
const courseLabelZh = String(req.body.courseLabelZh || "").trim();
|
||||
const courseLabelEn = String(req.body.courseLabelEn || "").trim();
|
||||
const moduleName = String(req.body.moduleName || "").trim();
|
||||
const statusRaw = String(req.body.status || "published").trim().toLowerCase();
|
||||
const status = statusRaw === "draft" ? "draft" : "published";
|
||||
|
||||
if (!title || !courseKey) {
|
||||
fs.unlink(file.path, () => {});
|
||||
res.status(400).json({ ok: false, error: "Missing title or courseKey" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const rel = `/uploads/${UPLOAD_SUBDIR}/${file.filename}`;
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`,
|
||||
[id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status],
|
||||
);
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
||||
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
|
||||
} catch (err) {
|
||||
console.error("[course-videos POST]", err);
|
||||
res.status(503).json({ ok: false, error: "Could not save video" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch("/api/course-videos/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await ensureCourseVideoTable(pool);
|
||||
const id = req.params.id;
|
||||
const status = typeof req.body?.status === "string" ? req.body.status.trim().toLowerCase() : "";
|
||||
if (status !== "published" && status !== "draft" && status !== "processing") {
|
||||
res.status(400).json({ ok: false, error: "Invalid status" });
|
||||
return;
|
||||
}
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`UPDATE ${TABLE} SET status = ? WHERE id = ?`,
|
||||
[status, id],
|
||||
);
|
||||
if (result.affectedRows === 0) {
|
||||
res.status(404).json({ ok: false, error: "Not found" });
|
||||
return;
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
||||
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
|
||||
} catch (err) {
|
||||
console.error("[course-videos PATCH]", err);
|
||||
res.status(503).json({ ok: false, error: "Could not update video" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/course-videos/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await ensureCourseVideoTable(pool);
|
||||
const id = req.params.id;
|
||||
const [rows] = await pool.query<RowDataPacket[]>(`SELECT file_relpath FROM ${TABLE} WHERE id = ?`, [id]);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
res.status(404).json({ ok: false, error: "Not found" });
|
||||
return;
|
||||
}
|
||||
const rel = String(row.file_relpath || "");
|
||||
await pool.execute(`DELETE FROM ${TABLE} WHERE id = ?`, [id]);
|
||||
if (rel.startsWith("/uploads/")) {
|
||||
const abs = path.join(process.cwd(), rel.replace(/^\//, ""));
|
||||
fs.unlink(abs, () => {});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[course-videos DELETE]", err);
|
||||
res.status(503).json({ ok: false, error: "Could not delete video" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/course-videos/:id/view", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await ensureCourseVideoTable(pool);
|
||||
const id = req.params.id;
|
||||
await pool.execute(`UPDATE ${TABLE} SET views = views + 1 WHERE id = ?`, [id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[course-videos view]", err);
|
||||
res.status(503).json({ ok: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
78
server/index.ts
Normal file
78
server/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Local API for MySQL. Keeps credentials off the client.
|
||||
* Run: npm run server, or npm run dev (starts API + Vite together).
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import type { RowDataPacket } from "mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import { registerAuthRoutes } from "./auth";
|
||||
import { registerCourseVideoRoutes } from "./courseVideos";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN?.split(",").map((s) => s.trim()) ?? true,
|
||||
}),
|
||||
);
|
||||
|
||||
const apiPort = Number(process.env.API_PORT ?? 3001);
|
||||
const apiHost = process.env.API_HOST ?? "127.0.0.1";
|
||||
|
||||
function sslOption(): boolean | { rejectUnauthorized: boolean } | undefined {
|
||||
if (process.env.MYSQL_SSL !== "true") return undefined;
|
||||
if (process.env.MYSQL_SSL_INSECURE === "true") {
|
||||
return { rejectUnauthorized: false };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
function getPool(): mysql.Pool {
|
||||
if (pool) return pool;
|
||||
const host = process.env.MYSQL_HOST || process.env.DB_HOST;
|
||||
const user = process.env.MYSQL_USER || process.env.DB_USER;
|
||||
const database = process.env.MYSQL_DATABASE || process.env.DB_NAME;
|
||||
if (!host || !user || !database) {
|
||||
throw new Error("Set DB_HOST, DB_USER, DB_NAME (or MYSQL_*) in .env");
|
||||
}
|
||||
const portEnv = process.env.MYSQL_PORT || process.env.DB_PORT;
|
||||
pool = mysql.createPool({
|
||||
host,
|
||||
port: Number(portEnv ?? 3306),
|
||||
user,
|
||||
password: process.env.MYSQL_PASSWORD ?? process.env.DB_PASSWORD ?? "",
|
||||
database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT ?? 5),
|
||||
ssl: sslOption(),
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
registerAuthRoutes(app, getPool);
|
||||
registerCourseVideoRoutes(app, getPool);
|
||||
|
||||
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/db/ping", async (_req, res) => {
|
||||
try {
|
||||
const [rows] = await getPool().query<RowDataPacket[]>("SELECT 1 AS ok");
|
||||
res.json({ ok: true, rows });
|
||||
} catch (err) {
|
||||
console.error("[db/ping]", err);
|
||||
res.status(503).json({ ok: false, error: "Database unreachable" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(apiPort, apiHost, () => {
|
||||
console.log(`API listening on http://${apiHost}:${apiPort}`);
|
||||
});
|
||||
Reference in New Issue
Block a user