Skip to content

Commit

Permalink
Merge branch 'registration-notification-email' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
floscher committed Jul 1, 2024
2 parents 55dbf24 + 33d2341 commit 1cfe9ee
Show file tree
Hide file tree
Showing 12 changed files with 192 additions and 11 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ A blog software written in Typescript, with an Express.js backend and a Vue3 fro
* [5010](http://localhost:5010): dev client
* [5020](http://localhost:5020): dev postgres
* [5030](https://localhost:5030): login portal (fake OAuth)
* [5040](https://localhost:5040): dev SMTP server (HTTP port)
* [5041](https://localhost:5041): dev SMTP server (SMTP port)
* [5100](http://localhost:5100): production app
* [5120](http://localhost:5120): production postgres

Expand Down
3 changes: 2 additions & 1 deletion client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
</div>
</nav>
<RouterView :userPermissions="loggedInUserInfo?.permissions" />
<RouterView :userPermissions="loggedInUserInfo?.permissions ?? DEFAULT_ROLE" />
</div>
<footer class="page-footer">
<div class="container">
Expand Down Expand Up @@ -118,6 +118,7 @@ import { saveIdToken } from "@client/util/storage.js";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
import type { AppSettingsDto, LoggedInUserInfo } from "@fumix/fu-blog-common";
import { DEFAULT_ROLE } from "@fumix/fu-blog-common";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
Expand Down
5 changes: 3 additions & 2 deletions client/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"not-available": {
"title": "Post nicht vorhanden",
"message": "Der Post ist nicht verfΓΌgbar oder wurde gelΓΆscht. Sie kΓΆnnen mit dem Button zur Startseite zurΓΌck, oder nutzen sie die Suchfunktion um einen bestimmten Post zu finden."
}
}
}
},
"admin": {
Expand Down Expand Up @@ -105,7 +105,8 @@
"email": "E-mail",
"username": "Benutzername",
"fullname": "Dein Name"
}
},
"username-requirements": "Der Benutzername muss zwischen {min} und {max} Zeichen lang sein (ist {length} Zeichen lang) und darf nur aus den Buchstaben a-z/A-Z, den Zahlen 0-9 und den Zeichen ._- bestehen."
},
"user_not_found": "Benutzer nicht gefunden."
}
Expand Down
5 changes: 3 additions & 2 deletions client/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@
"email": "Email",
"username": "Username",
"fullname": "Your name"
}
},
"username-requirements": "The username must be between 3 and 32 characters long and can only consist of letters a-z/A-Z, digits 0-9 and characters ._-"
},
"user_not_found": "User not found."
}
}
}
2 changes: 1 addition & 1 deletion client/src/util/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class PostEndpoints {

static async findPosts(pageIndex: number, itemsPerPage = 12, search: string | undefined = undefined, operator: "and" | "or" = "and") {
return callServer<void, JsonMimeType, { data: [PublicPost[], number | null] }>(
`/api/posts/page/${pageIndex}/count/${itemsPerPage}${search ? `/search/${encodeURIComponent(search)}/operator/${operator}` : ""}###`,
`/api/posts/page/${pageIndex}/count/${itemsPerPage}${search ? `/search/${encodeURIComponent(search)}/operator/${operator}` : ""}`,
"GET",
"application/json",
null,
Expand Down
9 changes: 8 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
version: '3'
name: "fublog-development"

services:

postgres-dev:
Expand All @@ -13,3 +14,9 @@ services:
POSTGRES_DB: "fublog"
ports:
- "5020:5432"

mailhog:
image: mailhog/mailhog:v1.0.1
ports:
- "5041:1025" # SMTP port
- "5040:8025" # HTTP port
12 changes: 12 additions & 0 deletions docker/compose/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ APP_MAIN_WEBSITE_URL=https://fumix.de
# OAUTH_GITLAB_2_ISSUER=
# …

###
### E-Mail settings
###
EMAIL_SMTP_HOST=
EMAIL_SMTP_FROM=
EMAIL_SMTP_USER=
EMAIL_SMTP_PASSWORD=
## Allowed values: "TLS" or "STARTTLS". If not set, defaults to "TLS".
# EMAIL_SMTP_SECURITY=TLS
## Custom SMTP port. If not explicitly set, the default port is 465. Except when `EMAIL_SMTP_SECURITY=STARTTLS` is set, then the default is 587.
# EMAIL_SMTP_PORT=465

###
### As soon as the first user is registered in the blog, you can
### delete everything in this file after this line.
Expand Down
6 changes: 6 additions & 0 deletions server/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@
# OAUTH_FAKE_CLIENT_ID=… # Custom client ID (default value is `ID`)
# OAUTH_FAKE_CLIENT_SECRET=… # Custom client secret (default value is `secret`)
# OAUTH_FAKE_ISSUER=localhost:42 # Custom domain (default value is `localhost:5030`)

# For development the user and password can be omitted
EMAIL_SMTP_HOST=localhost
EMAIL_SMTP_FROM=fublog@localhost
EMAIL_SMTP_SECURITY=STARTTLS
EMAIL_SMTP_PORT=5041
8 changes: 5 additions & 3 deletions server/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OAuthUserInfoDto,
UserInfoOAuthToken,
} from "@fumix/fu-blog-common";
import { sendNotificationEmailAboutNewRegistration } from "../service/email-service.js";
import express, { Request, Response, Router } from "express";
import fetch from "node-fetch";
import { BaseClient, Issuer, TokenSet } from "openid-client";
Expand Down Expand Up @@ -232,9 +233,10 @@ router.post("/userinfo/register", async (req, res, next) => {
oauthId: oauthUserId,
user,
};
await mgr
.insert(OAuthAccountEntity, [oauthAccount])
.then((it) => logger.info("New OAuth account created: " + JSON.stringify(oauthAccount)));
await mgr.insert(OAuthAccountEntity, [oauthAccount]).then((it) => {
sendNotificationEmailAboutNewRegistration(oauthAccount.user.username);
logger.info("New OAuth account created: " + JSON.stringify(oauthAccount));
});
})
.then(async () => {
const result: OAuthUserInfoDto = {
Expand Down
2 changes: 1 addition & 1 deletion server/src/routes/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
NewPostRequestDto,
PostRequestDto,
} from "@fumix/fu-blog-common";
import logger from "@server/logger.js";
import logger from "../logger.js";
import express, { NextFunction, Request, Response, Router } from "express";
import { In } from "typeorm";
import { AppDataSource } from "../data-source.js";
Expand Down
62 changes: 62 additions & 0 deletions server/src/service/email-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import console from "console";
import { createTransport } from "nodemailer";
import { AppDataSource } from "../data-source.js";
import { UserEntity } from "../entity/User.entity.js";
import logger from "../logger.js";
import { ClientSettings, EmailSettings } from "../settings.js";

export function sendNotificationEmailAboutNewRegistration(newUsername: string) {
if (!canSendEmail()) {
console.info("Could not send email notification about new user registration to admins. No SMTP server is configured in `.env` file.");
return;
}

AppDataSource.manager
.getRepository(UserEntity)
.createQueryBuilder("find all admins")
.where("roles @> :role", { role: ["ADMIN"] }) // https://www.postgresql.org/docs/16/functions-array.html#ARRAY-OPERATORS-TABLE
.getMany()
.then((users) => {
console.log("Found users", users);
return users.map((user) => user.email);
})
.catch((e) => {
console.error("Failed to get admins", e);
return [];
})
.then((adminEmails) => {
if (adminEmails.length <= 0) {
logger.warn(`There are no admins yet. No notification email is sent about new user '${newUsername}'.`);
} else {
logger.debug(`Sending notification email about registration of new user '${newUsername}' to ${adminEmails.join(", ")}`);

sendEmail(
adminEmails,
"New user registered",
`Hi admins,
a new user '${encodeURIComponent(newUsername)}' registered at ${ClientSettings.BASE_URL} .
Visit ${ClientSettings.BASE_URL}/administration in order to give them some permissions.
Kind regards,
Your fuBlog`,
);
}
});
}

function canSendEmail(): boolean {
return !!(EmailSettings.SMTP_HOST && EmailSettings.SMTP_PORT && EmailSettings.SMTP_FROM);
}

function sendEmail(to: string[], subject: string, text: string) {
createTransport(EmailSettings.SMTP_OPTIONS)
.sendMail({
from: EmailSettings.SMTP_FROM,
to,
subject,
text,
})
.catch(() => {
logger.error("Failed to send email notification!");
});
}
87 changes: 87 additions & 0 deletions server/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AppSettingsDto, asHyperlinkDto, isOAuthType, OAUTH_TYPES, OAuthProvider, OAuthType } from "@fumix/fu-blog-common";
import { createTransport } from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import { LeveledLogMethod } from "winston";
import logger from "./logger.js";
import console from "console";
import { readFileSync } from "fs";
import path, { dirname } from "path";
Expand Down Expand Up @@ -50,6 +54,65 @@ export class DatabaseSettings {
static readonly NAME: string = process.env.DATABASE_NAME ?? "fublog";
}

const securityTypes = ["STARTTLS", "TLS"] as const;
type SmtpSecurity = (typeof securityTypes)[number];

function isSmtpSecurity(s: string): s is SmtpSecurity {
return securityTypes.includes(s as SmtpSecurity);
}

export class EmailSettings {
static readonly SMTP_HOST: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_HOST);
static readonly SMTP_FROM: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_FROM);
static readonly SMTP_USER: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_USER);
static readonly SMTP_PASSWORD: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_PASSWORD);
static readonly SMTP_SECURITY: SmtpSecurity | undefined = toEnumValue(
process.env.EMAIL_SMTP_SECURITY,
(it) => (isSmtpSecurity(it) ? it : undefined),
"TLS",
);
static readonly SMTP_PORT: number = toNumberOrDefault(
process.env.EMAIL_SMTP_PORT,
EmailSettings.SMTP_SECURITY === "STARTTLS" ? 587 : 465,
);

static readonly SMTP_OPTIONS: SMTPTransport.Options = {
host: this.SMTP_HOST,
port: this.SMTP_PORT,
secure: this.SMTP_SECURITY !== "STARTTLS",
requireTLS: AppSettings.IS_PRODUCTION,
from: this.SMTP_FROM,
auth:
this.SMTP_USER && this.SMTP_PASSWORD
? {
user: this.SMTP_USER,
pass: this.SMTP_PASSWORD,
}
: undefined,
} as const;

static {
if (!this.SMTP_HOST || !this.SMTP_FROM) {
logger.warn(" πŸ“­ ❓ No email server and/or email from address specified! The blog won't send any email notifications.");
} else {
if (AppSettings.IS_PRODUCTION && (!this.SMTP_USER || !this.SMTP_PASSWORD)) {
logger.warn(
" πŸ“­ πŸ— No username/password set for sending email! Are you sure, that your SMTP server does not need any authentication?",
);
}
createTransport(this.SMTP_OPTIONS).verify(function (error, success) {
const message = error
? " πŸ“­ ❌ Failed to establish SMTP connection for sending e-mails!"
: " πŸ“¬ βœ… Checked that SMTP connection for sending e-mails can be established";
const log: LeveledLogMethod = error ? logger.error : logger.info;
log(
`${message}: ${EmailSettings.SMTP_HOST}:${EmailSettings.SMTP_PORT} (${EmailSettings.SMTP_SECURITY}) ${EmailSettings.SMTP_OPTIONS.auth ? "with username/password" : "with NO AUTHENTICATION!"}`,
);
});
}
}
}

export class ServerSettings {
static readonly API_PATH: string = process.env.SERVER_API_PATH ?? "/api";
static readonly PORT: number = toNumberOrDefault(process.env.SERVER_PORT, 5000);
Expand Down Expand Up @@ -106,9 +169,33 @@ export class OpenAISettings {
static readonly API_KEY: string | undefined = process.env.OPENAI_API_KEY;
}

function toNonBlankString(value: string | undefined | null): string | undefined {
const trimmed = value?.trim();
if ((trimmed?.length ?? 0) > 0) {
return trimmed;
}
}

function toNumberOrDefault(value: string | undefined | null, defaultValue: number): number {
if (value === undefined || value === null) {
return defaultValue;
}
return Number(value);
}

/**
* Converts a string value to an "enum value" (string union type).
*
* @template T the string union type, can include undefined in case the cast function
* or the default value can yield `undefined`
* @param {string | undefined | null} value - The value to be converted.
* @param {(value: string) => T} cast - The function used to cast the value to the enum type.
* @param {T} defaultValue - The default value to be returned if the input value is undefined or null.
* @return {T} - The converted enum value.
*/
function toEnumValue<T extends string | undefined>(value: string | undefined | null, cast: (v: string) => T, defaultValue: T): T {
if (value === undefined || value === null) {
return defaultValue;
}
return cast(value);
}

0 comments on commit 1cfe9ee

Please sign in to comment.