- Switch frontend sign-in to /api/v1/auth/sign-in endpoint (includes persona) - Add updateUser() to App context for proper state management - Fix Settings.tsx save flow to use updateUser() instead of broken localStorage merge - Remove unused web AuthController (gadget-code/src/controllers/auth.ts) - Fix UserApiControllerV1 to return flat user object instead of double-wrapped - Remove SessionType enum and references (dead code) - Add proper server sign-out call before clearing local state Resolves issue where User Settings view didn't display persona text even though it existed in the database.
382 lines
9.3 KiB
TypeScript
382 lines
9.3 KiB
TypeScript
// web-app.ts
|
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
|
// All Rights Reserved
|
|
|
|
import env from "./config/env.js";
|
|
import assert from "node:assert";
|
|
|
|
import path from "node:path";
|
|
import http from "node:http";
|
|
|
|
import "./lib/db.js";
|
|
import redis from "./lib/redis.js";
|
|
|
|
import cookieParser from "cookie-parser";
|
|
import compress from "compression";
|
|
import methodOverride from "method-override";
|
|
import favicon from "serve-favicon";
|
|
|
|
import session, { SessionOptions } from "express-session";
|
|
import { RedisStore } from "connect-redis";
|
|
|
|
import express, { NextFunction, Request, Response } from "express";
|
|
import morgan from "morgan";
|
|
import * as rfs from "rotating-file-stream";
|
|
|
|
import dayjs from "dayjs";
|
|
import relativeTime from "dayjs/plugin/relativeTime.js";
|
|
dayjs.extend(relativeTime);
|
|
|
|
import numeral from "numeral";
|
|
|
|
type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined;
|
|
|
|
import { GadgetComponent, GadgetLog } from "@gadget/api";
|
|
|
|
import { User } from "./models/user.js";
|
|
|
|
import { ApiController } from "./controllers/api.js";
|
|
import { HomeController } from "./controllers/home.js";
|
|
import { UserController } from "./controllers/user.js";
|
|
|
|
import {
|
|
ApiClientService,
|
|
SessionService,
|
|
SocketService,
|
|
startServices,
|
|
stopServices,
|
|
} from "./services/index.js";
|
|
|
|
class DtpWebAppServer implements GadgetComponent {
|
|
private log: GadgetLog;
|
|
|
|
private app?: express.Application;
|
|
private server?: http.Server;
|
|
|
|
get name(): string {
|
|
return "DtpWebAppServer";
|
|
}
|
|
get slug(): string {
|
|
return "web-app";
|
|
}
|
|
|
|
constructor() {
|
|
this.log = new GadgetLog(this);
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.hookProcessSignals();
|
|
|
|
await startServices();
|
|
|
|
await this.createExpressApp();
|
|
await this.startHttpServer();
|
|
|
|
this.log.info(
|
|
`DTP application server online at http://${env.https.address}:${env.https.port}/`,
|
|
);
|
|
}
|
|
|
|
async stop(): Promise<number> {
|
|
await this.stopHttpServer();
|
|
await stopServices();
|
|
|
|
return 0;
|
|
}
|
|
|
|
async createExpressApp(): Promise<void> {
|
|
this.log.info("creating ExpressJS app");
|
|
this.app = express();
|
|
this.app.set("trust proxy", true);
|
|
this.app.set("x-powered-by", false);
|
|
|
|
this.app.locals.env = env;
|
|
this.app.locals.pkg = env.pkg;
|
|
this.app.locals.site = env.site;
|
|
this.app.locals.dayjs = dayjs;
|
|
this.app.locals.numeral = numeral;
|
|
|
|
if (env.log.https.enabled) {
|
|
this.log.info("creating HTTPS access log", {
|
|
name: env.log.https.name,
|
|
path: env.log.https.path,
|
|
});
|
|
const httpLogStream = rfs.createStream(
|
|
env.log.https.name || "gadget-code-access.log",
|
|
{
|
|
interval: "1d",
|
|
path: env.log.https.path,
|
|
compress: "gzip",
|
|
},
|
|
);
|
|
this.app.use(morgan(env.log.https.format, { stream: httpLogStream }));
|
|
}
|
|
|
|
/*
|
|
* Static file services
|
|
*/
|
|
this.app.use(
|
|
favicon(path.join(env.installDir, "assets", "icon", "icon-32x32.png")),
|
|
);
|
|
this.app.use(
|
|
"/assets",
|
|
express.static(path.resolve(env.installDir, "assets")),
|
|
);
|
|
this.app.use(
|
|
"/client",
|
|
express.static(path.resolve(env.installDir, "dist", "client")),
|
|
);
|
|
|
|
/*
|
|
* REST, User, and Session support
|
|
*/
|
|
|
|
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
|
await SessionService.recordWebVisit(req);
|
|
|
|
if (req.query) {
|
|
res.locals.query = req.query;
|
|
}
|
|
|
|
const contentType = req.get("content-type") || "";
|
|
if (!contentType.includes("application/json")) {
|
|
return next();
|
|
}
|
|
|
|
let data: string = "";
|
|
req.on("data", (chunk) => {
|
|
data += chunk.toString();
|
|
});
|
|
req.on("end", () => {
|
|
req.rawBody = data;
|
|
if (!data) {
|
|
req.body = {};
|
|
return next();
|
|
}
|
|
try {
|
|
const parsedData = JSON.parse(data);
|
|
req.body = parsedData;
|
|
} catch (err) {
|
|
return next(err);
|
|
}
|
|
next();
|
|
});
|
|
});
|
|
|
|
this.app.use(express.json());
|
|
this.app.use(express.urlencoded({ extended: true }));
|
|
this.app.use(cookieParser(env.session.secret));
|
|
this.app.use(compress());
|
|
this.app.use(methodOverride());
|
|
|
|
const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
|
|
const SESSION_DURATION = ONE_WEEK;
|
|
|
|
/*
|
|
* ExpressJS session configuration
|
|
*/
|
|
|
|
const store = new RedisStore({
|
|
client: redis,
|
|
});
|
|
|
|
assert(env.session.secret, "Must define an HTTP session secret");
|
|
const sessionConfig: SessionOptions = {
|
|
name: `mtsess-${env.site.domainKey}-${env.NODE_ENV}`,
|
|
secret: env.session.secret,
|
|
resave: true,
|
|
saveUninitialized: true,
|
|
proxy: env.session.trustProxy,
|
|
cookie: {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: env.session.cookie.secure,
|
|
sameSite: env.session.cookie.sameSite as SameSiteOption,
|
|
maxAge: SESSION_DURATION,
|
|
},
|
|
store,
|
|
};
|
|
|
|
this.app.use(session(sessionConfig));
|
|
this.app.use(ApiClientService.middleware());
|
|
this.app.use(this.restoreUserSession.bind(this));
|
|
|
|
this.app.use("/", await this.createAppRouter());
|
|
|
|
this.app.use(this.defaultErrorHandler.bind(this));
|
|
}
|
|
|
|
async createAppRouter(): Promise<express.Router> {
|
|
const router: express.Router = express.Router();
|
|
|
|
const apiController = new ApiController();
|
|
await apiController.start();
|
|
this.log.info("mounting ApiController", {
|
|
route: apiController.route,
|
|
});
|
|
router.use(apiController.route, apiController.router);
|
|
|
|
const userController = new UserController();
|
|
await userController.start();
|
|
this.log.info("mounting UserController", {
|
|
route: userController.route,
|
|
});
|
|
router.use(userController.route, userController.router);
|
|
|
|
const homeController = new HomeController();
|
|
await homeController.start();
|
|
this.log.info("mounting HomeController", { route: homeController.route });
|
|
router.use(homeController.route, homeController.router);
|
|
|
|
return router;
|
|
}
|
|
|
|
async startHttpServer(): Promise<void> {
|
|
return new Promise<void>(async (resolve) => {
|
|
assert(this.app, "ExpressJS app instance is required");
|
|
this.log.info("starting HTTP server", {
|
|
address: env.https.address,
|
|
port: env.https.port,
|
|
});
|
|
|
|
/*
|
|
* Create HTTP server
|
|
*/
|
|
this.server = http.createServer(this.app);
|
|
|
|
/*
|
|
* Start the Socket.IO service
|
|
*/
|
|
await SocketService.listen(this.server);
|
|
|
|
/*
|
|
* Start HTTP server
|
|
*/
|
|
this.server.listen(
|
|
env.https.port,
|
|
env.https.address,
|
|
env.https.backlog,
|
|
() => {
|
|
this.log.debug("HTTP server started");
|
|
resolve();
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async stopHttpServer(): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (!this.server) {
|
|
return;
|
|
}
|
|
|
|
this.log.info("closing all HTTP connections");
|
|
this.server.closeAllConnections();
|
|
|
|
if (!this.server.listening) {
|
|
delete this.server;
|
|
return resolve();
|
|
}
|
|
|
|
this.log.info("closing HTTP server");
|
|
this.server.close((error) => {
|
|
if (error) {
|
|
return reject(error);
|
|
}
|
|
delete this.server;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async defaultErrorHandler(
|
|
err: Error,
|
|
_req: Request,
|
|
res: Response,
|
|
_next: NextFunction,
|
|
) {
|
|
this.log.error("ExpressJS error", { error: err });
|
|
res.locals.errorCode = err.statusCode || 500;
|
|
res.locals.error = err;
|
|
res.status(res.locals.errorCode).json({
|
|
success: false,
|
|
message: err.message,
|
|
});
|
|
}
|
|
|
|
async restoreUserSession(
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction,
|
|
): Promise<void> {
|
|
let token;
|
|
if (req.headers["authorization"]) {
|
|
const tokens = req.headers["authorization"].split(" ");
|
|
token = tokens[tokens.length - 1];
|
|
}
|
|
if (!token && (!req.session || !req.session.user)) {
|
|
return next();
|
|
}
|
|
|
|
if (token) {
|
|
req.user = await SessionService.verifyJsonWebToken(token);
|
|
} else {
|
|
req.user = await User.findOne({ _id: req.session.user._id });
|
|
}
|
|
|
|
res.locals.user = req.user;
|
|
if (!res.locals.user) {
|
|
req.session.destroy((err: Error) => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
return next();
|
|
});
|
|
return;
|
|
}
|
|
|
|
return next();
|
|
}
|
|
|
|
hookProcessSignals(): void {
|
|
process.title = this.name;
|
|
process.on("unhandledRejection", async (error: Error, p) => {
|
|
this.log.error("Unhandled rejection", {
|
|
error: error,
|
|
promise: p,
|
|
stack: error.stack,
|
|
});
|
|
|
|
const exitCode: number = await this.stop();
|
|
process.nextTick(() => {
|
|
process.exit(exitCode);
|
|
});
|
|
});
|
|
|
|
process.on("warning", (error) => {
|
|
if (error.name === "DeprecationWarning") {
|
|
return;
|
|
}
|
|
this.log.alert("warning", { error });
|
|
});
|
|
|
|
process.once("SIGINT", async () => {
|
|
this.log.info("SIGINT received (requesting shutdown)");
|
|
const exitCode = await this.stop();
|
|
process.nextTick(() => {
|
|
process.exit(exitCode);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
const server = new DtpWebAppServer();
|
|
await server.start();
|
|
} catch (error) {
|
|
console.error("DTP Web App server error", { error });
|
|
process.exit(-1);
|
|
}
|
|
})();
|