gadget/gadget-code/src/web-app.ts
Rob Colbert 896aff1b02 Fix User Settings persona display issue
- 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.
2026-05-12 01:15:00 -04:00

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);
}
})();