Browse Source

letting prettier do its thing on the code base

master
Rob Colbert 3 months ago
parent
commit
efb1412fa5
  1. 1
      package.json
  2. 10
      pnpm-lock.yaml
  3. 86
      src/app/controllers/auth.ts
  4. 37
      src/app/controllers/home.ts
  5. 83
      src/app/controllers/lib/populators.ts
  6. 40
      src/app/controllers/manifest.ts
  7. 69
      src/app/controllers/user.ts
  8. 40
      src/app/controllers/welcome.ts
  9. 30
      src/app/models/broadcast-show.ts
  10. 14
      src/app/models/connect-token.ts
  11. 8
      src/app/models/csrf-token.ts
  12. 41
      src/app/models/email-blacklist.ts
  13. 4
      src/app/models/email-log.ts
  14. 17
      src/app/models/email-verify.ts
  15. 11
      src/app/models/episode.ts
  16. 2
      src/app/models/feed-item.ts
  17. 2
      src/app/models/feed.ts
  18. 27
      src/app/models/image.ts
  19. 11
      src/app/models/lib/email-blacklist-flags.ts
  20. 13
      src/app/models/lib/image-file.ts
  21. 2
      src/app/models/lib/image-metadata.ts
  22. 7
      src/app/models/lib/process-endpoint.ts
  23. 14
      src/app/models/lib/process-stats.ts
  24. 18
      src/app/models/lib/speech-personality.ts
  25. 7
      src/app/models/lib/user-apis.ts
  26. 7
      src/app/models/lib/user-flags.ts
  27. 9
      src/app/models/lib/user-permissions.ts
  28. 26
      src/app/models/process-stats.ts
  29. 16
      src/app/models/process.ts
  30. 32
      src/app/models/user.ts
  31. 24
      src/app/models/video.ts
  32. 26
      src/app/services/cache.ts
  33. 50
      src/app/services/crypto.ts
  34. 27
      src/app/services/csrf-token.ts
  35. 19
      src/app/services/display-engine.ts
  36. 191
      src/app/services/email.ts
  37. 52
      src/app/services/feed.ts
  38. 26
      src/app/services/image.ts
  39. 24
      src/app/services/job-queue.ts
  40. 8
      src/app/services/minio.ts
  41. 66
      src/app/services/openai.ts
  42. 107
      src/app/services/process.ts
  43. 134
      src/app/services/session.ts
  44. 19
      src/app/services/sidebar.ts
  45. 9
      src/app/services/text.ts
  46. 76
      src/app/services/user.ts
  47. 63
      src/app/services/video.ts
  48. 1
      src/client/js/lib/app.ts
  49. 9
      src/client/js/lib/log.ts
  50. 3
      src/client/js/newsroom-app.ts
  51. 40
      src/config/env.ts
  52. 38
      src/lib/core/base.ts
  53. 19
      src/lib/core/component-registry.ts
  54. 19
      src/lib/core/log-file.ts
  55. 8
      src/lib/core/log-transport-console.ts
  56. 19
      src/lib/core/log-transport-file.ts
  57. 6
      src/lib/core/log-transport.ts
  58. 4
      src/lib/core/log.ts
  59. 15
      src/lib/core/platform.ts
  60. 19
      src/lib/core/process.ts
  61. 17
      src/lib/core/service.ts
  62. 1
      src/lib/core/unzalgo.ts
  63. 7
      src/lib/core/worker.ts
  64. 33
      src/lib/web/controller.ts
  65. 56
      src/lib/web/display-list.ts
  66. 1
      src/lib/web/error.ts
  67. 4
      src/lib/web/file.ts
  68. 138
      src/lib/web/server.ts
  69. 36
      src/newsroom-web.ts
  70. 35
      src/release.ts
  71. 17
      src/speechgen.ts
  72. 22
      src/workers/newsroom.ts
  73. 55
      src/workers/newsroom/fetch-news.ts

1
package.json

@ -107,6 +107,7 @@
"globals": "^15.14.0", "globals": "^15.14.0",
"less": "^4.2.2", "less": "^4.2.2",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"prettier": "^3.4.2",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsc-watch": "^6.2.1", "tsc-watch": "^6.2.1",

10
pnpm-lock.yaml

@ -249,6 +249,9 @@ importers:
nodemon: nodemon:
specifier: ^3.1.9 specifier: ^3.1.9
version: 3.1.9 version: 3.1.9
prettier:
specifier: ^3.4.2
version: 3.4.2
simple-git: simple-git:
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0 version: 3.27.0
@ -2254,6 +2257,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
[email protected]:
resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
engines: {node: '>=14'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-kCLsENsJ6h5Bcq106Q3YMSxuz2q3jtIXP7fgDB/+jZjUsZjRjAoL9Lr1TVwAEcugufVBhr5Mfd9L7P6d+SR+Yw==} resolution: {integrity: sha512-kCLsENsJ6h5Bcq106Q3YMSxuz2q3jtIXP7fgDB/+jZjUsZjRjAoL9Lr1TVwAEcugufVBhr5Mfd9L7P6d+SR+Yw==}
@ -4965,6 +4973,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}

86
src/app/controllers/auth.ts

@ -10,11 +10,7 @@ import passport from "passport";
import svgCaptcha from "svg-captcha"; import svgCaptcha from "svg-captcha";
import { import { WebServer, WebController, WebError } from "../../lib/dtplib.js";
WebServer,
WebController,
WebError,
} from "../../lib/dtplib.js";
import { UserService } from "../services/user.js"; import { UserService } from "../services/user.js";
import CsrfTokenService from "../services/csrf-token.js"; import CsrfTokenService from "../services/csrf-token.js";
@ -23,8 +19,12 @@ import { ConnectToken } from "../models/connect-token.js";
import { IUser } from "app/models/user.js"; import { IUser } from "app/models/user.js";
export default class AuthController extends WebController { export default class AuthController extends WebController {
static get name ( ) : string { return "AuthController"; } static get name(): string {
static get slug ( ) : string { return "auth"; } return "AuthController";
}
static get slug(): string {
return "auth";
}
constructor(server: WebServer) { constructor(server: WebServer) {
super(server, AuthController); super(server, AuthController);
@ -35,7 +35,8 @@ export default class AuthController extends WebController {
} }
async start(): Promise<void> { async start(): Promise<void> {
const csrfTokenService = this.platform.components.getService<CsrfTokenService>("csrfToken"); const csrfTokenService =
this.platform.components.getService<CsrfTokenService>("csrfToken");
const signupToken: RequestHandler = csrfTokenService.middleware({ const signupToken: RequestHandler = csrfTokenService.middleware({
name: "user-create", name: "user-create",
allowReuse: false, allowReuse: false,
@ -50,20 +51,21 @@ export default class AuthController extends WebController {
this.router.post("/signup", signupToken, this.postSignup.bind(this)); this.router.post("/signup", signupToken, this.postSignup.bind(this));
this.router.post("/login", loginToken, this.postLogin.bind(this)); this.router.post("/login", loginToken, this.postLogin.bind(this));
this.router.get( this.router.get("/google", (req, res, next) => {
'/google',
(req, res, next) => {
passport.authenticate("google", { passport.authenticate("google", {
session: true, session: true,
scope: ["email", "profile"], scope: ["email", "profile"],
})(req, res, next); })(req, res, next);
} });
);
this.router.get( this.router.get(
'/google/callback', "/google/callback",
passport.authenticate("google"), passport.authenticate("google"),
async (req: Request, res: Response, next: NextFunction) : Promise<void> => { async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
assert(req.user, "User is required"); assert(req.user, "User is required");
req.login(req.user, async (error: Error | null) => { req.login(req.user, async (error: Error | null) => {
if (error) { if (error) {
@ -75,7 +77,7 @@ export default class AuthController extends WebController {
res.locals.serviceName = "Google"; res.locals.serviceName = "Google";
res.locals.user = req.user; res.locals.user = req.user;
res.render('welcome/connected'); res.render("welcome/connected");
}); });
} }
); );
@ -86,8 +88,13 @@ export default class AuthController extends WebController {
this.router.get("/logout", this.getLogout.bind(this)); this.router.get("/logout", this.getLogout.bind(this));
} }
async postSignup (req: Request, res: Response, next: NextFunction) : Promise<void> { async postSignup(
const userService = this.platform.components.getService<UserService>("user"); req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const userService =
this.platform.components.getService<UserService>("user");
try { try {
assert(req.session, "Active HTTP session is required"); assert(req.session, "Active HTTP session is required");
@ -100,12 +107,12 @@ export default class AuthController extends WebController {
const user: IUser = await userService.login( const user: IUser = await userService.login(
req.body.email as string, req.body.email as string,
req.body.password as string, req.body.password as string
); );
req.logIn(user, (error: Error | null) => { req.logIn(user, (error: Error | null) => {
if (error) { if (error) {
this.log.error('failed to regenerate user session', { error }); this.log.error("failed to regenerate user session", { error });
return next(error); return next(error);
} }
return res.redirect(303, "/"); return res.redirect(303, "/");
@ -116,13 +123,17 @@ export default class AuthController extends WebController {
} }
} }
async postLogin (req: Request, res: Response, next: NextFunction) : Promise<void> { async postLogin(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try { try {
assert(req.session, "An active session is required"); assert(req.session, "An active session is required");
const redirectUri = req.session.loginReturnTo || '/'; const redirectUri = req.session.loginReturnTo || "/";
this.log.debug('starting passport authenticate', { redirectUri }); this.log.debug("starting passport authenticate", { redirectUri });
passport.authenticate('mt-local', (error: Error, user: IUser) : void => { passport.authenticate("mt-local", (error: Error, user: IUser): void => {
if (error) { if (error) {
req.session.loginResult = error.toString(); req.session.loginResult = error.toString();
return next(error); return next(error);
@ -131,7 +142,9 @@ export default class AuthController extends WebController {
req.session.loginResult = "No user account"; req.session.loginResult = "No user account";
return res.redirect("/welcome/login"); return res.redirect("/welcome/login");
} }
this.log.info("establishing authenticated user session", { user: { _id: user._id } }); this.log.info("establishing authenticated user session", {
user: { _id: user._id },
});
req.login(user, async (error: Error | null) => { req.login(user, async (error: Error | null) => {
if (error) { if (error) {
this.log.error("failed to start user session", { error }); this.log.error("failed to start user session", { error });
@ -146,7 +159,11 @@ export default class AuthController extends WebController {
} }
} }
async getSocketToken (req: Request, res: Response, next: NextFunction) : Promise<void> { async getSocketToken(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try { try {
if (!req.session || !req.user) { if (!req.session || !req.user) {
res.status(403).json({ res.status(403).json({
@ -157,7 +174,7 @@ export default class AuthController extends WebController {
} }
const token = await ConnectToken.create({ const token = await ConnectToken.create({
created: new Date(), created: new Date(),
userType: 'User', userType: "User",
user: req.user._id, user: req.user._id,
token: uuidv4(), token: uuidv4(),
}); });
@ -171,8 +188,13 @@ export default class AuthController extends WebController {
} }
} }
async getSignupForm (req: Request, res: Response, next: NextFunction) : Promise<void> { async getSignupForm(
const csrfTokenService = this.platform.components.getService<CsrfTokenService>("csrfToken"); req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const csrfTokenService =
this.platform.components.getService<CsrfTokenService>("csrfToken");
try { try {
assert(req.session, "HTTP session is required"); assert(req.session, "HTTP session is required");
@ -205,7 +227,11 @@ export default class AuthController extends WebController {
res.render("auth/login"); res.render("auth/login");
} }
async getLogout (req: Request, res: Response, next: NextFunction) : Promise<void> { async getLogout(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try { try {
if (!req.session) { if (!req.session) {
return next(new WebError(400, "You are not currently logged in.")); return next(new WebError(400, "You are not currently logged in."));

37
src/app/controllers/home.ts

@ -6,37 +6,46 @@ import { NextFunction, Request, Response } from "express";
import { SidebarService } from "../services/sidebar.js"; import { SidebarService } from "../services/sidebar.js";
import { import { WebServer, WebController } from "../../lib/dtplib.js";
WebServer,
WebController,
} from '../../lib/dtplib.js';
import { FeedService } from "app/services/feed.js"; import { FeedService } from "app/services/feed.js";
export class HomeController extends WebController { export class HomeController extends WebController {
static get name(): string {
static get name ( ) : string { return 'HomeController'; } return "HomeController";
static get slug ( ) : string { return 'home'; } }
static get slug(): string {
return "home";
}
constructor(server: WebServer) { constructor(server: WebServer) {
super(server, HomeController); super(server, HomeController);
} }
get route ( ) : string { return '/'; } get route(): string {
return "/";
}
async start(): Promise<void> { async start(): Promise<void> {
const sidebarService = this.platform.components.getService<SidebarService>("sidebar"); const sidebarService =
this.router.get('/', sidebarService.middleware(), this.getHome.bind(this)); this.platform.components.getService<SidebarService>("sidebar");
this.router.get("/", sidebarService.middleware(), this.getHome.bind(this));
} }
async getHome (req: Request, res: Response, next: NextFunction) : Promise<void> { async getHome(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const feedService = this.getService<FeedService>("feed"); const feedService = this.getService<FeedService>("feed");
try { try {
res.locals.pagination = this.getPaginationParameters(req, 20); res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.feedLibrary = await feedService.getUnifiedFeed(res.locals.pagination); res.locals.feedLibrary = await feedService.getUnifiedFeed(
res.locals.pagination
);
if (req.user) { if (req.user) {
return res.render('home-auth'); return res.render("home-auth");
} }
return res.render('home'); return res.render("home");
} catch (error) { } catch (error) {
this.log.error("failed to present the Home view", { error }); this.log.error("failed to present the Home view", { error });
return next(error); return next(error);

83
src/app/controllers/lib/populators.ts

@ -7,37 +7,54 @@ import assert from "node:assert";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { Request, Response, NextFunction, RequestHandler } from "express"; import { Request, Response, NextFunction, RequestHandler } from "express";
import { import { WebController, WebError } from "../../../lib/dtplib.js";
WebController,
WebError,
} from "../../../lib/dtplib.js";
import { ImageService } from "../../services/image.js"; import { ImageService } from "../../services/image.js";
import { UserService } from "../../services/user.js"; import { UserService } from "../../services/user.js";
export function populateAccountByEmail (controller: WebController) : RequestHandler { export function populateAccountByEmail(
const userService = controller.platform.components.getService<UserService>("user"); controller: WebController
return async function (_req: Request, res: Response, next: NextFunction, emailAddress?: string) : Promise<void> { ): RequestHandler {
const userService =
controller.platform.components.getService<UserService>("user");
return async function (
_req: Request,
res: Response,
next: NextFunction,
emailAddress?: string
): Promise<void> {
assert(emailAddress, "Email address is required"); assert(emailAddress, "Email address is required");
try { try {
res.locals.userProfile = await userService.getAccountByEmailAddress(emailAddress); res.locals.userProfile =
await userService.getAccountByEmailAddress(emailAddress);
if (!res.locals.userProfile) { if (!res.locals.userProfile) {
return next(new WebError(404, "User Account not found")); return next(new WebError(404, "User Account not found"));
} }
return next(); return next();
} catch (error) { } catch (error) {
controller.log.error("failed to populate user account by email address", { emailAddress, error }); controller.log.error("failed to populate user account by email address", {
emailAddress,
error,
});
return next(error); return next(error);
} }
}; };
} }
export function populateAccountById(controller: WebController): RequestHandler { export function populateAccountById(controller: WebController): RequestHandler {
const userService = controller.platform.components.getService<UserService>("user"); const userService =
return async function (_req: Request, res: Response, next: NextFunction, userId?: string) : Promise<void> { controller.platform.components.getService<UserService>("user");
return async function (
_req: Request,
res: Response,
next: NextFunction,
userId?: string
): Promise<void> {
assert(userId, "User ID is required"); assert(userId, "User ID is required");
try { try {
res.locals.userProfile = await userService.getAccountById(Types.ObjectId.createFromHexString(userId)); res.locals.userProfile = await userService.getAccountById(
Types.ObjectId.createFromHexString(userId)
);
if (!res.locals.userProfile) { if (!res.locals.userProfile) {
return next(new WebError(404, "User profile not found")); return next(new WebError(404, "User profile not found"));
} }
@ -50,8 +67,14 @@ export function populateAccountById (controller: WebController) : RequestHandler
} }
export function populateImageId(controller: WebController): RequestHandler { export function populateImageId(controller: WebController): RequestHandler {
const imageService = controller.platform.components.getService<ImageService>("image"); const imageService =
return async function (_req: Request, res: Response, next: NextFunction, imageId?: string) : Promise<void> { controller.platform.components.getService<ImageService>("image");
return async function (
_req: Request,
res: Response,
next: NextFunction,
imageId?: string
): Promise<void> {
assert(imageId, "imageId parameter required"); assert(imageId, "imageId parameter required");
try { try {
res.locals.image = await imageService.getById( res.locals.image = await imageService.getById(
@ -69,28 +92,46 @@ export function populateImageId (controller: WebController) : RequestHandler {
} }
export function populateUserByEmail(controller: WebController): RequestHandler { export function populateUserByEmail(controller: WebController): RequestHandler {
const userService = controller.platform.components.getService<UserService>("user"); const userService =
return async function (_req: Request, res: Response, next: NextFunction, emailAddress?: string) : Promise<void> { controller.platform.components.getService<UserService>("user");
return async function (
_req: Request,
res: Response,
next: NextFunction,
emailAddress?: string
): Promise<void> {
assert(emailAddress, "Email address is required"); assert(emailAddress, "Email address is required");
try { try {
res.locals.userProfile = await userService.getByEmailAddress(emailAddress); res.locals.userProfile =
await userService.getByEmailAddress(emailAddress);
if (!res.locals.userProfile) { if (!res.locals.userProfile) {
return next(new WebError(404, "User profile not found")); return next(new WebError(404, "User profile not found"));
} }
return next(); return next();
} catch (error) { } catch (error) {
controller.log.error("failed to populate user by email address", { emailAddress, error }); controller.log.error("failed to populate user by email address", {
emailAddress,
error,
});
return next(error); return next(error);
} }
}; };
} }
export function populateUserById(controller: WebController): RequestHandler { export function populateUserById(controller: WebController): RequestHandler {
const userService = controller.platform.components.getService<UserService>("user"); const userService =
return async function (_req: Request, res: Response, next: NextFunction, userId?: string) : Promise<void> { controller.platform.components.getService<UserService>("user");
return async function (
_req: Request,
res: Response,
next: NextFunction,
userId?: string
): Promise<void> {
assert(userId, "User ID is required"); assert(userId, "User ID is required");
try { try {
res.locals.userProfile = await userService.getById(Types.ObjectId.createFromHexString(userId)); res.locals.userProfile = await userService.getById(
Types.ObjectId.createFromHexString(userId)
);
if (!res.locals.userProfile) { if (!res.locals.userProfile) {
return next(new WebError(404, "User profile not found")); return next(new WebError(404, "User profile not found"));
} }

40
src/app/controllers/manifest.ts

@ -6,10 +6,7 @@ import env from "../../config/env.js";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { import { WebServer, WebController } from "../../lib/dtplib.js";
WebServer,
WebController,
} from '../../lib/dtplib.js';
type AppManifestIcon = { type AppManifestIcon = {
src: string; src: string;
@ -62,31 +59,40 @@ type AppManifest = {
}; };
export class ManifestController extends WebController { export class ManifestController extends WebController {
static get name(): string {
static get name ( ) : string { return 'ManifestController'; } return "ManifestController";
static get slug ( ) : string { return 'manifest'; } }
static get slug(): string {
return "manifest";
}
constructor(server: WebServer) { constructor(server: WebServer) {
super(server, ManifestController); super(server, ManifestController);
} }
get route ( ) : string { return '/manifest.json'; } get route(): string {
return "/manifest.json";
}
async start(): Promise<void> { async start(): Promise<void> {
this.router.get('/', this.getManifest.bind(this)); this.router.get("/", this.getManifest.bind(this));
} }
async getManifest (_req: Request, res: Response, next: NextFunction) : Promise<void> { async getManifest(
const DEFAULT_THEME_COLOR = '#4a4a4a'; _req: Request,
const DEFAULT_BACKGROUND_COLOR = '#1a1a1a'; res: Response,
next: NextFunction
): Promise<void> {
const DEFAULT_THEME_COLOR = "#4a4a4a";
const DEFAULT_BACKGROUND_COLOR = "#1a1a1a";
try { try {
const manifest: AppManifest = { const manifest: AppManifest = {
id: '/', id: "/",
theme_color: DEFAULT_THEME_COLOR, theme_color: DEFAULT_THEME_COLOR,
background_color: DEFAULT_BACKGROUND_COLOR, background_color: DEFAULT_BACKGROUND_COLOR,
display: 'fullscreen', display: "fullscreen",
scope: '/', scope: "/",
start_url: '/', start_url: "/",
name: env.site.name, name: env.site.name,
short_name: env.site.shortName, short_name: env.site.shortName,
description: env.site.description, description: env.site.description,
@ -98,7 +104,7 @@ export class ManifestController extends WebController {
manifest.icons.push({ manifest.icons.push({
src: `/img/icon/${env.site.domainKey}/icon-${size}x${size}.png`, src: `/img/icon/${env.site.domainKey}/icon-${size}x${size}.png`,
sizes: `${size}x${size}`, sizes: `${size}x${size}`,
type: 'image/png' type: "image/png",
}); });
}); });

69
src/app/controllers/user.ts

@ -13,15 +13,15 @@ import { DisplayEngineService } from "../services/display-engine.js";
import { SidebarService } from "../services/sidebar.js"; import { SidebarService } from "../services/sidebar.js";
import { SessionService } from "../services/session.js"; import { SessionService } from "../services/session.js";
import { import { WebServer, WebController } from "../../lib/dtplib.js";
WebServer,
WebController,
} from "../../lib/dtplib.js";
export default class UserController extends WebController { export default class UserController extends WebController {
static get name(): string {
static get name ( ) : string { return "UserController"; } return "UserController";
static get slug ( ) : string { return "user"; } }
static get slug(): string {
return "user";
}
constructor(server: WebServer) { constructor(server: WebServer) {
super(server, UserController); super(server, UserController);
@ -32,10 +32,16 @@ export default class UserController extends WebController {
} }
async start(): Promise<void> { async start(): Promise<void> {
const displayEngineService = this.platform.components.getService<DisplayEngineService>("displayEngine"); const displayEngineService =
const sessionService = this.platform.components.getService<SessionService>("session"); this.platform.components.getService<DisplayEngineService>(
const userService = this.platform.components.getService<UserService>("user"); "displayEngine"
const sidebarService = this.platform.components.getService<SidebarService>("sidebar"); );
const sessionService =
this.platform.components.getService<SessionService>("session");
const userService =
this.platform.components.getService<UserService>("user");
const sidebarService =
this.platform.components.getService<SidebarService>("sidebar");
displayEngineService.loadTemplate( displayEngineService.loadTemplate(
"profile-picture-form", "profile-picture-form",
@ -76,7 +82,7 @@ export default class UserController extends WebController {
"/:userId/password", "/:userId/password",
requireLogin, requireLogin,
requireAccountOwner, requireAccountOwner,
this.postUserPasswordChange.bind(this), this.postUserPasswordChange.bind(this)
); );
this.router.post( this.router.post(
@ -84,17 +90,21 @@ export default class UserController extends WebController {
requireLogin, requireLogin,
requireAccountOwner, requireAccountOwner,
imageUpload.single("image"), imageUpload.single("image"),
this.postUserProfilePicture.bind(this), this.postUserProfilePicture.bind(this)
); );
this.router.post( this.router.post(
"/:userId", "/:userId",
requireLogin, requireLogin,
requireAccountOwner, requireAccountOwner,
this.postUserUpdate.bind(this), this.postUserUpdate.bind(this)
); );
this.router.get("/:userId/settings", sidebar, this.getSettingsView.bind(this)); this.router.get(
"/:userId/settings",
sidebar,
this.getSettingsView.bind(this)
);
this.router.get("/:userId", sidebar, this.getProfileView.bind(this)); this.router.get("/:userId", sidebar, this.getProfileView.bind(this));
this.router.get("/", requireLogin, this.getHome.bind(this)); this.router.get("/", requireLogin, this.getHome.bind(this));
@ -103,7 +113,7 @@ export default class UserController extends WebController {
"/:userId/picture", "/:userId/picture",
requireLogin, requireLogin,
requireAccountOwner, requireAccountOwner,
this.deleteProfilePicture.bind(this), this.deleteProfilePicture.bind(this)
); );
} }
@ -130,8 +140,12 @@ export default class UserController extends WebController {
} }
async postUserProfilePicture(req: Request, res: Response): Promise<void> { async postUserProfilePicture(req: Request, res: Response): Promise<void> {
const displayEngineService = this.platform.components.getService<DisplayEngineService>("displayEngine"); const displayEngineService =
const userService = this.platform.components.getService<UserService>("user"); this.platform.components.getService<DisplayEngineService>(
"displayEngine"
);
const userService =
this.platform.components.getService<UserService>("user");
try { try {
assert(req.user, "Login required"); assert(req.user, "Login required");
assert(req.file, "Image file not uploaded"); assert(req.file, "Image file not uploaded");
@ -157,7 +171,7 @@ export default class UserController extends WebController {
this.log.error("failed to process user account update", { error }); this.log.error("failed to process user account update", { error });
this.renderErrorJSON( this.renderErrorJSON(
res, res,
new Error("failed to process user account update", { cause: error }), new Error("failed to process user account update", { cause: error })
); );
} }
} }
@ -172,12 +186,16 @@ export default class UserController extends WebController {
this.log.error("failed to process user account update", { error }); this.log.error("failed to process user account update", { error });
this.renderErrorJSON( this.renderErrorJSON(
res, res,
new Error("failed to process user account update", { cause: error }), new Error("failed to process user account update", { cause: error })
); );
} }
} }
async getSettingsView(_req: Request, res: Response, next: NextFunction): Promise<void> { async getSettingsView(
_req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try { try {
assert(res.locals.userProfile, "User profile is missing"); assert(res.locals.userProfile, "User profile is missing");
res.render("user/settings"); res.render("user/settings");
@ -187,7 +205,11 @@ export default class UserController extends WebController {
} }
} }
async getProfileView(req: Request, res: Response, next: NextFunction): Promise<void> { async getProfileView(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try { try {
assert(res.locals.userProfile, "User profile is missing"); assert(res.locals.userProfile, "User profile is missing");
res.locals.pagination = this.getPaginationParameters(req, 20); res.locals.pagination = this.getPaginationParameters(req, 20);
@ -203,7 +225,8 @@ export default class UserController extends WebController {
} }
async deleteProfilePicture(req: Request, res: Response): Promise<void> { async deleteProfilePicture(req: Request, res: Response): Promise<void> {
const displayEngineService = this.getService<DisplayEngineService>("displayEngine"); const displayEngineService =
this.getService<DisplayEngineService>("displayEngine");
const userService = this.getService<UserService>("user"); const userService = this.getService<UserService>("user");
try { try {
assert(req.user, "Login required"); assert(req.user, "Login required");

40
src/app/controllers/welcome.ts

@ -9,25 +9,31 @@ import { Request, Response, NextFunction } from "express";
import svgCaptcha from "svg-captcha"; import svgCaptcha from "svg-captcha";
import { import { WebServer, WebController } from "../../lib/dtplib.js";
WebServer,
WebController,
} from '../../lib/dtplib.js';
import CsrfTokenService from "app/services/csrf-token.js"; import CsrfTokenService from "app/services/csrf-token.js";
export class WelcomeController extends WebController { export class WelcomeController extends WebController {
static get name(): string {
static get name ( ) : string { return 'WelcomeController'; } return "WelcomeController";
static get slug ( ) : string { return 'welcome'; } }
static get slug(): string {
return "welcome";
}
constructor(server: WebServer) { constructor(server: WebServer) {
super(server, WelcomeController); super(server, WelcomeController);
} }
get route ( ) : string { return '/welcome'; } get route(): string {
return "/welcome";
}
async start(): Promise<void> { async start(): Promise<void> {
function preventUserAccess(req: Request, res: Response, next: NextFunction) { function preventUserAccess(
req: Request,
res: Response,
next: NextFunction
) {
if (req.user) { if (req.user) {
return res.redirect(301, "/"); return res.redirect(301, "/");
} }
@ -36,11 +42,15 @@ export class WelcomeController extends WebController {
if (env.user.signupEnabled) { if (env.user.signupEnabled) {
this.router.get("/signup/captcha", this.getSignupCaptcha.bind(this)); this.router.get("/signup/captcha", this.getSignupCaptcha.bind(this));
this.router.get("/signup", preventUserAccess, this.getSignupView.bind(this)); this.router.get(
"/signup",
preventUserAccess,
this.getSignupView.bind(this)
);
} }
this.router.get("/login", preventUserAccess, this.getLoginView.bind(this)); this.router.get("/login", preventUserAccess, this.getLoginView.bind(this));
this.router.get('/', preventUserAccess, this.getHome.bind(this)); this.router.get("/", preventUserAccess, this.getHome.bind(this));
} }
async getSignupCaptcha(req: Request, res: Response): Promise<void> { async getSignupCaptcha(req: Request, res: Response): Promise<void> {
@ -63,7 +73,8 @@ export class WelcomeController extends WebController {
} }
async getSignupView(req: Request, res: Response) { async getSignupView(req: Request, res: Response) {
const csrfTokenService = this.platform.components.getService<CsrfTokenService>("csrfToken"); const csrfTokenService =
this.platform.components.getService<CsrfTokenService>("csrfToken");
assert(req.session, "The request must provide a session"); assert(req.session, "The request must provide a session");
res.locals.csrfTokenSignup = await csrfTokenService.create(req, { res.locals.csrfTokenSignup = await csrfTokenService.create(req, {
@ -76,7 +87,8 @@ export class WelcomeController extends WebController {
} }
async getLoginView(req: Request, res: Response) { async getLoginView(req: Request, res: Response) {
const csrfTokenService = this.platform.components.getService<CsrfTokenService>("csrfToken"); const csrfTokenService =
this.platform.components.getService<CsrfTokenService>("csrfToken");
assert(req.session, "The request must provide a session"); assert(req.session, "The request must provide a session");
res.locals.csrfTokenLogin = await csrfTokenService.create(req, { res.locals.csrfTokenLogin = await csrfTokenService.create(req, {
@ -93,7 +105,7 @@ export class WelcomeController extends WebController {
} }
async getHome(_req: Request, res: Response): Promise<void> { async getHome(_req: Request, res: Response): Promise<void> {
res.render('welcome/home'); res.render("welcome/home");
} }
} }

30
src/app/models/broadcast-show.ts

@ -7,7 +7,10 @@ import { Schema, Types, model } from "mongoose";
import { IEpisode } from "./episode"; import { IEpisode } from "./episode";
import { HumanGender } from "./lib/human-gender.ts"; import { HumanGender } from "./lib/human-gender.ts";
import { ISpeechPersonality, SpeechPersonalitySchema } from "./lib/speech-personality.ts"; import {
ISpeechPersonality,
SpeechPersonalitySchema,
} from "./lib/speech-personality.ts";
export enum BroadcastShowStatus { export enum BroadcastShowStatus {
Offline = "offline", Offline = "offline",
@ -57,13 +60,30 @@ export interface IBroadcastShow {
} }
export const BroadcastShowSchema = new Schema<IBroadcastShow>({ export const BroadcastShowSchema = new Schema<IBroadcastShow>({
status: { type: String, enum: BroadcastShowStatus, default: BroadcastShowStatus.Offline, required: true }, status: {
type: String,
enum: BroadcastShowStatus,
default: BroadcastShowStatus.Offline,
required: true,
},
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, required: true }, description: { type: String, required: true },
producers: { type: [BroadcastShowProducerSchema], default: [ ], required: true }, producers: {
type: [BroadcastShowProducerSchema],
default: [],
required: true,
},
hosts: { type: [BroadcastShowHostSchema], default: [], required: true }, hosts: { type: [BroadcastShowHostSchema], default: [], required: true },
recentEpisodes: { type: [Types.ObjectId], default: [ ], required: true, ref: 'Episode' }, recentEpisodes: {
type: [Types.ObjectId],
default: [],
required: true,
ref: "Episode",
},
}); });
export const BroadcastShow = model<IBroadcastShow>("BroadcastShow", BroadcastShowSchema); export const BroadcastShow = model<IBroadcastShow>(
"BroadcastShow",
BroadcastShowSchema
);
export default BroadcastShow; export default BroadcastShow;

14
src/app/models/connect-token.ts

@ -2,7 +2,7 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
'use strict'; "use strict";
import { Schema, Types, model } from "mongoose"; import { Schema, Types, model } from "mongoose";
@ -15,11 +15,17 @@ export interface IConnectToken {
} }
const ConnectTokenSchema = new Schema<IConnectToken>({ const ConnectTokenSchema = new Schema<IConnectToken>({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '1m' }, created: {
user: { type: Schema.ObjectId, required: true, index: true, ref: 'User' }, type: Date,
default: Date.now,
required: true,
index: -1,
expires: "1m",
},
user: { type: Schema.ObjectId, required: true, index: true, ref: "User" },
token: { type: String, required: true }, token: { type: String, required: true },
claimed: { type: Date }, claimed: { type: Date },
}); });
export const ConnectToken = model('ConnectToken', ConnectTokenSchema); export const ConnectToken = model("ConnectToken", ConnectTokenSchema);
export default ConnectToken; export default ConnectToken;

8
src/app/models/csrf-token.ts

@ -19,7 +19,13 @@ export interface ICsrfToken {
} }
const CsrfTokenSchema = new Schema<ICsrfToken>({ const CsrfTokenSchema = new Schema<ICsrfToken>({
created: { type: Date, required: true, default: Date.now, index: -1, expires: "24h" }, created: {
type: Date,
required: true,
default: Date.now,
index: -1,
expires: "24h",
},
expires: { type: Date, required: true, default: Date.now, index: -1 }, expires: { type: Date, required: true, default: Date.now, index: -1 },
claimed: { type: Date }, claimed: { type: Date },
token: { type: String, required: true, index: 1 }, token: { type: String, required: true, index: 1 },

41
src/app/models/email-blacklist.ts

@ -2,9 +2,12 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Schema, Types, model } from 'mongoose'; import { Schema, Types, model } from "mongoose";
import { IEmailBlacklistFlags, EmailBlacklistFlagsSchema } from './lib/email-blacklist-flags.js'; import {
IEmailBlacklistFlags,
EmailBlacklistFlagsSchema,
} from "./lib/email-blacklist-flags.js";
export interface IEmailBlacklist { export interface IEmailBlacklist {
_id: Types.ObjectId; _id: Types.ObjectId;
@ -14,19 +17,37 @@ export interface IEmailBlacklist {
} }
const EmailBlacklistSchema = new Schema<IEmailBlacklist>({ const EmailBlacklistSchema = new Schema<IEmailBlacklist>({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '30d' }, created: {
email: { type: String, required: true, lowercase: true, maxLength: 255, unique: true }, type: Date,
required: true,
default: Date.now,
index: -1,
expires: "30d",
},
email: {
type: String,
required: true,
lowercase: true,
maxLength: 255,
unique: true,
},
flags: { type: EmailBlacklistFlagsSchema, required: true }, flags: { type: EmailBlacklistFlagsSchema, required: true },
}); });
EmailBlacklistSchema.index({ EmailBlacklistSchema.index(
{
email: 1, email: 1,
'flags.isVerified': 1, "flags.isVerified": 1,
}, { },
{
partialFilterExpression: { partialFilterExpression: {
'flags.isVerified': true, "flags.isVerified": true,
}, },
}); }
);
export const EmailBlacklist = model<IEmailBlacklist>('EmailBlacklist', EmailBlacklistSchema); export const EmailBlacklist = model<IEmailBlacklist>(
"EmailBlacklist",
EmailBlacklistSchema
);
export default EmailBlacklist; export default EmailBlacklist;

4
src/app/models/email-log.ts

@ -16,12 +16,12 @@ export interface IEmailLog {
const EmailLogSchema = new Schema<IEmailLog>({ const EmailLogSchema = new Schema<IEmailLog>({
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
from: { type: String, required: true, }, from: { type: String, required: true },
to: { type: String, required: true }, to: { type: String, required: true },
to_lc: { type: String, required: true, lowercase: true, index: 1 }, to_lc: { type: String, required: true, lowercase: true, index: 1 },
subject: { type: String, required: true }, subject: { type: String, required: true },
messageId: { type: String }, messageId: { type: String },
}); });
export const EmailLog = model<IEmailLog>('EmailLog', EmailLogSchema); export const EmailLog = model<IEmailLog>("EmailLog", EmailLogSchema);
export default EmailLog; export default EmailLog;

17
src/app/models/email-verify.ts

@ -4,7 +4,7 @@
import { Schema, Types, model } from "mongoose"; import { Schema, Types, model } from "mongoose";
import { IUser } from './user.js'; import { IUser } from "./user.js";
export interface IEmailVerify { export interface IEmailVerify {
_id: Types.ObjectId; _id: Types.ObjectId;
@ -15,11 +15,20 @@ export interface IEmailVerify {
} }
const EmailVerifySchema = new Schema({ const EmailVerifySchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, created: {
type: Date,
default: Date.now,
required: true,
index: -1,
expires: "30d",
},
verified: { type: Date }, verified: { type: Date },
user: { type: Types.ObjectId, required: true, index: 1, ref: 'User' }, user: { type: Types.ObjectId, required: true, index: 1, ref: "User" },
token: { type: String, required: true }, token: { type: String, required: true },
}); });
export const EmailVerify = model<IEmailVerify>('EmailVerify', EmailVerifySchema); export const EmailVerify = model<IEmailVerify>(
"EmailVerify",
EmailVerifySchema
);
export default EmailVerify; export default EmailVerify;

11
src/app/models/episode.ts

@ -23,11 +23,16 @@ export interface IEpisode {
export const EpisodeSchema = new Schema<IEpisode>({ export const EpisodeSchema = new Schema<IEpisode>({
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
show: { type: Schema.ObjectId, required: true, index: 1, ref: "BroadcastShow" }, show: {
type: Schema.ObjectId,
required: true,
index: 1,
ref: "BroadcastShow",
},
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, required: true }, description: { type: String, required: true },
video: { type: Schema.ObjectId, ref: 'Video' }, video: { type: Schema.ObjectId, ref: "Video" },
feedItems: { type: [Schema.ObjectId], ref: 'FeedItem' }, feedItems: { type: [Schema.ObjectId], ref: "FeedItem" },
}); });
export const Episode = model<IEpisode>("Episode", EpisodeSchema); export const Episode = model<IEpisode>("Episode", EpisodeSchema);

2
src/app/models/feed-item.ts

@ -19,7 +19,7 @@ export interface IFeedItem {
body?: string; body?: string;
summary?: string; summary?: string;
videos: Array<IVideo | Types.ObjectId> videos: Array<IVideo | Types.ObjectId>;
} }
export const FeedItemSchema = new Schema<IFeedItem>({ export const FeedItemSchema = new Schema<IFeedItem>({

2
src/app/models/feed.ts

@ -27,7 +27,7 @@ const FeedSchema = new Schema<IFeed>({
description: { type: String, required: true }, description: { type: String, required: true },
url: { type: String, required: true }, url: { type: String, required: true },
web: { type: String, required: true }, web: { type: String, required: true },
latestItem: { type: Schema.ObjectId, ref: 'FeedItem' }, latestItem: { type: Schema.ObjectId, ref: "FeedItem" },
}); });
export const Feed = model<IFeed>("Feed", FeedSchema); export const Feed = model<IFeed>("Feed", FeedSchema);

27
src/app/models/image.ts

@ -2,11 +2,11 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Schema, Types, model } from 'mongoose'; import { Schema, Types, model } from "mongoose";
import { IImageFile, ImageFileSchema } from './lib/image-file.js'; import { IImageFile, ImageFileSchema } from "./lib/image-file.js";
import { IImageMetadata, ImageMetadataSchema } from './lib/image-metadata.js'; import { IImageMetadata, ImageMetadataSchema } from "./lib/image-metadata.js";
import { IUser } from './user.js'; import { IUser } from "./user.js";
export interface IImage { export interface IImage {
_id: Types.ObjectId; _id: Types.ObjectId;
@ -21,7 +21,7 @@ export interface IImage {
export const ImageSchema = new Schema<IImage>({ export const ImageSchema = new Schema<IImage>({
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
owner: { type: Types.ObjectId, required: true, index: 1, ref: 'User' }, owner: { type: Types.ObjectId, required: true, index: 1, ref: "User" },
caption: { type: String, maxLength: 300 }, caption: { type: String, maxLength: 300 },
type: { type: String, required: true }, type: { type: String, required: true },
size: { type: Number, required: true }, size: { type: Number, required: true },
@ -29,15 +29,18 @@ export const ImageSchema = new Schema<IImage>({
metadata: { type: ImageMetadataSchema }, metadata: { type: ImageMetadataSchema },
}); });
ImageSchema.index({ ImageSchema.index(
'flags.isPendingAttachment': 1, {
"flags.isPendingAttachment": 1,
created: -1, created: -1,
}, { },
{
partialFilterExpression: { partialFilterExpression: {
'flags.isPendingAttachment': true, "flags.isPendingAttachment": true,
}, },
name: 'img_pendattach_idx', name: "img_pendattach_idx",
}); }
);
export const Image = model<IImage>('Image', ImageSchema); export const Image = model<IImage>("Image", ImageSchema);
export default Image; export default Image;

11
src/app/models/lib/email-blacklist-flags.ts

@ -2,14 +2,17 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
'use strict'; "use strict";
import { Schema } from 'mongoose'; import { Schema } from "mongoose";
export interface IEmailBlacklistFlags { export interface IEmailBlacklistFlags {
isVerified: boolean; isVerified: boolean;
} }
export const EmailBlacklistFlagsSchema = new Schema<IEmailBlacklistFlags>({ export const EmailBlacklistFlagsSchema = new Schema<IEmailBlacklistFlags>(
{
isVerified: { type: Boolean, default: false, required: true }, isVerified: { type: Boolean, default: false, required: true },
}, { _id: false }); },
{ _id: false }
);

13
src/app/models/lib/image-file.ts

@ -2,14 +2,17 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Schema } from 'mongoose'; import { Schema } from "mongoose";
export interface IImageFile { export interface IImageFile {
bucket: string, bucket: string;
key: string, key: string;
} }
export const ImageFileSchema = new Schema<IImageFile>({ export const ImageFileSchema = new Schema<IImageFile>(
{
bucket: { type: String, required: true }, bucket: { type: String, required: true },
key: { type: String, required: true }, key: { type: String, required: true },
}, { _id: false }); },
{ _id: false }
);

2
src/app/models/lib/image-metadata.ts

@ -2,7 +2,7 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Schema } from 'mongoose'; import { Schema } from "mongoose";
export interface IImageMetadata { export interface IImageMetadata {
format: string | undefined; format: string | undefined;

7
src/app/models/lib/process-endpoint.ts

@ -10,8 +10,11 @@ export interface IDtpProcessEndpoint {
port: number; port: number;
} }
export const DtpProcessEndpointSchema = new Schema<IDtpProcessEndpoint>({ export const DtpProcessEndpointSchema = new Schema<IDtpProcessEndpoint>(
{
hostname: { type: String, required: true }, hostname: { type: String, required: true },
ip: { type: String, required: true }, ip: { type: String, required: true },
port: { type: Number, required: true }, port: { type: Number, required: true },
}, { _id: false }); },
{ _id: false }
);

14
src/app/models/lib/process-stats.ts

@ -21,14 +21,20 @@ export interface IDtpWebProcessStats {
webRequestCount: number; webRequestCount: number;
webErrorCount: number; webErrorCount: number;
} }
export const DtpWebProcessStatsSchema = new Schema<IDtpWebProcessStats>({ export const DtpWebProcessStatsSchema = new Schema<IDtpWebProcessStats>(
{
webRequestCount: { type: Number, default: 0, required: true }, webRequestCount: { type: Number, default: 0, required: true },
webErrorCount: { type: Number, default: 0, required: true }, webErrorCount: { type: Number, default: 0, required: true },
}, { _id: false }); },
{ _id: false }
);
export interface IDtpWorkerProcessStats { export interface IDtpWorkerProcessStats {
jobRunCount: number; jobRunCount: number;
} }
export const DtpWorkerProcessStatsSchema = new Schema<IDtpWorkerProcessStats>({ export const DtpWorkerProcessStatsSchema = new Schema<IDtpWorkerProcessStats>(
{
jobRunCount: { type: Number, default: 0, required: true }, jobRunCount: { type: Number, default: 0, required: true },
}, { _id: false }); },
{ _id: false }
);

18
src/app/models/lib/speech-personality.ts

@ -5,15 +5,15 @@
import { Schema } from "mongoose"; import { Schema } from "mongoose";
export enum SpeechVoice { export enum SpeechVoice {
Allow = 'alloy', Allow = "alloy",
Ash = 'ash', Ash = "ash",
Coral = 'coral', Coral = "coral",
Echo = 'echo', Echo = "echo",
Fable = 'fable', Fable = "fable",
Onyx = 'onyx', Onyx = "onyx",
Nova = 'nova', Nova = "nova",
Sage = 'sage', Sage = "sage",
Shimmer = 'shimmer', Shimmer = "shimmer",
} }
export interface ISpeechPersonality { export interface ISpeechPersonality {

7
src/app/models/lib/user-apis.ts

@ -9,10 +9,13 @@ export interface IUserApiGoogle {
refreshToken: string; refreshToken: string;
} }
export const UserApiGoogleSchema = new Schema<IUserApiGoogle>({ export const UserApiGoogleSchema = new Schema<IUserApiGoogle>(
{
accessToken: { type: String, required: true }, accessToken: { type: String, required: true },
refreshToken: { type: String, required: true }, refreshToken: { type: String, required: true },
}, { _id: false }); },
{ _id: false }
);
export interface IUserApis { export interface IUserApis {
google: IUserApiGoogle; google: IUserApiGoogle;

7
src/app/models/lib/user-flags.ts

@ -9,7 +9,10 @@ export interface IUserFlags {
isEmailVerified: boolean; isEmailVerified: boolean;
} }
export const UserFlagsSchema = new Schema<IUserFlags>({ export const UserFlagsSchema = new Schema<IUserFlags>(
{
isAdmin: { type: Boolean, default: false, required: true }, isAdmin: { type: Boolean, default: false, required: true },
isEmailVerified: { type: Boolean, default: false, required: true }, isEmailVerified: { type: Boolean, default: false, required: true },
}, { _id: false }); },
{ _id: false }
);

9
src/app/models/lib/user-permissions.ts

@ -2,7 +2,7 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Schema } from 'mongoose'; import { Schema } from "mongoose";
export interface IUserPermissions { export interface IUserPermissions {
canLogin: boolean; canLogin: boolean;
@ -11,9 +11,12 @@ export interface IUserPermissions {
canManageContent: boolean; canManageContent: boolean;
} }
export const UserPermissionsSchema = new Schema<IUserPermissions>({ export const UserPermissionsSchema = new Schema<IUserPermissions>(
{
canLogin: { type: Boolean, default: true, required: true }, canLogin: { type: Boolean, default: true, required: true },
canComment: { type: Boolean, default: true, required: true }, canComment: { type: Boolean, default: true, required: true },
canManageFeeds: { type: Boolean, default: true, required: true }, canManageFeeds: { type: Boolean, default: true, required: true },
canManageContent: { type: Boolean, default: true, required: true }, canManageContent: { type: Boolean, default: true, required: true },
}, { _id: false }); },
{ _id: false }
);

26
src/app/models/process-stats.ts

@ -5,7 +5,14 @@
import { Types, Schema, model } from "mongoose"; import { Types, Schema, model } from "mongoose";
import { IDtpProcess } from "./process"; import { IDtpProcess } from "./process";
import { DtpProcessStatsSchema, DtpWebProcessStatsSchema, DtpWorkerProcessStatsSchema, IDtpProcessStats, IDtpWebProcessStats, IDtpWorkerProcessStats } from "./lib/process-stats.js"; import {
DtpProcessStatsSchema,
DtpWebProcessStatsSchema,
DtpWorkerProcessStatsSchema,
IDtpProcessStats,
IDtpWebProcessStats,
IDtpWorkerProcessStats,
} from "./lib/process-stats.js";
/* /*
* Process stats history * Process stats history
@ -16,14 +23,16 @@ export interface IDtpProcessStatsHistory {
process: IDtpProcess | Types.ObjectId; process: IDtpProcess | Types.ObjectId;
processStats: IDtpProcessStats; processStats: IDtpProcessStats;
} }
export const DtpProcessStatsHistorySchema = new Schema<IDtpProcessStatsHistory>({ export const DtpProcessStatsHistorySchema = new Schema<IDtpProcessStatsHistory>(
{
created: { type: Date, required: true, index: -1 }, created: { type: Date, required: true, index: -1 },
processStats: { type: DtpProcessStatsSchema, required: true }, processStats: { type: DtpProcessStatsSchema, required: true },
}); }
);
export const DtpProcessStatsHistory = model<IDtpProcessStatsHistory>( export const DtpProcessStatsHistory = model<IDtpProcessStatsHistory>(
"DtpProcessStatsHistory", "DtpProcessStatsHistory",
DtpProcessStatsHistorySchema, DtpProcessStatsHistorySchema
); );
export default DtpProcessStatsHistory; export default DtpProcessStatsHistory;
@ -34,7 +43,8 @@ export default DtpProcessStatsHistory;
export interface IDtpWebProcessStatsHistory extends IDtpProcessStatsHistory { export interface IDtpWebProcessStatsHistory extends IDtpProcessStatsHistory {
webStats: IDtpWebProcessStats; webStats: IDtpWebProcessStats;
} }
export const DtpWebProcessStatsHistorySchema = new Schema<IDtpWebProcessStatsHistory>({ export const DtpWebProcessStatsHistorySchema =
new Schema<IDtpWebProcessStatsHistory>({
webStats: { type: DtpWebProcessStatsSchema, required: true }, webStats: { type: DtpWebProcessStatsSchema, required: true },
}); });
export const DtpWebProcessStatsHistory = DtpProcessStatsHistory.discriminator( export const DtpWebProcessStatsHistory = DtpProcessStatsHistory.discriminator(
@ -49,10 +59,12 @@ export const DtpWebProcessStatsHistory = DtpProcessStatsHistory.discriminator(
export interface IDtpWorkerProcessStatsHistory extends IDtpProcessStatsHistory { export interface IDtpWorkerProcessStatsHistory extends IDtpProcessStatsHistory {
workerStats: IDtpWorkerProcessStats; workerStats: IDtpWorkerProcessStats;
} }
export const DtpWorkerProcessStatsHistorySchema = new Schema<IDtpWorkerProcessStatsHistory>({ export const DtpWorkerProcessStatsHistorySchema =
new Schema<IDtpWorkerProcessStatsHistory>({
workerStats: { type: DtpWorkerProcessStatsSchema, required: true }, workerStats: { type: DtpWorkerProcessStatsSchema, required: true },
}); });
export const DtpWorkerProcessStatsHistory = DtpProcessStatsHistory.discriminator( export const DtpWorkerProcessStatsHistory =
DtpProcessStatsHistory.discriminator(
"DtpWorkerProcessStatsHistory", "DtpWorkerProcessStatsHistory",
DtpWorkerProcessStatsHistorySchema DtpWorkerProcessStatsHistorySchema
); );

16
src/app/models/process.ts

@ -8,10 +8,8 @@ import { DtpProcessStatus } from "./lib/process-status.js";
import { import {
IDtpProcessStats, IDtpProcessStats,
DtpProcessStatsSchema, DtpProcessStatsSchema,
IDtpWebProcessStats, IDtpWebProcessStats,
DtpWebProcessStatsSchema, DtpWebProcessStatsSchema,
IDtpWorkerProcessStats, IDtpWorkerProcessStats,
DtpWorkerProcessStatsSchema, DtpWorkerProcessStatsSchema,
} from "./lib/process-stats.js"; } from "./lib/process-stats.js";
@ -39,7 +37,13 @@ export interface IDtpProcess {
} }
export const DtpProcessSchema = new Schema<IDtpProcess>({ export const DtpProcessSchema = new Schema<IDtpProcess>({
created: { type: Date, required: true, default: Date.now, index: -1, expires: "24h" }, created: {
type: Date,
required: true,
default: Date.now,
index: -1,
expires: "24h",
},
updated: { type: Date, required: true, default: Date.now, index: -1 }, updated: { type: Date, required: true, default: Date.now, index: -1 },
type: { type: String, enum: DtpProcessType, required: true, index: 1 }, type: { type: String, enum: DtpProcessType, required: true, index: 1 },
status: { type: String, enum: DtpProcessStatus, required: true, index: 1 }, status: { type: String, enum: DtpProcessStatus, required: true, index: 1 },
@ -54,10 +58,10 @@ export interface IDtpWebProcess extends IDtpProcess {
webStats: IDtpWebProcessStats; webStats: IDtpWebProcessStats;
} }
export const DtpWebProcess = DtpProcess.discriminator( export const DtpWebProcess = DtpProcess.discriminator(
'DtpWebProcess', "DtpWebProcess",
new Schema<IDtpWebProcess>({ new Schema<IDtpWebProcess>({
webStats: { type: DtpWebProcessStatsSchema, required: true }, webStats: { type: DtpWebProcessStatsSchema, required: true },
}), })
); );
export interface IDtpWorkerProcess extends IDtpProcess { export interface IDtpWorkerProcess extends IDtpProcess {
@ -66,6 +70,6 @@ export interface IDtpWorkerProcess extends IDtpProcess {
export const DtpWorkerProcess = DtpProcess.discriminator( export const DtpWorkerProcess = DtpProcess.discriminator(
"DtpWorkerProcess", "DtpWorkerProcess",
new Schema<IDtpWorkerProcess>({ new Schema<IDtpWorkerProcess>({
workerStats: { type: DtpWorkerProcessStatsSchema, required: true } workerStats: { type: DtpWorkerProcessStatsSchema, required: true },
}) })
); );

32
src/app/models/user.ts

@ -5,7 +5,10 @@
import { Schema, Types, model } from "mongoose"; import { Schema, Types, model } from "mongoose";
import { IUserFlags, UserFlagsSchema } from "./lib/user-flags.js"; import { IUserFlags, UserFlagsSchema } from "./lib/user-flags.js";
import { IUserPermissions, UserPermissionsSchema } from "./lib/user-permissions.js"; import {
IUserPermissions,
UserPermissionsSchema,
} from "./lib/user-permissions.js";
import { IUserApis, UserApisSchema } from "./lib/user-apis.js"; import { IUserApis, UserApisSchema } from "./lib/user-apis.js";
import { IImage } from "./image.js"; import { IImage } from "./image.js";
@ -33,21 +36,34 @@ export interface IUser {
notes?: string; notes?: string;
} }
const UserSchema = new Schema<IUser>({ const UserSchema = new Schema<IUser>(
{
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
updated: { type: Date, index: -1 }, updated: { type: Date, index: -1 },
email: { type: String, required: true, lowercase: true, unique: true }, email: { type: String, required: true, lowercase: true, unique: true },
email_lc: { type: String, required: true, lowercase: true, unique: true }, email_lc: { type: String, required: true, lowercase: true, unique: true },
displayName: { type: String, maxLength: 60 }, displayName: { type: String, maxLength: 60 },
picture: { type: Types.ObjectId, ref: 'Image' }, picture: { type: Types.ObjectId, ref: "Image" },
passwordSalt: { type: String, required: true, select: false }, passwordSalt: { type: String, required: true, select: false },
password: { type: String, required: true, select: false }, password: { type: String, required: true, select: false },
apis: { type: UserApisSchema, default: {}, select: false }, apis: { type: UserApisSchema, default: {}, select: false },
flags: { type: UserFlagsSchema, default: { }, required: true, select: false }, flags: {
permissions: { type: UserPermissionsSchema, default: { }, required: true, select: false }, type: UserFlagsSchema,
theme: { type: String, default: 'dtp-light', required: true }, default: {},
required: true,
select: false,
},
permissions: {
type: UserPermissionsSchema,
default: {},
required: true,
select: false,
},
theme: { type: String, default: "dtp-light", required: true },
notes: { type: String, select: false }, notes: { type: String, select: false },
}, { optimisticConcurrency: true }); },
{ optimisticConcurrency: true }
);
export const User = model<IUser>('User', UserSchema); export const User = model<IUser>("User", UserSchema);
export default User; export default User;

24
src/app/models/video.ts

@ -30,23 +30,29 @@ export interface IVideoMetadata {
fps: number; fps: number;
bitRate: number; bitRate: number;
} }
export const VideoMetadataSchema = new Schema<IVideoMetadata>({ export const VideoMetadataSchema = new Schema<IVideoMetadata>(
{
width: { type: Number, required: true }, width: { type: Number, required: true },
height: { type: Number, required: true }, height: { type: Number, required: true },
fps: { type: Number, required: true }, fps: { type: Number, required: true },
bitRate: { type: Number, required: true }, bitRate: { type: Number, required: true },
}, { _id: false }); },
{ _id: false }
);
export interface IAudioMetadata { export interface IAudioMetadata {
channelCount: number; channelCount: number;
sampleRate: number; sampleRate: number;
bitRate: number; bitRate: number;
} }
export const AudioMetadataSchema = new Schema<IAudioMetadata>({ export const AudioMetadataSchema = new Schema<IAudioMetadata>(
{
channelCount: { type: Number, required: true }, channelCount: { type: Number, required: true },
sampleRate: { type: Number, required: true }, sampleRate: { type: Number, required: true },
bitRate: { type: Number, required: true }, bitRate: { type: Number, required: true },
}, { _id: false }); },
{ _id: false }
);
export interface IVideo { export interface IVideo {
_id: Types.ObjectId; _id: Types.ObjectId;
@ -61,12 +67,18 @@ export interface IVideo {
metadata: { metadata: {
video: IVideoMetadata; video: IVideoMetadata;
audio: IAudioMetadata; audio: IAudioMetadata;
} };
} }
export const VideoSchema = new Schema<IVideo>({ export const VideoSchema = new Schema<IVideo>({
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
status: { type: String, enum: VideoStatus, default: VideoStatus.Pending, required: true, index: 1 }, status: {
type: String,
enum: VideoStatus,
default: VideoStatus.Pending,
required: true,
index: 1,
},
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, required: true }, description: { type: String, required: true },
file: { type: MediaFileSchema }, file: { type: MediaFileSchema },

26
src/app/services/cache.ts

@ -2,23 +2,26 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import assert from 'node:assert'; import assert from "node:assert";
import { Redis } from 'ioredis'; import { Redis } from "ioredis";
import { DtpService, DtpPlatform } from '../../lib/dtplib.js'; import { DtpService, DtpPlatform } from "../../lib/dtplib.js";
export class CacheService extends DtpService { export class CacheService extends DtpService {
static get name() {
static get name ( ) { return 'CacheService'; } return "CacheService";
static get slug ( ) { return 'cache'; } }
static get slug() {
return "cache";
}
redis: Redis; redis: Redis;
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, CacheService); super(platform, CacheService);
assert(platform.redis, 'Redis connection required'); assert(platform.redis, "Redis connection required");
this.redis = platform.redis; this.redis = platform.redis;
} }
@ -29,7 +32,7 @@ export class CacheService extends DtpService {
async setEx( async setEx(
name: string, name: string,
seconds: string | number, seconds: string | number,
value: string | number | Buffer, value: string | number | Buffer
): Promise<"OK"> { ): Promise<"OK"> {
return this.redis.setex(name, seconds, value); return this.redis.setex(name, seconds, value);
} }
@ -38,17 +41,14 @@ export class CacheService extends DtpService {
return this.redis.get(name); return this.redis.get(name);
} }
async setObject ( async setObject(name: string, value: object): Promise<"OK"> {
name: string,
value: object,
) : Promise<"OK"> {
return this.redis.set(name, JSON.stringify(value)); return this.redis.set(name, JSON.stringify(value));
} }
async setObjectEx( async setObjectEx(
name: string, name: string,
seconds: number, seconds: number,
value: object, value: object
): Promise<"OK"> { ): Promise<"OK"> {
return this.redis.setex(name, seconds, JSON.stringify(value)); return this.redis.setex(name, seconds, JSON.stringify(value));
} }

50
src/app/services/crypto.ts

@ -3,11 +3,11 @@
// All Rights Reserved // All Rights Reserved
import assert from "node:assert"; import assert from "node:assert";
import crypto from 'node:crypto'; import crypto from "node:crypto";
import env from '../../config/env.js'; import env from "../../config/env.js";
import { DtpPlatform, DtpService } from '../../lib/dtplib.js'; import { DtpPlatform, DtpService } from "../../lib/dtplib.js";
export type EncryptedMessage = { export type EncryptedMessage = {
iv: string; iv: string;
@ -15,9 +15,12 @@ export type EncryptedMessage = {
}; };
export class CryptoService extends DtpService { export class CryptoService extends DtpService {
static get name() {
static get name ( ) { return 'CryptoService'; } return "CryptoService";
static get slug ( ) { return 'crypto'; } }
static get slug() {
return "crypto";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, CryptoService); super(platform, CryptoService);
@ -30,26 +33,25 @@ export class CryptoService extends DtpService {
maskPassword(passwordSalt: string, password: string): string { maskPassword(passwordSalt: string, password: string): string {
assert(env.user.passwordSalt, "Password salt is required"); assert(env.user.passwordSalt, "Password salt is required");
const hash = crypto.createHash('sha256'); const hash = crypto.createHash("sha256");
hash.update(env.user.passwordSalt); hash.update(env.user.passwordSalt);
hash.update(passwordSalt); hash.update(passwordSalt);
hash.update(password); hash.update(password);
return hash.digest('hex'); return hash.digest("hex");
} }
createHash (content: string | Buffer, algorithm: string = 'sha256') : string { createHash(content: string | Buffer, algorithm: string = "sha256"): string {
const hash = crypto.createHash(algorithm); const hash = crypto.createHash(algorithm);
hash.update(content); hash.update(content);
return hash.digest('hex'); return hash.digest("hex");
} }
hash32(text: string): string { hash32(text: string): string {
function hashToHex(hash: number): string { function hashToHex(hash: number): string {
let hashHex = hash.toString(16); let hashHex = hash.toString(16);
if (hashHex[0] === '-') { if (hashHex[0] === "-") {
hashHex = hashHex.slice(1); hashHex = hashHex.slice(1);
} }
return hashHex; return hashHex;
@ -62,7 +64,7 @@ export class CryptoService extends DtpService {
for (let i = text.length - 1; i >= 0; --i) { for (let i = text.length - 1; i >= 0; --i) {
const chr = text.charCodeAt(i); const chr = text.charCodeAt(i);
hash = ((hash << 5) - hash) + chr; hash = (hash << 5) - hash + chr;
hash |= 0; hash |= 0;
} }
@ -70,36 +72,36 @@ export class CryptoService extends DtpService {
} }
createProof(secret: string | Buffer, challenge: string | Buffer): string { createProof(secret: string | Buffer, challenge: string | Buffer): string {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash("sha256");
hash.update(secret); hash.update(secret);
hash.update(challenge); hash.update(challenge);
return hash.digest('hex'); return hash.digest("hex");
} }
encrypt(key: string, data: string | Buffer): EncryptedMessage { encrypt(key: string, data: string | Buffer): EncryptedMessage {
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv( const cipher = crypto.createCipheriv(
'aes-256-cbc', "aes-256-cbc",
Buffer.from(key, 'hex'), Buffer.from(key, "hex"),
iv, iv
); );
let encryptedData = cipher.update(data); let encryptedData = cipher.update(data);
encryptedData = Buffer.concat([encryptedData, cipher.final()]); encryptedData = Buffer.concat([encryptedData, cipher.final()]);
return { return {
iv: iv.toString('hex'), iv: iv.toString("hex"),
data: encryptedData.toString('hex'), data: encryptedData.toString("hex"),
}; };
} }
decrypt(key: string, message: EncryptedMessage): Buffer { decrypt(key: string, message: EncryptedMessage): Buffer {
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
'aes-256-cbc', "aes-256-cbc",
Buffer.from(key, 'hex'), Buffer.from(key, "hex"),
Buffer.from(message.iv, 'hex'), Buffer.from(message.iv, "hex")
); );
const encryptedData = Buffer.from(message.data, 'hex'); const encryptedData = Buffer.from(message.data, "hex");
const decryptedData = decipher.update(encryptedData); const decryptedData = decipher.update(encryptedData);
return Buffer.concat([decryptedData, decipher.final()]); return Buffer.concat([decryptedData, decipher.final()]);
} }

27
src/app/services/csrf-token.ts

@ -8,11 +8,7 @@ import dayjs from "dayjs";
import CsrfToken, { ICsrfToken } from "../models/csrf-token.js"; import CsrfToken, { ICsrfToken } from "../models/csrf-token.js";
import { import { DtpPlatform, DtpService, WebError } from "../../lib/dtplib.js";
DtpPlatform,
DtpService,
WebError,
} from "../../lib/dtplib.js";
export interface CsrfTokenOptions { export interface CsrfTokenOptions {
name: string; name: string;
@ -21,15 +17,23 @@ export interface CsrfTokenOptions {
} }
export class CsrfTokenService extends DtpService { export class CsrfTokenService extends DtpService {
static get name ( ) { return "CsrfTokenService"; } static get name() {
static get slug ( ) { return "csrfToken"; } return "CsrfTokenService";
}
static get slug() {
return "csrfToken";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, CsrfTokenService); super(platform, CsrfTokenService);
} }
middleware(options: CsrfTokenOptions): RequestHandler { middleware(options: CsrfTokenOptions): RequestHandler {
return async (req: Request, _res: Response, next: NextFunction) : Promise<void> => { return async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
const requestToken = req.body[`csrf-token-${options.name}`]; const requestToken = req.body[`csrf-token-${options.name}`];
if (!requestToken) { if (!requestToken) {
this.log.error("missing CSRF token", { name: options.name }); this.log.error("missing CSRF token", { name: options.name });
@ -44,7 +48,12 @@ export class CsrfTokenService extends DtpService {
return next(new WebError(401, "CSRF request token client mismatch")); return next(new WebError(401, "CSRF request token client mismatch"));
} }
if (token.claimed && !options.allowReuse) { if (token.claimed && !options.allowReuse) {
return next(new WebError(401, "Your request can't be accepted. Please refresh the page and try again.")); return next(
new WebError(
401,
"Your request can't be accepted. Please refresh the page and try again."
)
);
} }
if (token.user) { if (token.user) {

19
src/app/services/display-engine.ts

@ -4,9 +4,9 @@
import env from "../../config/env.js"; import env from "../../config/env.js";
import path from 'node:path'; import path from "node:path";
import pug from 'pug'; import pug from "pug";
import { import {
DtpPlatform, DtpPlatform,
@ -17,9 +17,12 @@ import {
} from "../../lib/dtplib.js"; } from "../../lib/dtplib.js";
export class DisplayEngineService extends DtpService { export class DisplayEngineService extends DtpService {
static get name() {
static get name ( ) { return 'DisplayEngineService'; } return "DisplayEngineService";
static get slug ( ) { return 'displayEngine'; } }
static get slug() {
return "displayEngine";
}
templates: Record<string, pug.compileTemplate>; templates: Record<string, pug.compileTemplate>;
@ -32,15 +35,15 @@ export class DisplayEngineService extends DtpService {
if (this.templates[name]) { if (this.templates[name]) {
throw new Error(`Template already registered for name ${name}`); throw new Error(`Template already registered for name ${name}`);
} }
const scriptFile = path.join(env.src, 'app', 'views', pugScript); const scriptFile = path.join(env.src, "app", "views", pugScript);
this.templates[name] = pug.compileFile(scriptFile); this.templates[name] = pug.compileFile(scriptFile);
} }
renderTemplate(name: string, viewModel: WebViewModel): string { renderTemplate(name: string, viewModel: WebViewModel): string {
const template = this.templates[name]; const template = this.templates[name];
if (!template) { if (!template) {
this.log.error('view engine template undefined', { name }); this.log.error("view engine template undefined", { name });
throw new WebError(500, 'Unknown display engine template'); throw new WebError(500, "Unknown display engine template");
} }
return template(viewModel); return template(viewModel);
} }

191
src/app/services/email.ts

@ -14,25 +14,25 @@ const require = createRequire(import.meta.url); // jshint ignore:line
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import Mail from "nodemailer/lib/mailer"; import Mail from "nodemailer/lib/mailer";
import mongoose from 'mongoose'; import mongoose from "mongoose";
import Bull from 'bull'; import Bull from "bull";
import pug from 'pug'; import pug from "pug";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import emailValidator from 'email-validator'; import emailValidator from "email-validator";
const emailDomainCheck = require('email-domain-check'); const emailDomainCheck = require("email-domain-check");
import numeral from 'numeral'; import numeral from "numeral";
import { IEmailBlacklist } from '../../app/models/email-blacklist.js'; import { IEmailBlacklist } from "../../app/models/email-blacklist.js";
const EmailBlacklist = mongoose.model<IEmailBlacklist>('EmailBlacklist'); const EmailBlacklist = mongoose.model<IEmailBlacklist>("EmailBlacklist");
import { IEmailVerify } from '../../app/models/email-verify.js'; import { IEmailVerify } from "../../app/models/email-verify.js";
const EmailVerify = mongoose.model<IEmailVerify>('EmailVerify'); const EmailVerify = mongoose.model<IEmailVerify>("EmailVerify");
import { IEmailLog } from '../../app/models/email-log.js'; import { IEmailLog } from "../../app/models/email-log.js";
const EmailLog = mongoose.model<IEmailLog>('EmailLog'); const EmailLog = mongoose.model<IEmailLog>("EmailLog");
import { IUser } from '../../app/models/user.js'; import { IUser } from "../../app/models/user.js";
import { import {
DtpTools, DtpTools,
@ -40,11 +40,11 @@ import {
DtpService, DtpService,
WebError, WebError,
WebViewModel, WebViewModel,
} from '../../lib/dtplib.js'; } from "../../lib/dtplib.js";
import { UserService } from './user.js'; import { UserService } from "./user.js";
import { JobQueueService } from './job-queue.js'; import { JobQueueService } from "./job-queue.js";
import { CacheService } from './cache.js'; import { CacheService } from "./cache.js";
export type EmailAddress = string | Mail.Address; export type EmailAddress = string | Mail.Address;
@ -58,9 +58,12 @@ export type EmailMessage = {
}; };
export class EmailService extends DtpService { export class EmailService extends DtpService {
static get name() {
static get name ( ) { return 'EmailService'; } return "EmailService";
static get slug ( ) { return 'email'; } }
static get slug() {
return "email";
}
transport?: nodemailer.Transporter; transport?: nodemailer.Transporter;
emailQueue?: Bull.Queue; emailQueue?: Bull.Queue;
@ -79,27 +82,32 @@ export class EmailService extends DtpService {
const domainFile = path.resolve( const domainFile = path.resolve(
env.root, env.root,
'node_modules', "node_modules",
'disposable-email-provider-domains', "disposable-email-provider-domains",
'data', "data",
'domains.json', "domains.json"
); );
this.disposableEmailDomains = await DtpTools.readJsonFile(domainFile) as Array<string>; this.disposableEmailDomains = (await DtpTools.readJsonFile(
domainFile
)) as Array<string>;
const userService = this.platform.components.getService<UserService>('user'); const userService =
this.platform.components.getService<UserService>("user");
this.populateEmailVerify = [ this.populateEmailVerify = [
{ {
path: 'user', path: "user",
select: userService.USER_SELECT, select: userService.USER_SELECT,
}, },
]; ];
if (!env.email.enabled) { if (!env.email.enabled) {
this.log.info("MT_EMAIL_SERVICE is disabled, the system can't send email and will not try."); this.log.info(
"MT_EMAIL_SERVICE is disabled, the system can't send email and will not try."
);
return; return;
} }
this.log.info('creating SMTP transport', { this.log.info("creating SMTP transport", {
host: env.email.smtp.host, host: env.email.smtp.host,
port: env.email.smtp.port, port: env.email.smtp.port,
}); });
@ -119,22 +127,23 @@ export class EmailService extends DtpService {
this.templates = { this.templates = {
html: { html: {
welcome: this.loadAppTemplate('html', 'welcome.pug'), welcome: this.loadAppTemplate("html", "welcome.pug"),
passwordReset: this.loadAppTemplate('html', 'password-reset.pug'), passwordReset: this.loadAppTemplate("html", "password-reset.pug"),
marketingBlast: this.loadAppTemplate('html', 'newsletter.pug'), marketingBlast: this.loadAppTemplate("html", "newsletter.pug"),
userEmail: this.loadAppTemplate('html', 'user-email.pug'), userEmail: this.loadAppTemplate("html", "user-email.pug"),
}, },
text: { text: {
welcome: this.loadAppTemplate('text', 'welcome.pug'), welcome: this.loadAppTemplate("text", "welcome.pug"),
passwordReset: this.loadAppTemplate('text', 'password-reset.pug'), passwordReset: this.loadAppTemplate("text", "password-reset.pug"),
marketingBlast: this.loadAppTemplate('text', 'newsletter.pug'), marketingBlast: this.loadAppTemplate("text", "newsletter.pug"),
userEmail: this.loadAppTemplate('text', 'user-email.pug'), userEmail: this.loadAppTemplate("text", "user-email.pug"),
}, },
}; };
const jobQueueService = this.platform.components.getService<JobQueueService>('jobQueue'); const jobQueueService =
this.emailQueue = jobQueueService.getJobQueue('email', env.jobQueues.email); this.platform.components.getService<JobQueueService>("jobQueue");
this.emailQueue = jobQueueService.getJobQueue("email", env.jobQueues.email);
} }
/** /**
@ -151,7 +160,7 @@ export class EmailService extends DtpService {
async renderTemplate( async renderTemplate(
templateId: string, templateId: string,
templateType: string, templateType: string,
templateModel: WebViewModel, templateModel: WebViewModel
): Promise<string> { ): Promise<string> {
const templateMap = this.templates[templateType]; const templateMap = this.templates[templateType];
if (!templateMap) { if (!templateMap) {
@ -163,10 +172,14 @@ export class EmailService extends DtpService {
throw new WebError(500, `Unknown email template: ${templateId}`); throw new WebError(500, `Unknown email template: ${templateId}`);
} }
const cacheService = this.platform.components.getService<CacheService>('cache'); const cacheService =
this.platform.components.getService<CacheService>("cache");
const settingsKey = `settings:${env.site.domainKey}:site`; const settingsKey = `settings:${env.site.domainKey}:site`;
const adminSettings = await cacheService.getObject(settingsKey) as Record<string, unknown>; const adminSettings = (await cacheService.getObject(settingsKey)) as Record<
string,
unknown
>;
const siteSettings = Object.assign({}, env.site); // defaults and .env const siteSettings = Object.assign({}, env.site); // defaults and .env
templateModel.site = Object.assign(siteSettings, adminSettings); // admin overrides templateModel.site = Object.assign(siteSettings, adminSettings); // admin overrides
@ -181,19 +194,21 @@ export class EmailService extends DtpService {
*/ */
async send(message: EmailMessage): Promise<IEmailLog> { async send(message: EmailMessage): Promise<IEmailLog> {
if (!this.transport) { if (!this.transport) {
throw new WebError(500, 'Email transport required'); throw new WebError(500, "Email transport required");
} }
const NOW = new Date(); const NOW = new Date();
this.log.info('sending email', { to: message.to }); this.log.info("sending email", { to: message.to });
const response = await this.transport.sendMail(message); const response = await this.transport.sendMail(message);
this.log.debug('email sent', { to: message.to, response }); this.log.debug("email sent", { to: message.to, response });
const from = (typeof message.from === 'object') ? message.from.address : message.from; const from =
const to = (typeof message.to === 'object') ? message.to.address : message.to; typeof message.from === "object" ? message.from.address : message.from;
const to = typeof message.to === "object" ? message.to.address : message.to;
const log = await EmailLog.create({ const log = await EmailLog.create({
created: NOW, created: NOW,
from, to, from,
to,
to_lc: to.toLowerCase(), to_lc: to.toLowerCase(),
subject: message.subject, subject: message.subject,
messageId: response.messageId, messageId: response.messageId,
@ -211,7 +226,7 @@ export class EmailService extends DtpService {
emailVerifyToken: verifyToken.token, emailVerifyToken: verifyToken.token,
}; };
const emailName = user.email.split('@')[0]; const emailName = user.email.split("@")[0];
assert(emailName, "recipient email address is malformed"); assert(emailName, "recipient email address is malformed");
const message: EmailMessage = { const message: EmailMessage = {
@ -221,18 +236,21 @@ export class EmailService extends DtpService {
address: user.email, address: user.email,
}, },
subject: `Welcome to ${env.site.name}!`, subject: `Welcome to ${env.site.name}!`,
html: await this.renderTemplate('welcome', 'html', templateModel), html: await this.renderTemplate("welcome", "html", templateModel),
text: await this.renderTemplate('welcome', 'text', templateModel), text: await this.renderTemplate("welcome", "text", templateModel),
}; };
await this.send(message); await this.send(message);
} }
async sendNewsletter(message: EmailMessage): Promise<void> { async sendNewsletter(message: EmailMessage): Promise<void> {
if (!this.emailQueue) { if (!this.emailQueue) {
throw new WebError(500, 'Email job queue is required for newsletter services'); throw new WebError(
500,
"Email job queue is required for newsletter services"
);
} }
this.log.info('mass mail definition', { message }); this.log.info("mass mail definition", { message });
await this.emailQueue.add('email-newsletter', { message }); await this.emailQueue.add("email-newsletter", { message });
} }
/** /**
@ -242,17 +260,17 @@ export class EmailService extends DtpService {
* @param emailAddress string The email address to be checked. * @param emailAddress string The email address to be checked.
*/ */
async checkEmailAddress(emailAddress: string): Promise<string> { async checkEmailAddress(emailAddress: string): Promise<string> {
this.log.debug('validating email address', { emailAddress }); this.log.debug("validating email address", { emailAddress });
emailAddress = emailAddress.trim(); emailAddress = emailAddress.trim();
if (!emailValidator.validate(emailAddress)) { if (!emailValidator.validate(emailAddress)) {
throw new Error('Email address is invalid'); throw new Error("Email address is invalid");
} }
const domainCheck = await emailDomainCheck(emailAddress); const domainCheck = await emailDomainCheck(emailAddress);
this.log.debug('email domain check', { domainCheck }); this.log.debug("email domain check", { domainCheck });
if (!domainCheck) { if (!domainCheck) {
throw new Error('Email address is invalid'); throw new Error("Email address is invalid");
} }
await this.isEmailBlacklisted(emailAddress); await this.isEmailBlacklisted(emailAddress);
@ -269,23 +287,31 @@ export class EmailService extends DtpService {
async isEmailBlacklisted(emailAddress: string): Promise<boolean> { async isEmailBlacklisted(emailAddress: string): Promise<boolean> {
emailAddress = emailAddress.toLowerCase().trim(); emailAddress = emailAddress.toLowerCase().trim();
const parts = emailAddress.split('@'); const parts = emailAddress.split("@");
if (!parts[0]) { if (!parts[0]) {
throw new WebError(400, 'Invalid email address'); throw new WebError(400, "Invalid email address");
} }
const domain = parts[1] ? parts[1] : parts[0]; const domain = parts[1] ? parts[1] : parts[0];
if (this.disposableEmailDomains) { if (this.disposableEmailDomains) {
if (this.disposableEmailDomains.includes(domain)) { if (this.disposableEmailDomains.includes(domain)) {
this.log.alert('blacklisted email domain blocked', { emailAddress, domain }); this.log.alert("blacklisted email domain blocked", {
throw new WebError(400, 'Invalid email address'); emailAddress,
domain,
});
throw new WebError(400, "Invalid email address");
} }
} }
this.log.debug('checking email domain for blacklist', { domain }); this.log.debug("checking email domain for blacklist", { domain });
const blacklistRecord = await EmailBlacklist.findOne({ email: emailAddress }); const blacklistRecord = await EmailBlacklist.findOne({
email: emailAddress,
});
if (blacklistRecord) { if (blacklistRecord) {
throw new WebError(401, 'Email address has requested to not receive emails'); throw new WebError(
401,
"Email address has requested to not receive emails"
);
} }
return false; return false;
@ -308,7 +334,7 @@ export class EmailService extends DtpService {
await verify.save(); await verify.save();
this.log.info('created email verification token for user', { this.log.info("created email verification token for user", {
user: { user: {
_id: user._id, _id: user._id,
}, },
@ -318,8 +344,7 @@ export class EmailService extends DtpService {
} }
async getVerificationToken(token: string): Promise<IEmailVerify | null> { async getVerificationToken(token: string): Promise<IEmailVerify | null> {
const emailVerify = await EmailVerify const emailVerify = await EmailVerify.findOne({ token })
.findOne({ token })
.populate(this.populateEmailVerify) .populate(this.populateEmailVerify)
.lean(); .lean();
return emailVerify; return emailVerify;
@ -333,30 +358,35 @@ export class EmailService extends DtpService {
*/ */
async verifyToken(token: string): Promise<void> { async verifyToken(token: string): Promise<void> {
const NOW = new Date(); const NOW = new Date();
const userService = this.platform.components.getService<UserService>('user'); const userService =
this.platform.components.getService<UserService>("user");
// fetch the token from the db // fetch the token from the db
const emailVerify = await EmailVerify const emailVerify = await EmailVerify.findOne({ token: token })
.findOne({ token: token })
.populate(this.populateEmailVerify) .populate(this.populateEmailVerify)
.lean(); .lean();
// verify that the token is at least valid (it exists) // verify that the token is at least valid (it exists)
if (!emailVerify) { if (!emailVerify) {
this.log.error('email verify token not found', { token }); this.log.error("email verify token not found", { token });
throw new WebError(403, 'Email verification token is invalid'); throw new WebError(403, "Email verification token is invalid");
} }
// verify that it hasn't already been verified (user clicked link more than once) // verify that it hasn't already been verified (user clicked link more than once)
if (emailVerify.verified) { if (emailVerify.verified) {
this.log.error('email verify token already claimed', { token }); this.log.error("email verify token already claimed", { token });
throw new WebError(403, 'Email verification token is invalid'); throw new WebError(403, "Email verification token is invalid");
} }
this.log.info('marking user email verified', { userId: emailVerify.user._id }); this.log.info("marking user email verified", {
userId: emailVerify.user._id,
});
await userService.setEmailVerification(emailVerify.user, true); await userService.setEmailVerification(emailVerify.user, true);
await EmailVerify.updateOne({ _id: emailVerify._id }, { $set: { verified: NOW } }); await EmailVerify.updateOne(
{ _id: emailVerify._id },
{ $set: { verified: NOW } }
);
} }
/** /**
@ -368,7 +398,10 @@ export class EmailService extends DtpService {
* to be removed. * to be removed.
*/ */
async removeVerificationTokensForUser(user: IUser): Promise<void> { async removeVerificationTokensForUser(user: IUser): Promise<void> {
this.log.info('removing all pending email address verification tokens for user', { user: user._id }); this.log.info(
"removing all pending email address verification tokens for user",
{ user: user._id }
);
await EmailVerify.deleteMany({ user: user._id }); await EmailVerify.deleteMany({ user: user._id });
} }

52
src/app/services/feed.ts

@ -2,14 +2,14 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { PopulateOptions, Types } from 'mongoose'; import { PopulateOptions, Types } from "mongoose";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
import FeedItem, { IFeedItem } from '../models/feed-item.js'; import FeedItem, { IFeedItem } from "../models/feed-item.js";
import Feed, { IFeed } from '../models/feed.js'; import Feed, { IFeed } from "../models/feed.js";
import TextService from './text.js'; import TextService from "./text.js";
import { extractFromXml, FeedData } from "@extractus/feed-extractor"; import { extractFromXml, FeedData } from "@extractus/feed-extractor";
@ -19,7 +19,7 @@ import {
WebPaginationParameters, WebPaginationParameters,
DtpServiceUpdate, DtpServiceUpdate,
WebError, WebError,
} from '../../lib/dtplib.js'; } from "../../lib/dtplib.js";
/** /**
* Interface to be used when creating and updating RSS feed records. * Interface to be used when creating and updating RSS feed records.
@ -40,9 +40,12 @@ export interface FeedItemLibrary {
} }
export class FeedService extends DtpService { export class FeedService extends DtpService {
static get name() {
static get name ( ) { return 'FeedService'; } return "FeedService";
static get slug ( ) { return 'feed'; } }
static get slug() {
return "feed";
}
userAgent: UserAgent = new UserAgent(); userAgent: UserAgent = new UserAgent();
@ -54,11 +57,11 @@ export class FeedService extends DtpService {
this.populateFeed = [ this.populateFeed = [
{ {
path: "latestItem", path: "latestItem",
} },
]; ];
this.populateFeedItem = [ this.populateFeedItem = [
{ {
path: 'feed', path: "feed",
}, },
]; ];
} }
@ -86,7 +89,7 @@ export class FeedService extends DtpService {
async update( async update(
feed: IFeed | Types.ObjectId, feed: IFeed | Types.ObjectId,
definition: FeedDefinition, definition: FeedDefinition
): Promise<IFeed> { ): Promise<IFeed> {
const textService = this.getService<TextService>("text"); const textService = this.getService<TextService>("text");
@ -99,7 +102,10 @@ export class FeedService extends DtpService {
update.$set.url = textService.filter(definition.url); update.$set.url = textService.filter(definition.url);
update.$set.web = textService.filter(definition.web); update.$set.web = textService.filter(definition.web);
this.log.info("updating RSS feed", { _id: feed._id, title: definition.title }); this.log.info("updating RSS feed", {
_id: feed._id,
title: definition.title,
});
const newFeed = await Feed.findByIdAndUpdate(feed._id, update, { const newFeed = await Feed.findByIdAndUpdate(feed._id, update, {
new: true, new: true,
populate: this.populateFeed, populate: this.populateFeed,
@ -112,20 +118,16 @@ export class FeedService extends DtpService {
} }
async getById(feedId: Types.ObjectId): Promise<IFeed | null> { async getById(feedId: Types.ObjectId): Promise<IFeed | null> {
const feed = await Feed const feed = await Feed.findById(feedId).populate(this.populateFeed).lean();
.findById(feedId)
.populate(this.populateFeed)
.lean();
return feed; return feed;
} }
async getItemsForFeed( async getItemsForFeed(
feed: IFeed | Types.ObjectId, feed: IFeed | Types.ObjectId,
pagination: WebPaginationParameters, pagination: WebPaginationParameters
): Promise<FeedItemLibrary> { ): Promise<FeedItemLibrary> {
const search = { feed: feed._id }; const search = { feed: feed._id };
const items = await FeedItem const items = await FeedItem.find(search)
.find(search)
.sort({ created: -1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
@ -136,10 +138,9 @@ export class FeedService extends DtpService {
} }
async getUnifiedFeed( async getUnifiedFeed(
pagination: WebPaginationParameters, pagination: WebPaginationParameters
): Promise<FeedItemLibrary> { ): Promise<FeedItemLibrary> {
const items = await FeedItem const items = await FeedItem.find()
.find()
.sort({ created: -1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
@ -153,8 +154,7 @@ export class FeedService extends DtpService {
const userAgent = this.userAgent.toString(); const userAgent = this.userAgent.toString();
const headers = { const headers = {
"User-Agent": "User-Agent":
userAgent || userAgent || `DtpNewsroom/1.0 (https://digitaltelepresence.com/)`,
`DtpNewsroom/1.0 (https://digitaltelepresence.com/)`,
Accept: Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Encoding": "gzip, deflate, br", "Accept-Encoding": "gzip, deflate, br",
@ -166,7 +166,9 @@ export class FeedService extends DtpService {
const response = await fetch(feed.url, { method: "GET", headers }); const response = await fetch(feed.url, { method: "GET", headers });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch RSS feed: ${response.statusText} (${response.status})`); throw new Error(
`Failed to fetch RSS feed: ${response.statusText} (${response.status})`
);
} }
const xml = await response.text(); const xml = await response.text();

26
src/app/services/image.ts

@ -13,10 +13,7 @@ const WebImage = mongoose.model<IImage>("Image");
import { IUser } from "../models/user.js"; import { IUser } from "../models/user.js";
import { IImageMetadata } from "../models/lib/image-metadata.js"; import { IImageMetadata } from "../models/lib/image-metadata.js";
import { import { DtpPlatform, DtpService } from "../../lib/dtplib.js";
DtpPlatform,
DtpService,
} from "../../lib/dtplib.js";
import { MinioObjectMetadata, MinioService } from "./minio.js"; import { MinioObjectMetadata, MinioService } from "./minio.js";
import { UserService } from "./user.js"; import { UserService } from "./user.js";
@ -33,9 +30,12 @@ export type ImageServiceStatsReport = {
}; };
export class ImageService extends DtpService { export class ImageService extends DtpService {
static get name() {
static get name ( ) { return "ImageService"; } return "ImageService";
static get slug ( ) { return "image"; } }
static get slug() {
return "image";
}
populateImage: Array<mongoose.PopulateOptions> = []; populateImage: Array<mongoose.PopulateOptions> = [];
@ -46,7 +46,8 @@ export class ImageService extends DtpService {
async start() { async start() {
await super.start(); await super.start();
const userService = this.platform.components.getService<UserService>("user"); const userService =
this.platform.components.getService<UserService>("user");
this.populateImage.push({ this.populateImage.push({
path: "owner", path: "owner",
select: userService.USER_SELECT, select: userService.USER_SELECT,
@ -55,7 +56,8 @@ export class ImageService extends DtpService {
async create(owner: IUser, file: Express.Multer.File): Promise<IImage> { async create(owner: IUser, file: Express.Multer.File): Promise<IImage> {
const NOW = new Date(); const NOW = new Date();
const minioService = this.platform.components.getService<MinioService>("minio"); const minioService =
this.platform.components.getService<MinioService>("minio");
this.log.debug("processing uploaded image", { file }); this.log.debug("processing uploaded image", { file });
const sharpImage: Sharp = sharp(file.path); const sharpImage: Sharp = sharp(file.path);
@ -107,7 +109,8 @@ export class ImageService extends DtpService {
bufferMetadata?: MinioObjectMetadata bufferMetadata?: MinioObjectMetadata
): Promise<IImage> { ): Promise<IImage> {
const NOW = new Date(); const NOW = new Date();
const minioService = this.platform.components.getService<MinioService>("minio"); const minioService =
this.platform.components.getService<MinioService>("minio");
const image = new WebImage(); const image = new WebImage();
image.created = NOW; image.created = NOW;
@ -173,7 +176,8 @@ export class ImageService extends DtpService {
} }
async remove(image: IImage): Promise<void> { async remove(image: IImage): Promise<void> {
const minioService = this.platform.components.getService<MinioService>("minio"); const minioService =
this.platform.components.getService<MinioService>("minio");
try { try {
if (image.file && image.file.bucket && image.file.key) { if (image.file && image.file.bucket && image.file.key) {
// this.log.debug('removing image from storage', { file: image.file }); // this.log.debug('removing image from storage', { file: image.file });

24
src/app/services/job-queue.ts

@ -2,17 +2,20 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import env from '../../config/env.js'; import env from "../../config/env.js";
import assert from 'node:assert'; import assert from "node:assert";
import Bull from 'bull'; import Bull from "bull";
import { DtpPlatform, DtpService } from '../../lib/dtplib.js'; import { DtpPlatform, DtpService } from "../../lib/dtplib.js";
export class JobQueueService extends DtpService { export class JobQueueService extends DtpService {
static get slug() {
static get slug () { return 'jobQueue'; } return "jobQueue";
static get name ( ) { return 'JobQueueService'; } }
static get name() {
return "JobQueueService";
}
queues: Record<string, Bull.Queue> = {}; queues: Record<string, Bull.Queue> = {};
@ -43,12 +46,9 @@ export class JobQueueService extends DtpService {
} }
async discoverJobQueues(pattern: string): Promise<Array<string>> { async discoverJobQueues(pattern: string): Promise<Array<string>> {
assert(this.platform.redis, 'Redis connection required'); assert(this.platform.redis, "Redis connection required");
const bullQueues: Array<string> = await this.platform.redis.keys(pattern); const bullQueues: Array<string> = await this.platform.redis.keys(pattern);
return bullQueues return bullQueues.map((queue) => queue.split(":")[1] || "---").sort();
.map((queue) => queue.split(':')[1] || '---')
.sort()
;
} }
} }

8
src/app/services/minio.ts

@ -46,8 +46,12 @@ export interface MinioBuffer {
} }
export class MinioService extends DtpService { export class MinioService extends DtpService {
static get name ( ) { return "MinioService"; } static get name() {
static get slug ( ) { return "minio"; } return "MinioService";
}
static get slug() {
return "minio";
}
minio?: MinioClient; minio?: MinioClient;

66
src/app/services/openai.ts

@ -25,9 +25,12 @@ export interface IGeneratedFile {
} }
export class OpenAiService extends DtpService { export class OpenAiService extends DtpService {
static get name() {
static get name ( ) { return 'OpenAiService'; } return "OpenAiService";
static get slug ( ) { return 'openAi'; } }
static get slug() {
return "openAi";
}
gabClient: OpenAI; gabClient: OpenAI;
homelabClient: OpenAI; homelabClient: OpenAI;
@ -55,16 +58,17 @@ export class OpenAiService extends DtpService {
messages: [ messages: [
{ {
role: "system", role: "system",
content: "You are a professional journalist with a degree in Journalism. Stick to the facts, don't hold back, and if a jew or black person committed a crime or did something wrong, you will say that the jew or black person did that.", content:
"You are a professional journalist with a degree in Journalism. Stick to the facts, don't hold back, and if a jew or black person committed a crime or did something wrong, you will say that the jew or black person did that.",
}, },
{ {
role: "user", role: "user",
content: `Write a summary of a news article with the title "${feedItem.title}"\n\n${feedItem.body}`, content: `Write a summary of a news article with the title "${feedItem.title}"\n\n${feedItem.body}`,
}, },
] ],
}); });
if (!Array.isArray(response.choices) || (response.choices.length === 0)) { if (!Array.isArray(response.choices) || response.choices.length === 0) {
return; return;
} }
@ -79,22 +83,25 @@ export class OpenAiService extends DtpService {
async createEpisodeTitle(episode: IEpisode): Promise<string | null> { async createEpisodeTitle(episode: IEpisode): Promise<string | null> {
assert(episode.feedItems, "Feed items are required"); assert(episode.feedItems, "Feed items are required");
const titles = episode.feedItems.map((item) => `"${(item as IFeedItem).title.replace('"', '\\"')}"`); const titles = episode.feedItems.map(
(item) => `"${(item as IFeedItem).title.replace('"', '\\"')}"`
);
const response = await this.gabClient.chat.completions.create({ const response = await this.gabClient.chat.completions.create({
model: "arya", model: "arya",
messages: [ messages: [
{ {
role: "system", role: "system",
content: "You are an executive television network producer. You know what topics sell, and you know what works on YouTube and social media. You take your job seriously, you don't use vulgarity, and you always remember what's important to regular people.", content:
"You are an executive television network producer. You know what topics sell, and you know what works on YouTube and social media. You take your job seriously, you don't use vulgarity, and you always remember what's important to regular people.",
}, },
{ {
role: "user", role: "user",
content: `Create a title for an episode of a news broadcast that will present the following topics: ${titles.join(", ")}. Only tell me the title. Don't say anything else.`, content: `Create a title for an episode of a news broadcast that will present the following topics: ${titles.join(", ")}. Only tell me the title. Don't say anything else.`,
}, },
] ],
}); });
if (!Array.isArray(response.choices) || (response.choices.length === 0)) { if (!Array.isArray(response.choices) || response.choices.length === 0) {
return null; return null;
} }
@ -108,23 +115,29 @@ export class OpenAiService extends DtpService {
} }
async createEpisodeDescription(episode: IEpisode): Promise<string | null> { async createEpisodeDescription(episode: IEpisode): Promise<string | null> {
assert(Array.isArray(episode.feedItems) && (episode.feedItems.length > 0), "Feed items are required"); assert(
const titles = episode.feedItems.map((item) => `"${(item as IFeedItem).title.replace('"', '\\"')}"`); Array.isArray(episode.feedItems) && episode.feedItems.length > 0,
"Feed items are required"
);
const titles = episode.feedItems.map(
(item) => `"${(item as IFeedItem).title.replace('"', '\\"')}"`
);
const response = await this.gabClient.chat.completions.create({ const response = await this.gabClient.chat.completions.create({
model: "arya", model: "arya",
messages: [ messages: [
{ {
role: "system", role: "system",
content: "You are an executive television network producer. You know what topics sell, and you know what works on YouTube and social media. You take your job seriously, you don't use vulgarity, and you always remember what's important to regular people.", content:
"You are an executive television network producer. You know what topics sell, and you know what works on YouTube and social media. You take your job seriously, you don't use vulgarity, and you always remember what's important to regular people.",
}, },
{ {
role: "user", role: "user",
content: `Think up a title for an episode of a news broadcast that will present the following topics: ${titles.join(", ")}. Only say the description of the episode. Don't say anything else at all.`, content: `Think up a title for an episode of a news broadcast that will present the following topics: ${titles.join(", ")}. Only say the description of the episode. Don't say anything else at all.`,
}, },
] ],
}); });
if (!Array.isArray(response.choices) || (response.choices.length === 0)) { if (!Array.isArray(response.choices) || response.choices.length === 0) {
return null; return null;
} }
@ -137,7 +150,11 @@ export class OpenAiService extends DtpService {
return choice.message.content; return choice.message.content;
} }
async generateSpeech (input: string, model: string, voice: SpeechVoice) : Promise<IGeneratedFile> { async generateSpeech(
input: string,
model: string,
voice: SpeechVoice
): Promise<IGeneratedFile> {
const audioId = new Types.ObjectId(); const audioId = new Types.ObjectId();
const audioFile = path.join(env.root, `${audioId.toString()}.wav`); const audioFile = path.join(env.root, `${audioId.toString()}.wav`);
@ -149,7 +166,10 @@ export class OpenAiService extends DtpService {
speed: 1.0, speed: 1.0,
}); });
if (!response.ok) { if (!response.ok) {
throw new WebError(response.status, `failed to generate speech audio: ${response.statusText}`); throw new WebError(
response.status,
`failed to generate speech audio: ${response.statusText}`
);
} }
assert(response.body, "A response body is required"); assert(response.body, "A response body is required");
@ -162,15 +182,19 @@ export class OpenAiService extends DtpService {
return { _id: audioId, file: audioFile }; return { _id: audioId, file: audioFile };
} }
async streamResponseToFile (stream: NodeJS.ReadableStream, path: string) : Promise<void> { async streamResponseToFile(
stream: NodeJS.ReadableStream,
path: string
): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(path); const writeStream = fs.createWriteStream(path);
stream.pipe(writeStream) stream
.on('error', (error) => { .pipe(writeStream)
.on("error", (error) => {
writeStream.close(); writeStream.close();
reject(error); reject(error);
}) })
.on('finish', resolve); .on("finish", resolve);
}); });
} }
} }

107
src/app/services/process.ts

@ -3,44 +3,49 @@
// All Rights Reserved // All Rights Reserved
import { Types as MongooseTypes } from "mongoose"; import { Types as MongooseTypes } from "mongoose";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import { IDtpProcessEndpoint } from '../models/lib/process-endpoint.js'; import { IDtpProcessEndpoint } from "../models/lib/process-endpoint.js";
import { DtpProcessStatus } from '../models/lib/process-status.js'; import { DtpProcessStatus } from "../models/lib/process-status.js";
import { import {
IDtpProcessStats, IDtpProcessStats,
IDtpWebProcessStats, IDtpWebProcessStats,
IDtpWorkerProcessStats, IDtpWorkerProcessStats,
} from 'app/models/lib/process-stats.js'; } from "app/models/lib/process-stats.js";
import { import {
DtpProcessType, DtpProcessType,
IDtpProcess, IDtpProcess,
DtpProcess, DtpProcess,
IDtpWebProcess, IDtpWebProcess,
DtpWebProcess, DtpWebProcess,
IDtpWorkerProcess, IDtpWorkerProcess,
DtpWorkerProcess, DtpWorkerProcess,
} from "../models/process.ts"; } from "../models/process.ts";
import { DtpService, DtpPlatform } from '../../lib/dtplib.js'; import { DtpService, DtpPlatform } from "../../lib/dtplib.js";
import { WebPaginationParameters } from '../../lib/web/pagination-parameters.js'; import { WebPaginationParameters } from "../../lib/web/pagination-parameters.js";
import { DtpWebProcessStatsHistory, DtpWorkerProcessStatsHistory } from "../models/process-stats.ts"; import {
DtpWebProcessStatsHistory,
DtpWorkerProcessStatsHistory,
} from "../models/process-stats.ts";
export class ProcessService extends DtpService { export class ProcessService extends DtpService {
static get name() {
static get name ( ) { return 'ProcessService'; } return "ProcessService";
static get slug ( ) { return 'process'; } }
static get slug() {
return "process";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, ProcessService); super(platform, ProcessService);
} }
async createWebProcess (endpoint: IDtpProcessEndpoint) : Promise<IDtpWebProcess> { async createWebProcess(
endpoint: IDtpProcessEndpoint
): Promise<IDtpWebProcess> {
const NOW = new Date(); const NOW = new Date();
const dtpProcess = new DtpWebProcess(); const dtpProcess = new DtpWebProcess();
dtpProcess.created = NOW; dtpProcess.created = NOW;
@ -63,7 +68,9 @@ export class ProcessService extends DtpService {
return dtpProcess.toObject(); return dtpProcess.toObject();
} }
async createWorkerProcess (endpoint: IDtpProcessEndpoint) : Promise<IDtpWorkerProcess> { async createWorkerProcess(
endpoint: IDtpProcessEndpoint
): Promise<IDtpWorkerProcess> {
const NOW = new Date(); const NOW = new Date();
const dtpProcess = new DtpWorkerProcess(); const dtpProcess = new DtpWorkerProcess();
dtpProcess.created = NOW; dtpProcess.created = NOW;
@ -87,10 +94,10 @@ export class ProcessService extends DtpService {
async setStatus( async setStatus(
dtpProcess: IDtpProcess, dtpProcess: IDtpProcess,
status: DtpProcessStatus, status: DtpProcessStatus
): Promise<void> { ): Promise<void> {
const NOW = new Date(); const NOW = new Date();
this.log.debug('setting process status', { this.log.debug("setting process status", {
endpoint: dtpProcess.endpoint, endpoint: dtpProcess.endpoint,
status, status,
}); });
@ -101,18 +108,18 @@ export class ProcessService extends DtpService {
updated: NOW, updated: NOW,
status, status,
}, },
}, }
); );
} }
async reportWebProcessStats( async reportWebProcessStats(
webProcess: IDtpWebProcess, webProcess: IDtpWebProcess,
webStats: IDtpWebProcessStats, webStats: IDtpWebProcessStats,
processStats: IDtpProcessStats, processStats: IDtpProcessStats
): Promise<void> { ): Promise<void> {
const NOW = new Date(); const NOW = new Date();
this.log.debug('reporting web process stats', { this.log.debug("reporting web process stats", {
hostname: webProcess.endpoint.hostname, hostname: webProcess.endpoint.hostname,
stats: webStats, stats: webStats,
}); });
@ -125,7 +132,7 @@ export class ProcessService extends DtpService {
processStats, processStats,
webStats, webStats,
}, },
}, }
); );
await DtpWebProcessStatsHistory.create({ await DtpWebProcessStatsHistory.create({
created: NOW, created: NOW,
@ -138,11 +145,11 @@ export class ProcessService extends DtpService {
async reportWorkerProcessStats( async reportWorkerProcessStats(
workerProcess: IDtpWorkerProcess, workerProcess: IDtpWorkerProcess,
workerStats: IDtpWorkerProcessStats, workerStats: IDtpWorkerProcessStats,
processStats: IDtpProcessStats, processStats: IDtpProcessStats
): Promise<void> { ): Promise<void> {
const NOW = new Date(); const NOW = new Date();
this.log.debug('reporting worker process stats', { this.log.debug("reporting worker process stats", {
hostname: workerProcess.endpoint.hostname, hostname: workerProcess.endpoint.hostname,
workerStats, workerStats,
processStats, processStats,
@ -156,7 +163,7 @@ export class ProcessService extends DtpService {
processStats, processStats,
workerStats, workerStats,
}, },
}, }
); );
await DtpWorkerProcessStatsHistory.create({ await DtpWorkerProcessStatsHistory.create({
created: NOW, created: NOW,
@ -166,24 +173,36 @@ export class ProcessService extends DtpService {
}); });
} }
async getWebProcessById (webProcessId: MongooseTypes.ObjectId): Promise<IDtpWebProcess | null> { async getWebProcessById(
const dtpProcess = await DtpWebProcess.findOne({ _id: webProcessId }).lean(); webProcessId: MongooseTypes.ObjectId
): Promise<IDtpWebProcess | null> {
const dtpProcess = await DtpWebProcess.findOne({
_id: webProcessId,
}).lean();
return dtpProcess; return dtpProcess;
} }
async getWorkerProcessById (workerProcessId: MongooseTypes.ObjectId): Promise<IDtpWorkerProcess | null> { async getWorkerProcessById(
const dtpProcess = await DtpWorkerProcess.findOne({ _id: workerProcessId }).lean(); workerProcessId: MongooseTypes.ObjectId
): Promise<IDtpWorkerProcess | null> {
const dtpProcess = await DtpWorkerProcess.findOne({
_id: workerProcessId,
}).lean();
return dtpProcess; return dtpProcess;
} }
async getProcessById (dtpProcessId: MongooseTypes.ObjectId) : Promise<IDtpProcess | null> { async getProcessById(
dtpProcessId: MongooseTypes.ObjectId
): Promise<IDtpProcess | null> {
const dtpProcess = await DtpProcess.findOne({ _id: dtpProcessId }).lean(); const dtpProcess = await DtpProcess.findOne({ _id: dtpProcessId }).lean();
return dtpProcess; return dtpProcess;
} }
async getProcessesByStatus (status: [string], pagination: WebPaginationParameters) : Promise<Array<IDtpProcess>> { async getProcessesByStatus(
const dtpProcesses = await DtpProcess status: [string],
.find({ status }) pagination: WebPaginationParameters
): Promise<Array<IDtpProcess>> {
const dtpProcesses = await DtpProcess.find({ status })
.sort({ created: -1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
@ -191,9 +210,10 @@ export class ProcessService extends DtpService {
return dtpProcesses; return dtpProcesses;
} }
async getAllProcesses (pagination: WebPaginationParameters) : Promise<Array<IDtpProcess>> { async getAllProcesses(
const dtpProcesses = await DtpProcess pagination: WebPaginationParameters
.find() ): Promise<Array<IDtpProcess>> {
const dtpProcesses = await DtpProcess.find()
.sort({ created: -1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
@ -203,30 +223,31 @@ export class ProcessService extends DtpService {
async expireProcesses(): Promise<void> { async expireProcesses(): Promise<void> {
const NOW = new Date(); const NOW = new Date();
const OLDEST_DATE = dayjs(NOW).subtract(3, 'minute').toDate(); const OLDEST_DATE = dayjs(NOW).subtract(3, "minute").toDate();
this.log.info("expiring processes", { oldest: OLDEST_DATE }); this.log.info("expiring processes", { oldest: OLDEST_DATE });
await DtpProcess await DtpProcess.find({
.find({
$and: [ $and: [
{ status: { $ne: DtpProcessStatus.Shutdown } }, { status: { $ne: DtpProcessStatus.Shutdown } },
{ updated: { $lt: OLDEST_DATE } }, { updated: { $lt: OLDEST_DATE } },
] ],
}) })
.cursor() .cursor()
.eachAsync(async (dtpProcess) => { .eachAsync(async (dtpProcess) => {
this.log.info('expiring process', { this.log.info("expiring process", {
processId: dtpProcess._id, processId: dtpProcess._id,
hostname: dtpProcess.endpoint.hostname, hostname: dtpProcess.endpoint.hostname,
}); });
await DtpProcess.updateOne( await DtpProcess.updateOne(
{ _id: dtpProcess._id }, { _id: dtpProcess._id },
{ $set: { status: DtpProcessStatus.Shutdown } }, { $set: { status: DtpProcessStatus.Shutdown } }
); );
}); });
} }
async remove (dtpProcess: IDtpProcess | MongooseTypes.ObjectId) : Promise<void> { async remove(
this.log.info('removing process', { processId: dtpProcess._id }); dtpProcess: IDtpProcess | MongooseTypes.ObjectId
): Promise<void> {
this.log.info("removing process", { processId: dtpProcess._id });
await DtpProcess.deleteOne({ _id: dtpProcess._id }); await DtpProcess.deleteOne({ _id: dtpProcess._id });
} }
} }

134
src/app/services/session.ts

@ -2,7 +2,7 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
'use strict'; "use strict";
import env from "../../config/env.js"; import env from "../../config/env.js";
import assert from "node:assert"; import assert from "node:assert";
@ -27,11 +27,7 @@ import {
Profile as GoogleProfile, Profile as GoogleProfile,
} from "passport-google-oauth20"; } from "passport-google-oauth20";
import { import { DtpPlatform, DtpService, WebError } from "../../lib/dtplib.js";
DtpPlatform,
DtpService,
WebError,
} from "../../lib/dtplib.js";
import { IUser } from "../models/user.js"; import { IUser } from "../models/user.js";
import { UserService } from "./user.js"; import { UserService } from "./user.js";
@ -56,9 +52,12 @@ export type SessionAuthCheckOptions = {
}; };
export class SessionService extends DtpService { export class SessionService extends DtpService {
static get name() {
static get name ( ) { return "SessionService"; } return "SessionService";
static get slug ( ) { return "session"; } }
static get slug() {
return "session";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, SessionService); super(platform, SessionService);
@ -74,14 +73,21 @@ export class SessionService extends DtpService {
usernameField: "email", usernameField: "email",
passwordField: "password", passwordField: "password",
}; };
passport.use("mt-local", new PassportLocal(strategyOptions, this.processLocalLogin.bind(this))); passport.use(
passport.use("mt-admin", new PassportLocal(strategyOptions, this.processAdminLogin.bind(this))); "mt-local",
new PassportLocal(strategyOptions, this.processLocalLogin.bind(this))
);
passport.use(
"mt-admin",
new PassportLocal(strategyOptions, this.processAdminLogin.bind(this))
);
if (env.apis.google.enabled) { if (env.apis.google.enabled) {
assert(env.apis.google.clientId, "MT_GOOGLE_CLIENT_ID is required"); assert(env.apis.google.clientId, "MT_GOOGLE_CLIENT_ID is required");
assert(env.apis.google.secret, "MT_GOOGLE_SECRET is required"); assert(env.apis.google.secret, "MT_GOOGLE_SECRET is required");
const callbackURL = (env.NODE_ENV === "production") const callbackURL =
env.NODE_ENV === "production"
? `https://${env.site.host}/auth/google/callback` ? `https://${env.site.host}/auth/google/callback`
: "https://localhost:3000/auth/google/callback"; : "https://localhost:3000/auth/google/callback";
@ -93,7 +99,9 @@ export class SessionService extends DtpService {
// state: true, // state: true,
// sessionKey: "goauth2", // sessionKey: "goauth2",
}; };
passport.use(new GoogleStrategy(googleOptions, this.processGoogleLogin.bind(this))); passport.use(
new GoogleStrategy(googleOptions, this.processGoogleLogin.bind(this))
);
} }
} }
@ -107,7 +115,11 @@ export class SessionService extends DtpService {
* @returns A middleware function to process per-request session data. * @returns A middleware function to process per-request session data.
*/ */
middleware(): RequestHandler { middleware(): RequestHandler {
return async (req: Request, res: Response, next: NextFunction) : Promise<void> => { return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
res.locals.util = util; res.locals.util = util;
res.locals.user = req.user; res.locals.user = req.user;
@ -130,29 +142,42 @@ export class SessionService extends DtpService {
* and permission requirements. * and permission requirements.
*/ */
authCheckMiddleware(options: SessionAuthCheckOptions): RequestHandler { authCheckMiddleware(options: SessionAuthCheckOptions): RequestHandler {
options = Object.assign({ options = Object.assign(
{
useRedirect: true, useRedirect: true,
loginUri: '/welcome/login', loginUri: "/welcome/login",
}, options); },
return async (req: Request, res: Response, next: NextFunction) : Promise<void> => { options
);
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
if (options.permissions.requireCanLogin && !req.user) { if (options.permissions.requireCanLogin && !req.user) {
if (options.useRedirect) { if (options.useRedirect) {
req.session.loginReturnTo = req.url; req.session.loginReturnTo = req.url;
await this.saveSession(req); await this.saveSession(req);
this.log.info('redirecting to login', { returnTo: req.url }); this.log.info("redirecting to login", { returnTo: req.url });
return res.redirect(options.loginUri); return res.redirect(options.loginUri);
} }
return next(new WebError(403, 'Must sign in to continue')); return next(new WebError(403, "Must sign in to continue"));
} }
/* /*
* Flags checks * Flags checks
*/ */
if (options.flags.requireAdmin && (!req.user || !req.user.flags.isAdmin)) { if (
return next(new WebError(403, 'Administrator privileges are required')); options.flags.requireAdmin &&
(!req.user || !req.user.flags.isAdmin)
) {
return next(new WebError(403, "Administrator privileges are required"));
} }
if (options.flags.requireEmailVerified && (!req.user || !req.user.flags.isEmailVerified)) { if (
options.flags.requireEmailVerified &&
(!req.user || !req.user.flags.isEmailVerified)
) {
return next(new WebError(403, "Email verification is required")); return next(new WebError(403, "Email verification is required"));
} }
@ -160,14 +185,27 @@ export class SessionService extends DtpService {
* Permission checks * Permission checks
*/ */
if (options.permissions.requireCanComment && (!req.user || !req.user.permissions.canComment)) { if (
return next(new WebError(403, 'Commenting privileges are required')); options.permissions.requireCanComment &&
(!req.user || !req.user.permissions.canComment)
) {
return next(new WebError(403, "Commenting privileges are required"));
} }
if (options.permissions.requireCanManageFeeds && (!req.user || !req.user.permissions.canManageFeeds)) { if (
return next(new WebError(403, 'Feed management privileges are required')); options.permissions.requireCanManageFeeds &&
(!req.user || !req.user.permissions.canManageFeeds)
) {
return next(
new WebError(403, "Feed management privileges are required")
);
} }
if (options.permissions.requireCanManageContent && (!req.user || !req.user.permissions.canManageContent)) { if (
return next(new WebError(403, 'Content management privileges are required')); options.permissions.requireCanManageContent &&
(!req.user || !req.user.permissions.canManageContent)
) {
return next(
new WebError(403, "Content management privileges are required")
);
} }
return next(); return next();
@ -185,7 +223,7 @@ export class SessionService extends DtpService {
*/ */
async serializeUser( async serializeUser(
user: IUser, user: IUser,
done: (error: Error | null, id?: string | undefined) => void, done: (error: Error | null, id?: string | undefined) => void
) { ) {
return done(null, user._id.toHexString()); return done(null, user._id.toHexString());
} }
@ -198,17 +236,20 @@ export class SessionService extends DtpService {
*/ */
async deserializeUser( async deserializeUser(
userId: string, userId: string,
done: (err: Error | null, user?: Express.User | false | null) => void, done: (err: Error | null, user?: Express.User | false | null) => void
): Promise<void> { ): Promise<void> {
const userService = this.platform.components.getService<UserService>("user"); const userService =
this.platform.components.getService<UserService>("user");
try { try {
const user = await userService.getAccountById(Types.ObjectId.createFromHexString(userId)); const user = await userService.getAccountById(
Types.ObjectId.createFromHexString(userId)
);
if (user && user.permissions && !user.permissions.canLogin) { if (user && user.permissions && !user.permissions.canLogin) {
return done(null, null); // destroy the session return done(null, null); // destroy the session
} }
return done(null, user); return done(null, user);
} catch (error) { } catch (error) {
this.log.error('failed to deserialize user from session', { error }); this.log.error("failed to deserialize user from session", { error });
return done(error as Error, null); return done(error as Error, null);
} }
} }
@ -240,9 +281,14 @@ export class SessionService extends DtpService {
username: string, username: string,
password: string, password: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
done: (error: any, user?: Express.User | false, options?: IVerifyOptions) => void, done: (
error: any,
user?: Express.User | false,
options?: IVerifyOptions
) => void
): Promise<void> { ): Promise<void> {
const userService = this.platform.components.getService<UserService>("user"); const userService =
this.platform.components.getService<UserService>("user");
try { try {
const user: IUser = await userService.login(username, password); const user: IUser = await userService.login(username, password);
this.log.info("user login", { user: { _id: user._id } }); this.log.info("user login", { user: { _id: user._id } });
@ -264,7 +310,11 @@ export class SessionService extends DtpService {
username: string, username: string,
password: string, password: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
done: (error: any, user?: Express.User | false, options?: IVerifyOptions) => void, done: (
error: any,
user?: Express.User | false,
options?: IVerifyOptions
) => void
): Promise<void> { ): Promise<void> {
const userService = this.getService<UserService>("user"); const userService = this.getService<UserService>("user");
try { try {
@ -288,17 +338,21 @@ export class SessionService extends DtpService {
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
profile: GoogleProfile, profile: GoogleProfile,
done: GoogleVerifyCallback, done: GoogleVerifyCallback
): Promise<void> { ): Promise<void> {
const userService = this.getService<UserService>("user"); const userService = this.getService<UserService>("user");
try { try {
const user = await userService.grantGoogle(profile, accessToken, refreshToken); const user = await userService.grantGoogle(
profile,
accessToken,
refreshToken
);
if (!user) { if (!user) {
return done(new Error("Failed to process User account"), false); return done(new Error("Failed to process User account"), false);
} }
return done(null, user); return done(null, user);
} catch (error) { } catch (error) {
this.log.error('failed to process Google login', { profile, error }); this.log.error("failed to process Google login", { profile, error });
return done(error, false); return done(error, false);
} }
} }

19
src/app/services/sidebar.ts

@ -4,21 +4,26 @@
import { Request, Response, NextFunction, RequestHandler } from "express"; import { Request, Response, NextFunction, RequestHandler } from "express";
import { import { DtpPlatform, DtpService } from "../../lib/dtplib.js";
DtpPlatform,
DtpService,
} from "../../lib/dtplib.js";
export class SidebarService extends DtpService { export class SidebarService extends DtpService {
static get name ( ) { return "SidebarService"; } static get name() {
static get slug ( ) { return "sidebar"; } return "SidebarService";
}
static get slug() {
return "sidebar";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, SidebarService); super(platform, SidebarService);
} }
middleware(): RequestHandler { middleware(): RequestHandler {
return async (_req: Request, _res: Response, next: NextFunction) : Promise<void> => { return async (
_req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try { try {
/* /*
* Populate sidebar content here * Populate sidebar content here

9
src/app/services/text.ts

@ -24,9 +24,12 @@ import { DtpPlatform, DtpService, DtpUnzalgo } from "../../lib/dtplib.js";
export type ReplacerFunction = (mention: string) => string; export type ReplacerFunction = (mention: string) => string;
export class TextService extends DtpService { export class TextService extends DtpService {
static get name() {
static get name ( ) { return "TextService"; } return "TextService";
static get slug ( ) { return "text"; } }
static get slug() {
return "text";
}
markedRenderer?: MarkedRenderer; markedRenderer?: MarkedRenderer;
marked?: Marked; marked?: Marked;

76
src/app/services/user.ts

@ -48,13 +48,19 @@ export interface UserPasswordChangeParams {
} }
export class UserService extends DtpService { export class UserService extends DtpService {
static get name() {
return "UserService";
}
static get slug() {
return "user";
}
static get name ( ) { return "UserService"; } USER_SELECT: string =
static get slug ( ) { return "user"; } "_id created email displayName bio image flags permissions";
SESSION_SELECT: string =
USER_SELECT: string = "_id created email displayName bio image flags permissions"; "_id created email displayName bio image theme flags permissions";
SESSION_SELECT: string = "_id created email displayName bio image theme flags permissions"; ADMIN_SELECT: string =
ADMIN_SELECT: string = "_id created email displayName bio image flags permissions"; "_id created email displayName bio image flags permissions";
populateUser: Array<mongoose.PopulateOptions> = []; populateUser: Array<mongoose.PopulateOptions> = [];
@ -63,7 +69,11 @@ export class UserService extends DtpService {
} }
requireAccountOwner(field: string = "userProfile"): RequestHandler { requireAccountOwner(field: string = "userProfile"): RequestHandler {
return async (req: Request, res: Response, next?: NextFunction) : Promise<void> => { return async (
req: Request,
res: Response,
next?: NextFunction
): Promise<void> => {
assert(next, "NextFunction is required"); assert(next, "NextFunction is required");
if (!req.user) { if (!req.user) {
throw new WebError(403, "Login required"); throw new WebError(403, "Login required");
@ -77,9 +87,12 @@ export class UserService extends DtpService {
} }
async create(params: UserCreateParams): Promise<IUser> { async create(params: UserCreateParams): Promise<IUser> {
const cryptoService = this.platform.components.getService<CryptoService>("crypto"); const cryptoService =
const emailService = this.platform.components.getService<EmailService>("email"); this.platform.components.getService<CryptoService>("crypto");
const textService = this.platform.components.getService<TextService>("text"); const emailService =
this.platform.components.getService<EmailService>("email");
const textService =
this.platform.components.getService<TextService>("text");
const NOW = new Date(); const NOW = new Date();
@ -117,14 +130,19 @@ export class UserService extends DtpService {
async grantGoogle( async grantGoogle(
profile: GoogleProfile, profile: GoogleProfile,
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string
): Promise<IUser> { ): Promise<IUser> {
assert(Array.isArray(profile.emails) && profile.emails.length > 0, "An email address is required"); assert(
Array.isArray(profile.emails) && profile.emails.length > 0,
"An email address is required"
);
const email = profile.emails.find((email) => email.verified); const email = profile.emails.find((email) => email.verified);
assert(email, "A verified email address is required"); assert(email, "A verified email address is required");
let dbUser: IUser | null = await this.getByEmailAddress(email.value.trim().toLowerCase()); let dbUser: IUser | null = await this.getByEmailAddress(
email.value.trim().toLowerCase()
);
if (dbUser) { if (dbUser) {
await User.updateOne( await User.updateOne(
{ _id: dbUser._id }, { _id: dbUser._id },
@ -132,13 +150,13 @@ export class UserService extends DtpService {
$set: { $set: {
"apis.google": { accessToken, refreshToken }, "apis.google": { accessToken, refreshToken },
}, },
}, }
); );
return dbUser; return dbUser;
} }
const randomPassword = Randomstring.generate({ length: 12 }); const randomPassword = Randomstring.generate({ length: 12 });
this.log.info('creating new User account for Google authorization', { this.log.info("creating new User account for Google authorization", {
profile: { profile: {
id: profile.id, id: profile.id,
email: email.value, email: email.value,
@ -163,7 +181,8 @@ export class UserService extends DtpService {
} }
async update(user: IUser, params: UserUpdateParams): Promise<IUser> { async update(user: IUser, params: UserUpdateParams): Promise<IUser> {
const textService = this.platform.components.getService<TextService>("text"); const textService =
this.platform.components.getService<TextService>("text");
const NOW = new Date(); const NOW = new Date();
const update: DtpServiceUpdate = {}; const update: DtpServiceUpdate = {};
@ -186,7 +205,9 @@ export class UserService extends DtpService {
update.$set.theme = params.theme; update.$set.theme = params.theme;
} }
const newUser = await User.findOneAndUpdate({ _id: user._id }, update, { new: true }); const newUser = await User.findOneAndUpdate({ _id: user._id }, update, {
new: true,
});
assert(newUser, "failed to update User record"); assert(newUser, "failed to update User record");
return newUser.toObject(); return newUser.toObject();
@ -196,7 +217,8 @@ export class UserService extends DtpService {
user: IUser, user: IUser,
params: UserPasswordChangeParams params: UserPasswordChangeParams
): Promise<IUser> { ): Promise<IUser> {
const cryptoService = this.platform.components.getService<CryptoService>("crypto"); const cryptoService =
this.platform.components.getService<CryptoService>("crypto");
if (!params.currentPassword) { if (!params.currentPassword) {
throw new WebError(400, "Must provide current password"); throw new WebError(400, "Must provide current password");
@ -249,7 +271,10 @@ export class UserService extends DtpService {
return User.populate(newUser, this.populateUser); return User.populate(newUser, this.populateUser);
} }
async setProfilePicture (user: IUser, file: Express.Multer.File) : Promise<IUser> { async setProfilePicture(
user: IUser,
file: Express.Multer.File
): Promise<IUser> {
const imageService = this.getService<ImageService>("image"); const imageService = this.getService<ImageService>("image");
const NOW = new Date(); const NOW = new Date();
@ -295,11 +320,11 @@ export class UserService extends DtpService {
); );
return User.populate(newUser, this.populateUser); return User.populate(newUser, this.populateUser);
} }
async login(email: string, password: string): Promise<IUser> { async login(email: string, password: string): Promise<IUser> {
const cryptoService = this.platform.components.getService<CryptoService>("crypto"); const cryptoService =
this.platform.components.getService<CryptoService>("crypto");
const user = await this.getAccountByEmailAddress(email); const user = await this.getAccountByEmailAddress(email);
if (!user) { if (!user) {
@ -310,7 +335,10 @@ export class UserService extends DtpService {
throw new WebError(403, "Account disabled"); throw new WebError(403, "Account disabled");
} }
const maskedPassword = cryptoService.maskPassword(user.passwordSalt, password); const maskedPassword = cryptoService.maskPassword(
user.passwordSalt,
password
);
if (maskedPassword !== user.password) { if (maskedPassword !== user.password) {
throw new WebError(400, "Credentials don't match user records"); throw new WebError(400, "Credentials don't match user records");
} }
@ -337,7 +365,7 @@ export class UserService extends DtpService {
async getById(userId: mongoose.Types.ObjectId): Promise<IUser | null> { async getById(userId: mongoose.Types.ObjectId): Promise<IUser | null> {
const user = await User.findOne({ _id: userId }) const user = await User.findOne({ _id: userId })
.select('+flags +permissions') .select("+flags +permissions")
.populate(this.populateUser) .populate(this.populateUser)
.lean(); .lean();
return user; return user;
@ -346,7 +374,7 @@ export class UserService extends DtpService {
async getByEmailAddress(email: string): Promise<IUser | null> { async getByEmailAddress(email: string): Promise<IUser | null> {
email = email.trim().toLowerCase(); email = email.trim().toLowerCase();
const user = await User.findOne({ email_lc: email }) const user = await User.findOne({ email_lc: email })
.select('+flags +permissions') .select("+flags +permissions")
.populate(this.populateUser) .populate(this.populateUser)
.lean(); .lean();
return user; return user;

63
src/app/services/video.ts

@ -4,10 +4,15 @@
import env from "../../config/env.js"; import env from "../../config/env.js";
import { Types } from 'mongoose'; import { Types } from "mongoose";
import Video, { IVideo, VideoStatus } from '../models/video.js'; import Bull from "bull";
import TextService from './text.js';
import Video, { IVideo, VideoStatus } from "../models/video.js";
import TextService from "./text.js";
import JobQueueService from "./job-queue.js";
import MinioService from "./minio.js";
import { import {
DtpService, DtpService,
@ -15,10 +20,7 @@ import {
WebPaginationParameters, WebPaginationParameters,
DtpServiceUpdate, DtpServiceUpdate,
WebError, WebError,
} from '../../lib/dtplib.js'; } from "../../lib/dtplib.js";
import Bull from 'bull';
import JobQueueService from './job-queue.js';
import MinioService from "./minio.js";
/** /**
* Interface to be used when creating and updating videos. * Interface to be used when creating and updating videos.
@ -37,9 +39,12 @@ export interface VideoLibrary {
} }
export class VideoService extends DtpService { export class VideoService extends DtpService {
static get name() {
static get name ( ) { return 'VideoService'; } return "VideoService";
static get slug ( ) { return 'video'; } }
static get slug() {
return "video";
}
jobQueue?: Bull.Queue; jobQueue?: Bull.Queue;
@ -52,7 +57,10 @@ export class VideoService extends DtpService {
this.jobQueue = jobQueueService.getJobQueue("video", env.jobQueues.video); this.jobQueue = jobQueueService.getJobQueue("video", env.jobQueues.video);
} }
async create (definition: VideoDefinition, file: Express.Multer.File) : Promise<IVideo> { async create(
definition: VideoDefinition,
file: Express.Multer.File
): Promise<IVideo> {
const textService = this.getService<TextService>("text"); const textService = this.getService<TextService>("text");
const minioService = this.getService<MinioService>("minio"); const minioService = this.getService<MinioService>("minio");
const NOW = new Date(); const NOW = new Date();
@ -87,19 +95,25 @@ export class VideoService extends DtpService {
key: video.file.key, key: video.file.key,
filePath: file.path, filePath: file.path,
metadata: { metadata: {
'Content-Type': file.mimetype, "Content-Type": file.mimetype,
'Content-Length': file.size.toString(), "Content-Length": file.size.toString(),
}, },
}); });
this.log.info("video file uploaded to storage", { etag: minioFile.etag }); this.log.info("video file uploaded to storage", { etag: minioFile.etag });
this.log.info("creating video record", { videoId: video._id, title: video.title }); this.log.info("creating video record", {
videoId: video._id,
title: video.title,
});
await video.save(); await video.save();
return video.toObject(); return video.toObject();
} }
async update (video: IVideo | Types.ObjectId, definition: VideoDefinition) : Promise<IVideo> { async update(
video: IVideo | Types.ObjectId,
definition: VideoDefinition
): Promise<IVideo> {
const textService = this.getService<TextService>("text"); const textService = this.getService<TextService>("text");
const update: DtpServiceUpdate = {}; const update: DtpServiceUpdate = {};
update.$set = {}; update.$set = {};
@ -108,18 +122,19 @@ export class VideoService extends DtpService {
update.$set.description = textService.filter(definition.description); update.$set.description = textService.filter(definition.description);
this.log.info("updating video", { videoId: video._id }); this.log.info("updating video", { videoId: video._id });
const newVideo = await Video const newVideo = await Video.findByIdAndUpdate(video._id, update, {
.findByIdAndUpdate(video._id, update, {
new: true, new: true,
}) }).lean();
.lean();
if (!newVideo) { if (!newVideo) {
throw new WebError(500, "Failed to update video"); throw new WebError(500, "Failed to update video");
} }
return newVideo; return newVideo;
} }
async setStatus (video: IVideo | Types.ObjectId, status: VideoStatus) : Promise<void> { async setStatus(
video: IVideo | Types.ObjectId,
status: VideoStatus
): Promise<void> {
this.log.info("setting video status", { videoId: video._id, status }); this.log.info("setting video status", { videoId: video._id, status });
await Video.findByIdAndUpdate(video._id, { $set: { status } }); await Video.findByIdAndUpdate(video._id, { $set: { status } });
} }
@ -130,8 +145,7 @@ export class VideoService extends DtpService {
} }
async getAll(pagination: WebPaginationParameters): Promise<VideoLibrary> { async getAll(pagination: WebPaginationParameters): Promise<VideoLibrary> {
const videos = await Video const videos = await Video.find({})
.find({ })
.sort({ created: -1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
@ -152,7 +166,10 @@ export class VideoService extends DtpService {
await minioService.removeObject(video.file?.bucket, video.file?.key); await minioService.removeObject(video.file?.bucket, video.file?.key);
} }
this.log.info("removing video record", { videoId: video._id, title: video.title }); this.log.info("removing video record", {
videoId: video._id,
title: video.title,
});
await Video.deleteOne({ _id: video._id }); await Video.deleteOne({ _id: video._id });
} }
} }

1
src/client/js/lib/app.ts

@ -5,7 +5,6 @@
import { WebLog } from "./log"; import { WebLog } from "./log";
export class WebApp { export class WebApp {
log: WebLog; log: WebLog;
constructor() { constructor() {

9
src/client/js/lib/log.ts

@ -41,7 +41,6 @@ export interface WebLogData {
} }
export class WebLog { export class WebLog {
componentName: string; componentName: string;
options: WebLogOptions; options: WebLogOptions;
@ -182,7 +181,13 @@ export class WebLog {
this.write("error", this.styles.error, event, msg, data); this.write("error", this.styles.error, event, msg, data);
} }
write(method: string, css: WebLogCss, event: string, msg: string, data?: WebLogData) { write(
method: string,
css: WebLogCss,
event: string,
msg: string,
data?: WebLogData
) {
if (!this.enabled) { if (!this.enabled) {
return; return;
} }

3
src/client/js/newsroom-app.ts

@ -9,14 +9,12 @@ export interface WebAppGlobals {
} }
declare global { declare global {
interface Window { interface Window {
dtp: WebAppGlobals; dtp: WebAppGlobals;
} }
} }
(async () => { (async () => {
try { try {
window.dtp = window.dtp || {}; window.dtp = window.dtp || {};
window.dtp.app = new WebApp(); window.dtp.app = new WebApp();
@ -24,5 +22,4 @@ declare global {
} catch (error) { } catch (error) {
console.error("failed to start DTP Newsroom application", { error }); console.error("failed to start DTP Newsroom application", { error });
} }
})(); })();

40
src/config/env.ts

@ -18,31 +18,39 @@ async function readJsonFile<T>(path: string): Promise<T> {
return JSON.parse(file.toString("utf-8")) as T; return JSON.parse(file.toString("utf-8")) as T;
} }
/* eslint-disable no-process-env */ /* eslint-disable no-process-env */
export default { export default {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
timezone: process.env.DTP_TIMEZONE || "America/New_York", timezone: process.env.DTP_TIMEZONE || "America/New_York",
root: ROOT_DIR, root: ROOT_DIR,
src: SRC_DIR, src: SRC_DIR,
pkg: await readJsonFile<typeof PackageJson>(path.join(ROOT_DIR, "package.json")), pkg: await readJsonFile<typeof PackageJson>(
path.join(ROOT_DIR, "package.json")
),
site: { site: {
company: process.env.DTP_SITE_COMPANY || "DTP Technologies, LLC", company: process.env.DTP_SITE_COMPANY || "DTP Technologies, LLC",
companyShort: process.env.DTP_SITE_COMPANY_SHORT || "DTP", companyShort: process.env.DTP_SITE_COMPANY_SHORT || "DTP",
name: process.env.DTP_SITE_NAME || "DTP Newsroom", name: process.env.DTP_SITE_NAME || "DTP Newsroom",
shortName: process.env.DTP_SITE_NAME || "Newsroom", shortName: process.env.DTP_SITE_NAME || "Newsroom",
description: process.env.DTP_SITE_DESCRIPTION || "A virtual newsroom powered by RSS and AI.", description:
domain: process.env.DTP_SITE_DOMAIN || "dev.newsroom.digitaltelepresence.com", process.env.DTP_SITE_DESCRIPTION ||
domainKey: process.env.DTP_SITE_DOMAIN_KEY || "newsroom.digitaltelepresence.com", "A virtual newsroom powered by RSS and AI.",
host: process.env.DTP_SITE_HOST || "dev.newsroom.digitaltelepresence.com:3443", domain:
process.env.DTP_SITE_DOMAIN || "dev.newsroom.digitaltelepresence.com",
domainKey:
process.env.DTP_SITE_DOMAIN_KEY || "newsroom.digitaltelepresence.com",
host:
process.env.DTP_SITE_HOST || "dev.newsroom.digitaltelepresence.com:3443",
}, },
session: { session: {
secret: process.env.DTP_SESSION_SECRET, secret: process.env.DTP_SESSION_SECRET,
trustProxy: (process.env.NODE_ENV === "production") || (process.env.DTP_SESSION_TRUST_PROXY === "enabled"), trustProxy:
process.env.NODE_ENV === "production" ||
process.env.DTP_SESSION_TRUST_PROXY === "enabled",
cookie: { cookie: {
secure: process.env.DTP_SESSION_COOKIE_SECURE === "enabled", secure: process.env.DTP_SESSION_COOKIE_SECURE === "enabled",
sameSite: process.env.DTP_SESSION_COOKIE_SAMESITE || false, sameSite: process.env.DTP_SESSION_COOKIE_SAMESITE || false,
} },
}, },
mongodb: { mongodb: {
host: process.env.DTP_MONGODB_HOST || "localhost", host: process.env.DTP_MONGODB_HOST || "localhost",
@ -106,13 +114,21 @@ export default {
host: process.env.DTP_EMAIL_SMTP_HOST || "localhost", host: process.env.DTP_EMAIL_SMTP_HOST || "localhost",
port: parseInt(process.env.DTP_EMAIL_SMTP_PORT || "465", 10), port: parseInt(process.env.DTP_EMAIL_SMTP_PORT || "465", 10),
secure: process.env.DTP_EMAIL_SMTP_SECURE === "enabled", secure: process.env.DTP_EMAIL_SMTP_SECURE === "enabled",
from: process.env.DTP_EMAIL_SMTP_FROM || "Support <[email protected]>", from:
process.env.DTP_EMAIL_SMTP_FROM ||
"Support <[email protected]>",
user: process.env.DTP_EMAIL_SMTP_USER, user: process.env.DTP_EMAIL_SMTP_USER,
password: process.env.DTP_EMAIL_SMTP_PASS, password: process.env.DTP_EMAIL_SMTP_PASS,
pool: { pool: {
enabled: process.env.DTP_EMAIL_SMTP_POOL_ENABLED === "enabled", enabled: process.env.DTP_EMAIL_SMTP_POOL_ENABLED === "enabled",
maxConnections: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_CONN || "5", 10), maxConnections: parseInt(
maxMessages: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_MSGS || "100", 10), process.env.DTP_EMAIL_SMTP_POOL_MAX_CONN || "5",
10
),
maxMessages: parseInt(
process.env.DTP_EMAIL_SMTP_POOL_MAX_MSGS || "100",
10
),
}, },
}, },
}, },
@ -157,7 +173,7 @@ export default {
debug: process.env.DTP_LOG_DEBUG === "enabled", debug: process.env.DTP_LOG_DEBUG === "enabled",
info: process.env.DTP_LOG_INFO === "enabled", info: process.env.DTP_LOG_INFO === "enabled",
warn: process.env.DTP_LOG_WARN === "enabled", warn: process.env.DTP_LOG_WARN === "enabled",
} },
}, },
}; };
/* eslint-enable no-process-env */ /* eslint-enable no-process-env */

38
src/lib/core/base.ts

@ -2,20 +2,22 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import env from '../../config/env.js'; import env from "../../config/env.js";
import path from 'node:path'; import path from "node:path";
import pug from 'pug'; import pug from "pug";
import { EventEmitter } from 'node:events'; import { EventEmitter } from "node:events";
import { DtpPlatform } from './platform.js'; import { DtpPlatform } from "./platform.js";
import { DtpLog } from './log.js'; import { DtpLog } from "./log.js";
import { DtpComponent } from './component.js'; import { DtpComponent } from "./component.js";
import { DisplayEngineService } from '../../app/services/display-engine.js'; import { DisplayEngineService } from "../../app/services/display-engine.js";
/**
* Base class for most components in the system.
*/
export class DtpBase extends EventEmitter { export class DtpBase extends EventEmitter {
platform: DtpPlatform; platform: DtpPlatform;
component: DtpComponent; component: DtpComponent;
log: DtpLog; log: DtpLog;
@ -29,21 +31,31 @@ export class DtpBase extends EventEmitter {
this.log = new DtpLog(this.component, platform.logFile); this.log = new DtpLog(this.component, platform.logFile);
} }
get name ( ) { return this.component.name; } get name() {
get slug ( ) { return this.component.slug; } return this.component.name;
}
get slug() {
return this.component.slug;
}
getService<T>(slug: string): T { getService<T>(slug: string): T {
return this.platform.components.getService<T>(slug); return this.platform.components.getService<T>(slug);
} }
loadAppTemplate(type: string, name: string): pug.compileTemplate { loadAppTemplate(type: string, name: string): pug.compileTemplate {
this.log.debug('loading application template', { type, name, root: env.src }); this.log.debug("loading application template", {
type,
name,
root: env.src,
});
const templateRoot = path.join(env.src, "app", "templates"); const templateRoot = path.join(env.src, "app", "templates");
return pug.compileFile(path.join(templateRoot, type, name)); return pug.compileFile(path.join(templateRoot, type, name));
} }
createDisplayList(name: string) { createDisplayList(name: string) {
const displayEngineService = this.getService<DisplayEngineService>('displayEngine'); const displayEngineService =
this.getService<DisplayEngineService>("displayEngine");
return displayEngineService.createDisplayList(name); return displayEngineService.createDisplayList(name);
} }
} }

19
src/lib/core/component-registry.ts

@ -23,14 +23,18 @@ type ServiceRegistry = Record<string, DtpService>;
type ControllerRegistry = Record<string, WebController>; type ControllerRegistry = Record<string, WebController>;
export class DtpComponentRegistry extends DtpBase { export class DtpComponentRegistry extends DtpBase {
static get name ( ) { return "DtpComponentRegistry"; }
static get slug ( ) { return "DtpComponentRegistry"; }
models: ModelRegistry = {}; models: ModelRegistry = {};
services: ServiceRegistry = {}; services: ServiceRegistry = {};
controllers: ControllerRegistry = {}; controllers: ControllerRegistry = {};
static get name() {
return "DtpComponentRegistry";
}
static get slug() {
return "DtpComponentRegistry";
}
constructor(platform: DtpPlatform) { constructor(platform: DtpPlatform) {
super(platform, DtpComponentRegistry); super(platform, DtpComponentRegistry);
} }
@ -77,7 +81,9 @@ export class DtpComponentRegistry extends DtpBase {
} }
try { try {
const ServiceClass = (await import(path.join(basePath, entry.name))).default; const ServiceClass = (await import(path.join(basePath, entry.name)))
.default;
this.log.info("loading service", { this.log.info("loading service", {
script: entry.name, script: entry.name,
name: ServiceClass.name, name: ServiceClass.name,
@ -125,7 +131,8 @@ export class DtpComponentRegistry extends DtpBase {
} }
try { try {
const ControllerClass = (await import(path.join(basePath, entry.name))).default; const ControllerClass = (await import(path.join(basePath, entry.name)))
.default;
if (!ControllerClass) { if (!ControllerClass) {
this.log.error("failed to receive a default export from controller", { this.log.error("failed to receive a default export from controller", {
script: entry.name, script: entry.name,

19
src/lib/core/log-file.ts

@ -2,12 +2,12 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import fs from 'node:fs'; import fs from "node:fs";
import path from 'node:path'; import path from "node:path";
import numeral from 'numeral'; import numeral from "numeral";
import { Writable, WritableOptions } from 'stream'; import { Writable, WritableOptions } from "stream";
type StreamCallback = (error?: Error | null) => void; type StreamCallback = (error?: Error | null) => void;
@ -19,7 +19,6 @@ export interface DtpLogFileOptions extends WritableOptions {
} }
export class DtpLogFile extends Writable { export class DtpLogFile extends Writable {
options: DtpLogFileOptions; options: DtpLogFileOptions;
file?: fs.WriteStream; file?: fs.WriteStream;
@ -35,13 +34,17 @@ export class DtpLogFile extends Writable {
fs.mkdirSync(this.options.basePath, { recursive: true }); fs.mkdirSync(this.options.basePath, { recursive: true });
const filename = path.join( const filename = path.join(
this.options.basePath, this.options.basePath,
`${this.options.name}.${numeral(this.fileIdx).format('000')}.log`, `${this.options.name}.${numeral(this.fileIdx).format("000")}.log`
); );
this.file = fs.createWriteStream(filename, { encoding: 'utf-8' }); this.file = fs.createWriteStream(filename, { encoding: "utf-8" });
this.writeCount = 0; this.writeCount = 0;
} }
_write(chunk: unknown, encoding: BufferEncoding, callback: StreamCallback): boolean { _write(
chunk: unknown,
encoding: BufferEncoding,
callback: StreamCallback
): boolean {
if (!this.file) { if (!this.file) {
return false; return false;
} }

8
src/lib/core/log-transport-console.ts

@ -7,11 +7,7 @@ import * as util from "node:util";
import dayjs from "dayjs"; import dayjs from "dayjs";
import color from "ansicolor"; import color from "ansicolor";
import { import { DtpLogLevel, DtpLogTransport, DtpComponent } from "../dtplib.js";
DtpLogLevel,
DtpLogTransport,
DtpComponent,
} from "../dtplib.js";
export class DtpLogTransportConsole implements DtpLogTransport { export class DtpLogTransportConsole implements DtpLogTransport {
async writeLog( async writeLog(
@ -19,7 +15,7 @@ export class DtpLogTransportConsole implements DtpLogTransport {
component: DtpComponent, component: DtpComponent,
level: DtpLogLevel, level: DtpLogLevel,
message: string, message: string,
metadata?: unknown, metadata?: unknown
): Promise<void> { ): Promise<void> {
let clevel = level.padEnd(5); let clevel = level.padEnd(5);
switch (level) { switch (level) {

19
src/lib/core/log-transport-file.ts

@ -2,14 +2,13 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { Writable } from 'node:stream'; import { Writable } from "node:stream";
import { DtpLogLevel } from './log.js'; import { DtpLogLevel } from "./log.js";
import { DtpLogTransport } from './log-transport.js'; import { DtpLogTransport } from "./log-transport.js";
import { DtpComponent } from "./component.js"; import { DtpComponent } from "./component.js";
export class DtpLogTransportFile implements DtpLogTransport { export class DtpLogTransportFile implements DtpLogTransport {
file: Writable; file: Writable;
constructor(file: Writable) { constructor(file: Writable) {
@ -21,11 +20,17 @@ export class DtpLogTransportFile implements DtpLogTransport {
component: DtpComponent, component: DtpComponent,
level: DtpLogLevel, level: DtpLogLevel,
message: string, message: string,
metadata?: unknown, metadata?: unknown
): Promise<void> { ): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const logMessage = JSON.stringify({ timestamp, component, level, message, metadata }); const logMessage = JSON.stringify({
const chunk = Buffer.from(logMessage + '\r\n'); timestamp,
component,
level,
message,
metadata,
});
const chunk = Buffer.from(logMessage + "\r\n");
this.file.write(chunk, (error: Error | null | undefined): void => { this.file.write(chunk, (error: Error | null | undefined): void => {
if (error) { if (error) {
return reject(error); return reject(error);

6
src/lib/core/log-transport.ts

@ -2,8 +2,8 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { DtpComponent } from './component.js'; import { DtpComponent } from "./component.js";
import { DtpLogLevel } from './log.js'; import { DtpLogLevel } from "./log.js";
export abstract class DtpLogTransport { export abstract class DtpLogTransport {
abstract writeLog( abstract writeLog(
@ -11,6 +11,6 @@ export abstract class DtpLogTransport {
component: DtpComponent, component: DtpComponent,
level: DtpLogLevel, level: DtpLogLevel,
message: string, message: string,
metadata?: unknown, metadata?: unknown
): Promise<void>; ): Promise<void>;
} }

4
src/lib/core/log.ts

@ -7,8 +7,8 @@ import env from "../../config/env.js";
import { DtpComponent } from "./component.js"; import { DtpComponent } from "./component.js";
import { DtpLogTransportConsole } from "./log-transport-console.js"; import { DtpLogTransportConsole } from "./log-transport-console.js";
import { DtpLogTransportFile } from "./log-transport-file.js"; import { DtpLogTransportFile } from "./log-transport-file.js";
import { DtpLogTransport } from './log-transport.js'; import { DtpLogTransport } from "./log-transport.js";
import { DtpLogFile } from './log-file.js'; import { DtpLogFile } from "./log-file.js";
export enum DtpLogLevel { export enum DtpLogLevel {
debug = "debug", debug = "debug",

15
src/lib/core/platform.ts

@ -1,7 +1,7 @@
// lib/core/platform.ts // lib/core/platform.ts
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
console.log('importing DtpPlatform'); console.log("importing DtpPlatform");
import env from "../../config/env.js"; import env from "../../config/env.js";
@ -18,8 +18,9 @@ interface ProcessWarning extends Error {
} }
export class DtpPlatform { export class DtpPlatform {
static get REPORT_INTERVAL() {
static get REPORT_INTERVAL ( ) { return 1000 * 60; } return 1000 * 60;
}
log: DtpLog; log: DtpLog;
logFile: DtpLogFile; logFile: DtpLogFile;
@ -31,7 +32,7 @@ export class DtpPlatform {
components: DtpComponentRegistry; components: DtpComponentRegistry;
constructor(component: DtpComponent) { constructor(component: DtpComponent) {
console.log('DtpPlatform constructor'); console.log("DtpPlatform constructor");
this.component = component; this.component = component;
const logOptions: DtpLogFileOptions = { const logOptions: DtpLogFileOptions = {
@ -58,7 +59,7 @@ export class DtpPlatform {
await this.components.loadModels(); await this.components.loadModels();
await this.components.loadServices(); await this.components.loadServices();
} catch (error) { } catch (error) {
throw new Error('failed to start DTP core', { cause: error }); throw new Error("failed to start DTP core", { cause: error });
} }
} }
@ -111,13 +112,13 @@ export class DtpPlatform {
async connectMongoDB() { async connectMongoDB() {
const mongoConnectUri = `mongodb://${env.mongodb.host}/${env.mongodb.database}`; const mongoConnectUri = `mongodb://${env.mongodb.host}/${env.mongodb.database}`;
this.log.info('connecting to MongoDB', { uri: mongoConnectUri }); this.log.info("connecting to MongoDB", { uri: mongoConnectUri });
await mongoose.connect(mongoConnectUri, { await mongoose.connect(mongoConnectUri, {
socketTimeoutMS: 0, socketTimeoutMS: 0,
dbName: env.mongodb.database, dbName: env.mongodb.database,
}); });
this.db = mongoose.connection; this.db = mongoose.connection;
this.log.debug('connected to MongoDB', { id: this.db.id }); this.log.debug("connected to MongoDB", { id: this.db.id });
} }
async resetIndexes(args: Array<string>) { async resetIndexes(args: Array<string>) {

19
src/lib/core/process.ts

@ -29,7 +29,6 @@ interface CpuTimes {
} }
export abstract class DtpProcess extends DtpPlatform { export abstract class DtpProcess extends DtpPlatform {
maxCpuTime: number = 0; maxCpuTime: number = 0;
processStats: IDtpProcessStats = { processStats: IDtpProcessStats = {
cpu: 0, cpu: 0,
@ -55,7 +54,7 @@ export abstract class DtpProcess extends DtpPlatform {
this.reportStats.bind(this), this.reportStats.bind(this),
null, null,
true, true,
env.timezone, env.timezone
); );
} }
@ -108,14 +107,21 @@ export abstract class DtpProcess extends DtpPlatform {
cpuTimes.sys /= cpus.length; cpuTimes.sys /= cpus.length;
cpuTimes.user /= cpus.length; cpuTimes.user /= cpus.length;
const cpuTime = cpuTimes.idle + cpuTimes.irq + cpuTimes.nice + cpuTimes.sys + cpuTimes.user; const cpuTime =
cpuTimes.idle +
cpuTimes.irq +
cpuTimes.nice +
cpuTimes.sys +
cpuTimes.user;
this.processStats.cpu = cpuTime / this.maxCpuTime; this.processStats.cpu = cpuTime / this.maxCpuTime;
} }
} }
async getDiskUsage(pathname: string): Promise<void> { async getDiskUsage(pathname: string): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
diskusage.check(pathname, (error?: Error, usage?: diskusage.DiskUsage) => { diskusage.check(
pathname,
(error?: Error, usage?: diskusage.DiskUsage) => {
if (error) { if (error) {
return reject(error); return reject(error);
} }
@ -124,7 +130,8 @@ export abstract class DtpProcess extends DtpPlatform {
return resolve(); return resolve();
} }
return 0; return 0;
}); }
);
}); });
} }
@ -132,7 +139,7 @@ export abstract class DtpProcess extends DtpPlatform {
this.processStats.netBytesRx = 0; this.processStats.netBytesRx = 0;
this.processStats.netBytesTx = 0; this.processStats.netBytesTx = 0;
const interfaces = await SystemInformation.networkStats('*'); const interfaces = await SystemInformation.networkStats("*");
for (const iface of interfaces) { for (const iface of interfaces) {
this.processStats.netBytesTx += iface.tx_bytes; this.processStats.netBytesTx += iface.tx_bytes;
this.processStats.netBytesRx += iface.rx_bytes; this.processStats.netBytesRx += iface.rx_bytes;

17
src/lib/core/service.ts

@ -2,22 +2,17 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { import { DtpPlatform, DtpBase, DtpComponent } from "../dtplib.js";
DtpPlatform,
DtpBase,
DtpComponent,
} from "../dtplib.js";
export type DtpServiceUpdate = { export type DtpServiceUpdate = {
$set?: Record<string, unknown>, $set?: Record<string, unknown>;
$unset?: Record<string, unknown>, $unset?: Record<string, unknown>;
$push?: Record<string, unknown>, $push?: Record<string, unknown>;
$pull?: Record<string, unknown>, $pull?: Record<string, unknown>;
$inc?: Record<string, number>, $inc?: Record<string, number>;
}; };
export abstract class DtpService extends DtpBase { export abstract class DtpService extends DtpBase {
constructor(platform: DtpPlatform, component: DtpComponent) { constructor(platform: DtpPlatform, component: DtpComponent) {
super(platform, component); super(platform, component);
} }

1
src/lib/core/unzalgo.ts

@ -17,7 +17,6 @@ const computeZalgoDensity = (text: string) =>
const clamp = (x: number) => Math.max(Math.min(x, 1), 0); const clamp = (x: number) => Math.max(Math.min(x, 1), 0);
export class DtpUnzalgo { export class DtpUnzalgo {
/** /**
* Computes a score [0, 1] for every word in the input string. Each score * Computes a score [0, 1] for every word in the input string. Each score
* represents the ratio of combining characters to total characters in a word. * represents the ratio of combining characters to total characters in a word.

7
src/lib/core/worker.ts

@ -18,7 +18,6 @@ import JobQueueService from "app/services/job-queue.js";
* Base class for all Worker processes. * Base class for all Worker processes.
*/ */
export class DtpWorker extends DtpProcess { export class DtpWorker extends DtpProcess {
workerProcess?: IDtpWorkerProcess; workerProcess?: IDtpWorkerProcess;
workerStats: IDtpWorkerProcessStats = { workerStats: IDtpWorkerProcessStats = {
jobRunCount: 0, jobRunCount: 0,
@ -27,13 +26,13 @@ export class DtpWorker extends DtpProcess {
jobQueue?: Bull.Queue; jobQueue?: Bull.Queue;
constructor(component: DtpComponent) { constructor(component: DtpComponent) {
console.log('DtpWorker constructor'); console.log("DtpWorker constructor");
super(component); super(component);
} }
async startWorker( async startWorker(
queueName: string, queueName: string,
queueOptions: Bull.JobOptions, queueOptions: Bull.JobOptions
): Promise<void> { ): Promise<void> {
await super.start(); await super.start();
@ -49,7 +48,7 @@ export class DtpWorker extends DtpProcess {
await processService.reportWorkerProcessStats( await processService.reportWorkerProcessStats(
this.workerProcess, this.workerProcess,
this.workerStats, this.workerStats,
this.processStats, this.processStats
); );
} }

33
src/lib/web/controller.ts

@ -10,7 +10,10 @@ import { Router, Request, Response, NextFunction } from "express";
import multer from "multer"; import multer from "multer";
import { ICsrfToken } from "../../app/models/csrf-token.js"; import { ICsrfToken } from "../../app/models/csrf-token.js";
import { CsrfTokenService, CsrfTokenOptions } from "../../app/services/csrf-token.js"; import {
CsrfTokenService,
CsrfTokenOptions,
} from "../../app/services/csrf-token.js";
import { import {
DtpBase, DtpBase,
@ -29,7 +32,6 @@ import {
* or JSON object responses. * or JSON object responses.
*/ */
export abstract class WebController extends DtpBase { export abstract class WebController extends DtpBase {
server: WebServer; server: WebServer;
router: Router; router: Router;
@ -83,7 +85,10 @@ export abstract class WebController extends DtpBase {
name: ControllerClass.name, name: ControllerClass.name,
slug: ControllerClass.slug, slug: ControllerClass.slug,
}); });
const controller: WebController = new ControllerClass(this.server, ControllerClass); const controller: WebController = new ControllerClass(
this.server,
ControllerClass
);
this.log.info("starting child controller", { this.log.info("starting child controller", {
name: ControllerClass.name, name: ControllerClass.name,
@ -115,8 +120,12 @@ export abstract class WebController extends DtpBase {
pageParamName: string = "p", pageParamName: string = "p",
cppParamName: string = "cpp" cppParamName: string = "cpp"
): WebPaginationParameters { ): WebPaginationParameters {
const pageParam: string = req.query[pageParamName] ? req.query[pageParamName] as string : "1"; const pageParam: string = req.query[pageParamName]
const cppParam: string = req.query[cppParamName] ? req.query[cppParamName] as string : maxPerPage.toString(); ? (req.query[pageParamName] as string)
: "1";
const cppParam: string = req.query[cppParamName]
? (req.query[cppParamName] as string)
: maxPerPage.toString();
const pagination: WebPaginationParameters = { const pagination: WebPaginationParameters = {
p: parseInt(pageParam, 10), p: parseInt(pageParam, 10),
skip: 0, skip: 0,
@ -140,16 +149,19 @@ export abstract class WebController extends DtpBase {
* @returns An ExpressJS middleware that enables a route to receive files. * @returns An ExpressJS middleware that enables a route to receive files.
*/ */
createMulter(slug: string, options: multer.Options): multer.Multer { createMulter(slug: string, options: multer.Options): multer.Multer {
if (!!slug && (typeof slug === 'object')) { if (!!slug && typeof slug === "object") {
options = slug; options = slug;
slug = this.component.slug; slug = this.component.slug;
} else { } else {
slug = slug || this.component.slug; slug = slug || this.component.slug;
} }
options = Object.assign({ options = Object.assign(
{
dest: path.join(env.https.uploadPath, slug), dest: path.join(env.https.uploadPath, slug),
}, options || { }); },
options || {}
);
return multer(options); return multer(options);
} }
@ -177,7 +189,10 @@ export abstract class WebController extends DtpBase {
* @param error WebError|Error|unknown The error being reported. * @param error WebError|Error|unknown The error being reported.
* @returns * @returns
*/ */
async renderErrorJSON (res: Response, error: WebError | Error | unknown) : Promise<void> { async renderErrorJSON(
res: Response,
error: WebError | Error | unknown
): Promise<void> {
if (error instanceof WebError) { if (error instanceof WebError) {
res.status(error.status).json({ res.status(error.status).json({
success: false, success: false,

56
src/lib/web/display-list.ts

@ -11,9 +11,8 @@ export interface WebDisplayListCommand {
} }
export class WebDisplayList { export class WebDisplayList {
id: string = uuidv4(); id: string = uuidv4();
name: string = 'default'; name: string = "default";
commands: Array<WebDisplayListCommand> = []; commands: Array<WebDisplayListCommand> = [];
constructor(name?: string) { constructor(name?: string) {
@ -22,121 +21,138 @@ export class WebDisplayList {
} }
} }
showNotification (message: string, status: string, pos: string, timeout: number) { showNotification(
message: string,
status: string,
pos: string,
timeout: number
) {
this.commands.push({ this.commands.push({
action: 'showNotification', action: "showNotification",
params: { message, status, pos, timeout }, params: { message, status, pos, timeout },
}); });
} }
showModal(html: string) { showModal(html: string) {
this.commands.push({ this.commands.push({
action: 'showModal', action: "showModal",
params: { html }, params: { html },
}); });
} }
addElement(selector: string, where: string, html: string) { addElement(selector: string, where: string, html: string) {
this.commands.push({ this.commands.push({
selector, action: 'addElement', selector,
action: "addElement",
params: { where, html }, params: { where, html },
}); });
} }
removeChildren(selector: string) { removeChildren(selector: string) {
this.commands.push({ this.commands.push({
selector, action: 'removeChildren', selector,
action: "removeChildren",
params: {}, params: {},
}); });
} }
setTextContent(selector: string, text: string) { setTextContent(selector: string, text: string) {
this.commands.push({ this.commands.push({
selector, action: 'setTextContent', selector,
action: "setTextContent",
params: { text }, params: { text },
}); });
} }
replaceElement(selector: string, html: string) { replaceElement(selector: string, html: string) {
this.commands.push({ this.commands.push({
selector, action: 'replaceElement', selector,
action: "replaceElement",
params: { html }, params: { html },
}); });
} }
removeElement(selector: string) { removeElement(selector: string) {
this.commands.push({ this.commands.push({
selector, action: 'removeElement', selector,
action: "removeElement",
params: {}, params: {},
}); });
} }
setAttribute(selector: string, name: string, value: string) { setAttribute(selector: string, name: string, value: string) {
this.commands.push({ this.commands.push({
selector, action: 'setAttribute', selector,
action: "setAttribute",
params: { name, value }, params: { name, value },
}); });
} }
removeAttribute(selector: string, name: string) { removeAttribute(selector: string, name: string) {
this.commands.push({ this.commands.push({
selector, action: 'removeAttribute', selector,
action: "removeAttribute",
params: { name }, params: { name },
}); });
} }
toggleAttribute(selector: string, name: string, force: boolean) { toggleAttribute(selector: string, name: string, force: boolean) {
this.commands.push({ this.commands.push({
selector, action: 'toggleAttribute', selector,
action: "toggleAttribute",
params: { name, force }, params: { name, force },
}); });
} }
addClass(selector: string, add: string) { addClass(selector: string, add: string) {
this.commands.push({ this.commands.push({
selector, action: 'addClass', selector,
action: "addClass",
params: { add }, params: { add },
}); });
} }
removeClass(selector: string, remove: string) { removeClass(selector: string, remove: string) {
this.commands.push({ this.commands.push({
selector, action: 'removeClass', selector,
action: "removeClass",
params: { remove }, params: { remove },
}); });
} }
replaceClass(selector: string, remove: string, add: string) { replaceClass(selector: string, remove: string, add: string) {
this.commands.push({ this.commands.push({
selector, action: 'replaceClass', selector,
action: "replaceClass",
params: { remove, add }, params: { remove, add },
}); });
} }
setValue(selector: string, value: string) { setValue(selector: string, value: string) {
this.commands.push({ this.commands.push({
selector, action: 'setValue', selector,
action: "setValue",
params: { value }, params: { value },
}); });
} }
navigateTo(href: string) { navigateTo(href: string) {
this.commands.push({ this.commands.push({
action: 'navigateTo', action: "navigateTo",
params: { href }, params: { href },
}); });
} }
navigateBack() { navigateBack() {
this.commands.push({ this.commands.push({
action: 'navigateBack', action: "navigateBack",
params: {}, params: {},
}); });
} }
reloadView() { reloadView() {
this.commands.push({ this.commands.push({
action: 'reloadView', action: "reloadView",
params: {}, params: {},
}); });
} }

1
src/lib/web/error.ts

@ -3,7 +3,6 @@
// All Rights Reserved // All Rights Reserved
export class WebError extends Error { export class WebError extends Error {
status: number; status: number;
constructor(status: number, message: string, options?: ErrorOptions) { constructor(status: number, message: string, options?: ErrorOptions) {

4
src/lib/web/file.ts

@ -2,7 +2,7 @@
// Copyright (C) 2025 DTP Technologies, LLC // Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved // All Rights Reserved
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
export class WebFile { export class WebFile {
uuid: string; uuid: string;
@ -15,7 +15,7 @@ export class WebFile {
filePath: string, filePath: string,
filename: string, filename: string,
mimetype: string, mimetype: string,
size: number, size: number
) { ) {
this.uuid = uuidv4(); this.uuid = uuidv4();
this.filePath = filePath; this.filePath = filePath;

138
src/lib/web/server.ts

@ -17,7 +17,7 @@ import svgCaptcha from "svg-captcha";
import * as rfs from "rotating-file-stream"; import * as rfs from "rotating-file-stream";
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from "express";
import morgan from "morgan"; import morgan from "morgan";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
@ -31,12 +31,7 @@ import { RedisStore } from "connect-redis";
import SessionService from "../../app/services/session.js"; import SessionService from "../../app/services/session.js";
import TextService from "../../app/services/text.js"; import TextService from "../../app/services/text.js";
import { import { DtpTools, DtpPlatform, DtpBase, WebError } from "../dtplib.js";
DtpTools,
DtpPlatform,
DtpBase,
WebError,
} from "../dtplib.js";
type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined; type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined;
@ -46,9 +41,12 @@ export interface WebServerStats {
} }
export class WebServer extends DtpBase { export class WebServer extends DtpBase {
static get name() {
static get name ( ) { return "WebServer"; } return "WebServer";
static get slug ( ) { return "webServer"; } }
static get slug() {
return "webServer";
}
app?: express.Application; app?: express.Application;
https?: https.Server; https?: https.Server;
@ -59,8 +57,12 @@ export class WebServer extends DtpBase {
super(platform, WebServer); super(platform, WebServer);
} }
get name ( ) { return "WebServer"; } get name() {
get slug ( ) { return "webServer"; } return "WebServer";
}
get slug() {
return "webServer";
}
async createExpressApp() { async createExpressApp() {
const textService = this.getService<TextService>("text"); const textService = this.getService<TextService>("text");
@ -68,7 +70,12 @@ export class WebServer extends DtpBase {
* Load the CAPTCHA font * Load the CAPTCHA font
*/ */
const captchaFontPath = path.resolve(env.src, "client", "fonts", "green-nature.ttf"); const captchaFontPath = path.resolve(
env.src,
"client",
"fonts",
"green-nature.ttf"
);
svgCaptcha.loadFont(captchaFontPath); svgCaptcha.loadFont(captchaFontPath);
/* /*
@ -84,7 +91,9 @@ export class WebServer extends DtpBase {
this.app.locals.MT_SCRIPT_DEBUG = env.NODE_ENV === "development"; this.app.locals.MT_SCRIPT_DEBUG = env.NODE_ENV === "development";
this.app.locals.env = env; this.app.locals.env = env;
this.app.locals.pkg = await DtpTools.readJsonFile<typeof PackageJson>(path.join(env.root, "package.json")); this.app.locals.pkg = await DtpTools.readJsonFile<typeof PackageJson>(
path.join(env.root, "package.json")
);
this.app.locals.platform = this; this.app.locals.platform = this;
this.app.locals.site = env.site; this.app.locals.site = env.site;
@ -94,7 +103,7 @@ export class WebServer extends DtpBase {
this.app.locals.marked = textService.marked; this.app.locals.marked = textService.marked;
if (env.log.http.enabled) { if (env.log.http.enabled) {
this.log.info('creating HTTP access log', { this.log.info("creating HTTP access log", {
name: env.log.http.name, name: env.log.http.name,
path: env.log.http.path, path: env.log.http.path,
}); });
@ -123,23 +132,71 @@ export class WebServer extends DtpBase {
return next(); return next();
} }
function serviceWorkerAllowed(_req: Request, res: Response, next: NextFunction) { function serviceWorkerAllowed(
_req: Request,
res: Response,
next: NextFunction
) {
res.set("Service-Worker-Allowed", "/"); res.set("Service-Worker-Allowed", "/");
return next(); return next();
} }
this.app.use(["/dist", "/"], serviceWorkerAllowed, express.static(path.join(env.root, "dist", "client"))); this.app.use(
this.app.use("/socket.io", cacheOneDay, express.static(path.join(env.root, "node_modules", "socket.io", "client-dist"))); ["/dist", "/"],
serviceWorkerAllowed,
express.static(path.join(env.root, "dist", "client"))
);
this.app.use(
"/socket.io",
cacheOneDay,
express.static(
path.join(env.root, "node_modules", "socket.io", "client-dist")
)
);
this.app.use("/uikit/images", cacheOneDay, express.static(path.join(env.root, "node_modules", "uikit", "src", "images"))); this.app.use(
this.app.use("/uikit", cacheOneDay, express.static(path.join(env.root, "node_modules", "uikit", "dist"))); "/uikit/images",
cacheOneDay,
express.static(
path.join(env.root, "node_modules", "uikit", "src", "images")
)
);
this.app.use(
"/uikit",
cacheOneDay,
express.static(path.join(env.root, "node_modules", "uikit", "dist"))
);
this.app.use("/fontawesome", cacheOneDay, express.static(path.join(env.root, "node_modules", "@fortawesome", "fontawesome-free"))); this.app.use(
this.app.use("/pretty-checkbox", cacheOneDay, express.static(path.join(env.root, "node_modules", "pretty-checkbox", "dist"))); "/fontawesome",
this.app.use("/cropperjs", cacheOneDay, express.static(path.join(env.root, "node_modules", "cropperjs", "dist"))); cacheOneDay,
express.static(
path.join(env.root, "node_modules", "@fortawesome", "fontawesome-free")
)
);
this.app.use(
"/pretty-checkbox",
cacheOneDay,
express.static(
path.join(env.root, "node_modules", "pretty-checkbox", "dist")
)
);
this.app.use(
"/cropperjs",
cacheOneDay,
express.static(path.join(env.root, "node_modules", "cropperjs", "dist"))
);
this.app.use("/dayjs", cacheOneDay, express.static(path.join(env.root, "node_modules", "dayjs"))); this.app.use(
this.app.use("/numeral", cacheOneDay, express.static(path.join(env.root, "node_modules", "numeral", "min"))); "/dayjs",
cacheOneDay,
express.static(path.join(env.root, "node_modules", "dayjs"))
);
this.app.use(
"/numeral",
cacheOneDay,
express.static(path.join(env.root, "node_modules", "numeral", "min"))
);
} }
registerPreSessionHandlers() { registerPreSessionHandlers() {
@ -155,7 +212,8 @@ export class WebServer extends DtpBase {
assert(env.session.secret, "Must define an HTTP session secret"); assert(env.session.secret, "Must define an HTTP session secret");
assert(this.app, "ExpressJS app instance is required"); assert(this.app, "ExpressJS app instance is required");
const sessionService = this.platform.components.getService<SessionService>("session"); const sessionService =
this.platform.components.getService<SessionService>("session");
const ONE_WEEK = 1000 * 60 * 60 * 24 * 7; const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
const SESSION_DURATION = ONE_WEEK; const SESSION_DURATION = ONE_WEEK;
@ -185,7 +243,7 @@ export class WebServer extends DtpBase {
}, },
store, store,
}; };
this.log.debug('ExpressJS session configuration', sessionConfig); this.log.debug("ExpressJS session configuration", sessionConfig);
this.app.use(session(sessionConfig)); this.app.use(session(sessionConfig));
/* /*
@ -202,7 +260,13 @@ export class WebServer extends DtpBase {
registerDefaultHandlers() { registerDefaultHandlers() {
assert(this.app, "ExpressJS app instance is required"); assert(this.app, "ExpressJS app instance is required");
this.app.use(async (err: WebError, _req: Request, res: Response, next: NextFunction) => { this.app.use(
async (
err: WebError,
_req: Request,
res: Response,
next: NextFunction
) => {
const errorCode = err.status || 500; const errorCode = err.status || 500;
++this.stats.errorCount; ++this.stats.errorCount;
@ -214,17 +278,25 @@ export class WebServer extends DtpBase {
}); });
return next(); return next();
}); }
);
} }
async startHttpsServer() { async startHttpsServer() {
assert(env.https.keyFile, "SSL private key file is required"); assert(env.https.keyFile, "SSL private key file is required");
assert(env.https.crtFile, "SSL certificate file is required"); assert(env.https.crtFile, "SSL certificate file is required");
const serverOptions = Object.assign({ const serverOptions = Object.assign(
key: await fs.promises.readFile(env.https.keyFile, { encoding: "utf-8" }), {
cert: await fs.promises.readFile(env.https.crtFile, { encoding: "utf-8" }), key: await fs.promises.readFile(env.https.keyFile, {
}, env.https); encoding: "utf-8",
}),
cert: await fs.promises.readFile(env.https.crtFile, {
encoding: "utf-8",
}),
},
env.https
);
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
assert(this.app, "ExpressJS app instance is required"); assert(this.app, "ExpressJS app instance is required");

36
src/newsroom-web.ts

@ -23,14 +23,16 @@ import { IDtpWebProcess } from "./app/models/process.js";
import ProcessService from "./app/services/process.js"; import ProcessService from "./app/services/process.js";
import { DtpProcess, WebServer } from "./lib/dtplib.js"; import { DtpProcess, WebServer } from "./lib/dtplib.js";
/** /**
* The DTP Newsroom Web application. * The DTP Newsroom Web application.
*/ */
class DtpNewsroomWebProcess extends DtpProcess { class DtpNewsroomWebProcess extends DtpProcess {
static get name() {
static get name ( ) { return "DtpNewsroomWebProcess"; } return "DtpNewsroomWebProcess";
static get slug ( ) { return "newsroom-web"; } }
static get slug() {
return "newsroom-web";
}
webServer?: WebServer; webServer?: WebServer;
bs?: BrowserSyncInstance; bs?: BrowserSyncInstance;
@ -93,10 +95,12 @@ class DtpNewsroomWebProcess extends DtpProcess {
async startBrowserSync() { async startBrowserSync() {
if (env.NODE_ENV !== "development") { if (env.NODE_ENV !== "development") {
throw new Error("BrowserSync is only available in development environments"); throw new Error(
"BrowserSync is only available in development environments"
);
} }
this.log.debug('BrowserSync create', { options: bsOptions }); this.log.debug("BrowserSync create", { options: bsOptions });
this.bs = browserSync.create("mt-control"); this.bs = browserSync.create("mt-control");
this.bs.init(bsOptions); this.bs.init(bsOptions);
@ -115,7 +119,7 @@ class DtpNewsroomWebProcess extends DtpProcess {
this.bs.reload(); this.bs.reload();
} }
} catch (error) { } catch (error) {
this.log.error('Less build failed', { error }); this.log.error("Less build failed", { error });
} }
}); });
@ -162,12 +166,12 @@ class DtpNewsroomWebProcess extends DtpProcess {
await this.lessCompile( await this.lessCompile(
path.join(env.src, "client", "less", "newsroom-dark.less"), path.join(env.src, "client", "less", "newsroom-dark.less"),
path.join(env.root, "dist", "client", "css", "newsroom-dark.css"), path.join(env.root, "dist", "client", "css", "newsroom-dark.css"),
path.join(env.root, "dist", "client", "css", "newsroom-dark.map"), path.join(env.root, "dist", "client", "css", "newsroom-dark.map")
); );
await this.lessCompile( await this.lessCompile(
path.join(env.src, "client", "less", "newsroom-light.less"), path.join(env.src, "client", "less", "newsroom-light.less"),
path.join(env.root, "dist", "client", "css", "newsroom-light.css"), path.join(env.root, "dist", "client", "css", "newsroom-light.css"),
path.join(env.root, "dist", "client", "css", "newsroom-light.map"), path.join(env.root, "dist", "client", "css", "newsroom-light.map")
); );
} }
@ -187,9 +191,7 @@ class DtpNewsroomWebProcess extends DtpProcess {
async runEsBuild(): Promise<void> { async runEsBuild(): Promise<void> {
const options: esbuild.BuildOptions = { const options: esbuild.BuildOptions = {
entryPoints: [ entryPoints: [path.join(env.src, "client", "js", "newsroom-app.ts")],
path.join(env.src, "client", "js", "newsroom-app.ts"),
],
outdir: path.join(env.root, "dist", "client", "js"), outdir: path.join(env.root, "dist", "client", "js"),
bundle: true, bundle: true,
minify: false, minify: false,
@ -200,7 +202,7 @@ class DtpNewsroomWebProcess extends DtpProcess {
plugins: [], plugins: [],
}; };
const output = await esbuild.build(options); const output = await esbuild.build(options);
this.log.info('esbuild finished', { output }); this.log.info("esbuild finished", { output });
} }
async reportStats(): Promise<void> { async reportStats(): Promise<void> {
@ -208,13 +210,15 @@ class DtpNewsroomWebProcess extends DtpProcess {
const processService = this.getService<ProcessService>("process"); const processService = this.getService<ProcessService>("process");
await this.updateStats(); await this.updateStats();
await processService.reportWebProcessStats(this.webProcess, this.webStats, this.processStats); await processService.reportWebProcessStats(
this.webProcess,
this.webStats,
this.processStats
);
} }
} }
(async () => { (async () => {
const webApp = new DtpNewsroomWebProcess(); const webApp = new DtpNewsroomWebProcess();
await webApp.start(); await webApp.start();
})(); })();

35
src/release.ts

@ -12,7 +12,10 @@ import semver from "semver";
import SimpleGit from "simple-git"; import SimpleGit from "simple-git";
const release = process.argv[2] as semver.ReleaseType; const release = process.argv[2] as semver.ReleaseType;
assert (release && semver.RELEASE_TYPES.includes(release), "Must specify the release type: major, minor, patch, build"); assert(
release && semver.RELEASE_TYPES.includes(release),
"Must specify the release type: major, minor, patch, build"
);
const pkgVersion = new semver.SemVer(env.pkg.version); const pkgVersion = new semver.SemVer(env.pkg.version);
assert(pkgVersion, "A valid version is required in package.json"); assert(pkgVersion, "A valid version is required in package.json");
@ -22,11 +25,16 @@ newVersion.inc(release);
env.pkg.version = newVersion.format(); env.pkg.version = newVersion.format();
const releaseTag = `v${newVersion.format()}`; const releaseTag = `v${newVersion.format()}`;
console.log('tagging release:', pkgVersion.format(), release, newVersion.format(), releaseTag); console.log(
"tagging release:",
pkgVersion.format(),
release,
newVersion.format(),
releaseTag
);
(async () => { (async () => {
try { try {
const simpleGit = SimpleGit(env.root); const simpleGit = SimpleGit(env.root);
/* /*
@ -47,14 +55,24 @@ console.log('tagging release:', pkgVersion.format(), release, newVersion.format(
*/ */
const status = await simpleGit.status(); const status = await simpleGit.status();
assert(!status.detached, "git repo is detached, please fix."); assert(!status.detached, "git repo is detached, please fix.");
assert(status.not_added.length === 0, "You have untracked files, please fix."); assert(
assert(status.modified.length === 0, "You have modified files, please fix."); status.not_added.length === 0,
"You have untracked files, please fix."
);
assert(
status.modified.length === 0,
"You have modified files, please fix."
);
/* /*
* Write package.json to disk * Write package.json to disk
*/ */
const pkgFilename = path.join(env.root, "package.json"); const pkgFilename = path.join(env.root, "package.json");
await fs.promises.writeFile(pkgFilename, JSON.stringify(env.pkg, null, 2), "utf-8"); await fs.promises.writeFile(
pkgFilename,
JSON.stringify(env.pkg, null, 2),
"utf-8"
);
/* /*
* Commit package.json and push to develop on origin. * Commit package.json and push to develop on origin.
@ -78,7 +96,8 @@ console.log('tagging release:', pkgVersion.format(), release, newVersion.format(
simpleGit.checkout("develop"); simpleGit.checkout("develop");
} catch (error) { } catch (error) {
console.error(`Failed to process release: ${(error as Error).message}`, { error }); console.error(`Failed to process release: ${(error as Error).message}`, {
error,
});
} }
})(); })();

17
src/speechgen.ts

@ -11,15 +11,22 @@ import { SpeechVoice } from "./app/models/lib/speech-personality.js";
import OpenAiService, { IGeneratedFile } from "app/services/openai.js"; import OpenAiService, { IGeneratedFile } from "app/services/openai.js";
class SpeechGenerator extends DtpProcess { class SpeechGenerator extends DtpProcess {
static get name() {
static get name ( ) { return "SpeechGenerator"; } return "SpeechGenerator";
static get slug ( ) { return "speechgen"; } }
static get slug() {
return "speechgen";
}
constructor() { constructor() {
super(SpeechGenerator); super(SpeechGenerator);
} }
async generate (model: string, voice: SpeechVoice, input: string) : Promise<IGeneratedFile> { async generate(
model: string,
voice: SpeechVoice,
input: string
): Promise<IGeneratedFile> {
try { try {
console.log("requesting audio resource"); console.log("requesting audio resource");
const openAiService = this.getService<OpenAiService>("openAi"); const openAiService = this.getService<OpenAiService>("openAi");
@ -35,7 +42,6 @@ class SpeechGenerator extends DtpProcess {
} }
(async () => { (async () => {
try { try {
console.log("Speech Generator: A command line tool to generate audio"); console.log("Speech Generator: A command line tool to generate audio");
const generator = new SpeechGenerator(); const generator = new SpeechGenerator();
@ -57,7 +63,6 @@ class SpeechGenerator extends DtpProcess {
} catch (error) { } catch (error) {
console.error("audiogen has failed", error); console.error("audiogen has failed", error);
} }
})(); })();
/* /*

22
src/workers/newsroom.ts

@ -16,13 +16,17 @@ import { DtpProcessStatus } from "../app/models/lib/process-status.js";
import ProcessService from "../app/services/process.js"; import ProcessService from "../app/services/process.js";
class NewsroomWorker extends DtpWorker { class NewsroomWorker extends DtpWorker {
static get name ( ) { return "NewsroomWorker"; }
static get slug ( ) { return "newsroom-worker"; }
fetchNews?: FetchNewsJob; fetchNews?: FetchNewsJob;
fetchNewsJob?: CronJob; fetchNewsJob?: CronJob;
static get name() {
return "NewsroomWorker";
}
static get slug() {
return "newsroom-worker";
}
constructor() { constructor() {
super(NewsroomWorker); super(NewsroomWorker);
} }
@ -50,7 +54,11 @@ class NewsroomWorker extends DtpWorker {
this.fetchNews = new FetchNewsJob(this, this.jobQueue); this.fetchNews = new FetchNewsJob(this, this.jobQueue);
this.log.info("registering ingest-entry job processor"); this.log.info("registering ingest-entry job processor");
this.jobQueue.process("ingest-entry", 1, this.fetchNews.ingestEntry.bind(this.fetchNews)); this.jobQueue.process(
"ingest-entry",
1,
this.fetchNews.ingestEntry.bind(this.fetchNews)
);
/* /*
* Create cron job schedules * Create cron job schedules
@ -63,7 +71,7 @@ class NewsroomWorker extends DtpWorker {
this.fetchNews.run.bind(this.fetchNews), this.fetchNews.run.bind(this.fetchNews),
null, null,
true, true,
env.timezone, env.timezone
); );
/* /*
@ -86,7 +94,6 @@ class NewsroomWorker extends DtpWorker {
} }
(async () => { (async () => {
process.on("unhandledRejection", async (error: Error, p) => { process.on("unhandledRejection", async (error: Error, p) => {
console.error("Unhandled rejection", { console.error("Unhandled rejection", {
error: error, error: error,
@ -103,5 +110,4 @@ class NewsroomWorker extends DtpWorker {
console.log("worker error", (error as Error).message); console.log("worker error", (error as Error).message);
process.exit(-1); process.exit(-1);
} }
})(); })();

55
src/workers/newsroom/fetch-news.ts

@ -24,16 +24,20 @@ import { HumanGender } from "app/models/lib/human-gender.js";
import { SpeechVoice } from "app/models/lib/speech-personality.js"; import { SpeechVoice } from "app/models/lib/speech-personality.js";
export class FetchNewsJob extends DtpBase { export class FetchNewsJob extends DtpBase {
static get name ( ) { return "FetchNewsJob"; }
static get slug ( ) { return "job:fetch-news"; }
worker: DtpWorker; worker: DtpWorker;
jobQueue: Bull.Queue; jobQueue: Bull.Queue;
userAgent: UserAgent = new UserAgent(); userAgent: UserAgent = new UserAgent();
aiService: OpenAiService; aiService: OpenAiService;
static get name() {
return "FetchNewsJob";
}
static get slug() {
return "job:fetch-news";
}
constructor(worker: DtpWorker, jobQueue: Bull.Queue) { constructor(worker: DtpWorker, jobQueue: Bull.Queue) {
super(worker, FetchNewsJob); super(worker, FetchNewsJob);
this.worker = worker; this.worker = worker;
@ -43,10 +47,7 @@ export class FetchNewsJob extends DtpBase {
async run(): Promise<void> { async run(): Promise<void> {
const processor = this.ingestFeed.bind(this); const processor = this.ingestFeed.bind(this);
await Feed await Feed.find().cursor().eachAsync(processor);
.find()
.cursor()
.eachAsync(processor);
} }
async ingestFeed(feed: IFeed): Promise<void> { async ingestFeed(feed: IFeed): Promise<void> {
@ -55,12 +56,12 @@ export class FetchNewsJob extends DtpBase {
this.log.info("running news fetch job", { title: feed.title }); this.log.info("running news fetch job", { title: feed.title });
const rss: FeedData = await feedService.fetchRssFeed(feed); const rss: FeedData = await feedService.fetchRssFeed(feed);
this.log.debug('feed loaded', { this.log.debug("feed loaded", {
feed: { feed: {
title: rss.title, title: rss.title,
}, },
}); });
if (!Array.isArray(rss.entries) || (rss.entries.length === 0)) { if (!Array.isArray(rss.entries) || rss.entries.length === 0) {
this.log.warn("the RSS feed is empty", { title: rss.title }); this.log.warn("the RSS feed is empty", { title: rss.title });
return; return;
} }
@ -86,7 +87,10 @@ export class FetchNewsJob extends DtpBase {
async ingestEntry(job: Bull.Job): Promise<void> { async ingestEntry(job: Bull.Job): Promise<void> {
const feed: IFeed = job.data.feed as IFeed; const feed: IFeed = job.data.feed as IFeed;
const entry: FeedEntry = job.data.entry as FeedEntry; const entry: FeedEntry = job.data.entry as FeedEntry;
this.log.info("ingesting news article", { jobId: job.id, title: entry.title }); this.log.info("ingesting news article", {
jobId: job.id,
title: entry.title,
});
const feedItem = await FeedItem.findOneAndUpdate( const feedItem = await FeedItem.findOneAndUpdate(
{ link: entry.link }, { link: entry.link },
@ -101,7 +105,7 @@ export class FetchNewsJob extends DtpBase {
link: entry.link, link: entry.link,
}, },
}, },
{ upsert: true, new: true }, { upsert: true, new: true }
); );
if (!feedItem.isNew) { if (!feedItem.isNew) {
@ -119,7 +123,7 @@ export class FetchNewsJob extends DtpBase {
model: "tts-1-hd", model: "tts-1-hd",
voice: SpeechVoice.Shimmer, voice: SpeechVoice.Shimmer,
role: "You are a female anchor of a television news broadcast.", role: "You are a female anchor of a television news broadcast.",
} },
}); });
} }
@ -129,12 +133,14 @@ export class FetchNewsJob extends DtpBase {
const response = await fetch(rssEntry.link, { const response = await fetch(rssEntry.link, {
method: "GET", method: "GET",
headers: { headers: {
"Host": url.host, Host: url.host,
"User-Agent": this.userAgent.toString(), "User-Agent": this.userAgent.toString(),
} },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch news article: ${response.statusText} (${response.status})`); throw new Error(
`Failed to fetch news article: ${response.statusText} (${response.status})`
);
} }
const html = await response.text(); const html = await response.text();
@ -143,7 +149,7 @@ export class FetchNewsJob extends DtpBase {
const paragraphs = document.body.querySelectorAll("p"); const paragraphs = document.body.querySelectorAll("p");
let body: string = ''; let body: string = "";
for (const p of paragraphs) { for (const p of paragraphs) {
body += `[${p.classList.toString()}] ${p.textContent}\n\n`; body += `[${p.classList.toString()}] ${p.textContent}\n\n`;
} }
@ -165,17 +171,24 @@ export class FetchNewsJob extends DtpBase {
feedItem.summary = await openAiService.summarizeFeedItem(feedItem); feedItem.summary = await openAiService.summarizeFeedItem(feedItem);
await FeedItem.updateOne( await FeedItem.updateOne(
{ _id: feedItem._id }, { _id: feedItem._id },
{ $set: { summary: feedItem.summary } }, { $set: { summary: feedItem.summary } }
); );
this.log.debug('article summarized', { title: feedItem.title, summary: feedItem.summary }); this.log.debug("article summarized", {
title: feedItem.title,
summary: feedItem.summary,
});
} }
async generateItemNarration( async generateItemNarration(
feedItem: IFeedItem, feedItem: IFeedItem,
host: IBroadcastShowHost, host: IBroadcastShowHost
): Promise<IGeneratedFile> { ): Promise<IGeneratedFile> {
const openAiService = this.getService<OpenAiService>("openAi"); const openAiService = this.getService<OpenAiService>("openAi");
assert(feedItem.summary, "Feed item summary is required"); assert(feedItem.summary, "Feed item summary is required");
return openAiService.generateSpeech(feedItem.summary, host.speech.model, host.speech.voice); return openAiService.generateSpeech(
feedItem.summary,
host.speech.model,
host.speech.voice
);
} }
} }

Loading…
Cancel
Save