From f3f2cfff9733f21c165ef0adb41fceeb066ecedf Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 6 Sep 2025 22:08:57 +0100 Subject: [PATCH 01/15] models/user: update to ts, no-verify --- server/models/{user.js => user.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/models/{user.js => user.ts} (100%) diff --git a/server/models/user.js b/server/models/user.ts similarity index 100% rename from server/models/user.js rename to server/models/user.ts From 3ec3f7f87daad261b65dd001651c16942b48e40c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 6 Sep 2025 22:39:21 +0100 Subject: [PATCH 02/15] install b-crypt types, no-verify --- package-lock.json | 14 ++++++++++++++ package.json | 1 + 2 files changed, 15 insertions(+) diff --git a/package-lock.json b/package-lock.json index cfd1a3c504..7ba157a1e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,6 +153,7 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/node": "^16.18.126", @@ -13993,6 +13994,13 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -50416,6 +50424,12 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index 989ef2a75c..d989d79e8d 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/node": "^16.18.126", From a8ce8e7c0141feaee88ca4b5c06dcd26fcaf8f44 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 6 Sep 2025 22:40:39 +0100 Subject: [PATCH 03/15] models/user: add api key type and api key document, no-verify --- server/models/user.ts | 18 ++++++++++++++---- server/tsconfig.json | 2 +- types/apiKey.ts | 5 +++++ types/email.ts | 5 +++++ types/index.ts | 4 ++++ types/timestamp.ts | 4 ++++ 6 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 types/apiKey.ts create mode 100644 types/email.ts create mode 100644 types/index.ts create mode 100644 types/timestamp.ts diff --git a/server/models/user.ts b/server/models/user.ts index b825971747..8568fac937 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -1,5 +1,10 @@ -import mongoose from 'mongoose'; +/* eslint-disable no-underscore-dangle */ +import mongoose, { Document, Schema, Model, Types } from 'mongoose'; import bcrypt from 'bcryptjs'; +import { + ApiKey, + MongooseDocumentTimestamp as DocumentTimestamp +} from '../../types'; const EmailConfirmationStates = { Verified: 'verified', @@ -7,9 +12,14 @@ const EmailConfirmationStates = { Resent: 'resent' }; -const { Schema } = mongoose; +interface ApiKeyDocument + extends ApiKey, + Document, + DocumentTimestamp { + id: string; +} -const apiKeySchema = new Schema( +const apiKeySchema = new Schema( { label: { type: String, default: 'API Key' }, lastUsedAt: { type: Date }, @@ -27,7 +37,7 @@ apiKeySchema.virtual('id').get(function getApiKeyId() { * should never be exposed to the client. So we only return * a safe list of fields when toObject and toJSON are called. */ -function apiKeyMetadata(doc, ret, options) { +function apiKeyMetadata(doc: ApiKeyDocument): Partial { return { id: doc.id, label: doc.label, diff --git a/server/tsconfig.json b/server/tsconfig.json index 3cd303094b..3242826595 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -6,6 +6,6 @@ "lib": ["ES2022"], "types": ["node"], }, - "include": ["./**/*"], + "include": ["./**/*", "../types"], "exclude": ["../node_modules", "../client"] } diff --git a/types/apiKey.ts b/types/apiKey.ts new file mode 100644 index 0000000000..3a83074909 --- /dev/null +++ b/types/apiKey.ts @@ -0,0 +1,5 @@ +export interface ApiKey { + label: string; + lastUsedAt?: Date; + hashedKey: string; +} diff --git a/types/email.ts b/types/email.ts new file mode 100644 index 0000000000..fd36d7cad8 --- /dev/null +++ b/types/email.ts @@ -0,0 +1,5 @@ +export enum EmailConfirmationStates { + Verified = 'verified', + Sent = 'sent', + Resent = 'resent' +} diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000000..385d3d2f6f --- /dev/null +++ b/types/index.ts @@ -0,0 +1,4 @@ +export * from './apiKey'; +export * from './email'; +export * from './timestamp'; +// export * from './user'; diff --git a/types/timestamp.ts b/types/timestamp.ts new file mode 100644 index 0000000000..90c419b7ad --- /dev/null +++ b/types/timestamp.ts @@ -0,0 +1,4 @@ +export interface MongooseDocumentTimestamp { + updatedAt?: Date; + createdAt?: Date; +} From d71d3fa0853cfc974887eacba046a005312a00b7 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 00:12:39 +0100 Subject: [PATCH 04/15] models/user: wip update to create user interface and user document interface, no-verify --- client/tsconfig.json | 2 +- server/models/user.ts | 67 +++++++++++++++++++++------------------- types/index.ts | 5 +-- types/mongoose.ts | 16 ++++++++++ types/timestamp.ts | 4 --- types/user.ts | 23 ++++++++++++++ types/userPreferences.ts | 28 +++++++++++++++++ 7 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 types/mongoose.ts delete mode 100644 types/timestamp.ts create mode 100644 types/user.ts create mode 100644 types/userPreferences.ts diff --git a/client/tsconfig.json b/client/tsconfig.json index 9e63548a60..3c03827e78 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -6,6 +6,6 @@ "lib": ["DOM", "ESNext"], "jsx": "react", }, - "include": ["./**/*"], + "include": ["./**/*", "../types"], "exclude": ["../node_modules", "../server"] } \ No newline at end of file diff --git a/server/models/user.ts b/server/models/user.ts index 8568fac937..fe86df2cb2 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -1,9 +1,11 @@ /* eslint-disable no-underscore-dangle */ -import mongoose, { Document, Schema, Model, Types } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; import bcrypt from 'bcryptjs'; import { ApiKey, - MongooseDocumentTimestamp as DocumentTimestamp + DocumentWithTimestampAndVirtualId, + User, + CookieConsent } from '../../types'; const EmailConfirmationStates = { @@ -12,12 +14,9 @@ const EmailConfirmationStates = { Resent: 'resent' }; -interface ApiKeyDocument +export interface ApiKeyDocument extends ApiKey, - Document, - DocumentTimestamp { - id: string; -} + DocumentWithTimestampAndVirtualId {} const apiKeySchema = new Schema( { @@ -55,7 +54,9 @@ apiKeySchema.set('toJSON', { transform: apiKeyMetadata }); -const userSchema = new Schema( +export interface UserDocument extends User, DocumentWithTimestampAndVirtualId {} + +const userSchema = new Schema( { name: { type: String, default: '' }, username: { type: String, required: true, unique: true }, @@ -89,13 +90,13 @@ const userSchema = new Schema( totalSize: { type: Number, default: 0 }, cookieConsent: { type: String, - enum: ['none', 'essential', 'all'], - default: 'none' + enum: Object.values(CookieConsent), + default: CookieConsent.NONE }, banned: { type: Boolean, default: false }, lastLoginTimestamp: { type: Date } }, - { timestamps: true, usePushEach: true } + { timestamps: true } ); /** @@ -112,6 +113,10 @@ userSchema.pre('save', function checkPassword(next) { next(err); return; } + if (!user.password) { + next(new Error('Password is missing')); + return; + } bcrypt.hash(user.password, salt, (innerErr, hash) => { if (innerErr) { next(innerErr); @@ -137,7 +142,7 @@ userSchema.pre('save', function checkApiKey(next) { let pendingTasks = 0; let nextCalled = false; - const done = (err) => { + const done = (err?: mongoose.CallbackError) => { if (nextCalled) return; if (err) { nextCalled = true; @@ -189,7 +194,7 @@ userSchema.set('toJSON', { * @return {Promise} */ userSchema.methods.comparePassword = async function comparePassword( - candidatePassword + candidatePassword: string ) { if (!this.password) { return false; @@ -207,8 +212,8 @@ userSchema.methods.comparePassword = async function comparePassword( * Helper method for validating a user's api key */ userSchema.methods.findMatchingKey = async function findMatchingKey( - candidateKey -) { + candidateKey: string +): Promise<{ isMatch: boolean; keyDocument: UserDocument | null }> { let keyObj = { isMatch: false, keyDocument: null }; /* eslint-disable no-restricted-syntax */ for (const k of this.apiKeys) { @@ -237,7 +242,9 @@ userSchema.methods.findMatchingKey = async function findMatchingKey( * @callback [cb] - Optional error-first callback that passes User document * @return {Object} - Returns User Object fulfilled by User document */ -userSchema.statics.findByEmail = async function findByEmail(email) { +userSchema.statics.findByEmail = async function findByEmail( + email: string | string[] +): Promise { const user = this; const query = Array.isArray(email) ? { email: { $in: email } } : { email }; @@ -257,7 +264,9 @@ userSchema.statics.findByEmail = async function findByEmail(email) { * @param {string[]} emails - Array of email strings * @return {Promise} - Returns Promise fulfilled by User document */ -userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { +userSchema.statics.findAllByEmails = async function findAllByEmails( + emails: string[] +): Promise { const user = this; const query = { email: { $in: emails } @@ -281,19 +290,15 @@ userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByUsername = async function findByUsername( - username, - options -) { + username: string, + options: { caseInsensitive: boolean } +): Promise { const user = this; const query = { username }; - if ( - arguments.length === 2 && - typeof options === 'object' && - options.caseInsensitive - ) { + if (options?.caseInsensitive) { const foundUser = await user .findOne(query) .collation({ locale: 'en', strength: 2 }) @@ -320,9 +325,9 @@ userSchema.statics.findByUsername = async function findByUsername( * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( - value, - options -) { + value: string, + options: { caseInsensitive: boolean; valueType: 'email' | 'username' } +): Promise { const user = this; const isEmail = options && options.valueType @@ -362,9 +367,9 @@ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsername( - email, - username -) { + email: string, + username: string +): Promise { const user = this; const query = { $or: [{ email }, { username }] diff --git a/types/index.ts b/types/index.ts index 385d3d2f6f..6efd8a00fb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,4 +1,5 @@ export * from './apiKey'; export * from './email'; -export * from './timestamp'; -// export * from './user'; +export * from './mongoose'; +export * from './user'; +export * from './userPreferences'; diff --git a/types/mongoose.ts b/types/mongoose.ts new file mode 100644 index 0000000000..0cc579c97a --- /dev/null +++ b/types/mongoose.ts @@ -0,0 +1,16 @@ +import mongoose, { Document, Schema, Model, Types } from 'mongoose'; + +export interface DocumentTimestamp { + updatedAt?: Date; + createdAt?: Date; +} + +export interface VirtualId { + id: string; +} + +/** Mongoose document with virtual id and timestamps enabled */ +export interface DocumentWithTimestampAndVirtualId + extends Omit, 'id'>, + DocumentTimestamp, + VirtualId {} diff --git a/types/timestamp.ts b/types/timestamp.ts deleted file mode 100644 index 90c419b7ad..0000000000 --- a/types/timestamp.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface MongooseDocumentTimestamp { - updatedAt?: Date; - createdAt?: Date; -} diff --git a/types/user.ts b/types/user.ts new file mode 100644 index 0000000000..52096fa387 --- /dev/null +++ b/types/user.ts @@ -0,0 +1,23 @@ +import { UserPreferences, CookieConsent } from './userPreferences'; +import { ApiKeyDocument } from '../server/models/user'; + +export interface User { + name: string; + username: string; + password?: string; + resetPasswordToken?: string; + resetPasswordExpires?: Date; + verified?: string; + verifiedToken?: string; + verifiedTokenExpires?: Date; + github?: string; + google?: string; + email?: string; + tokens?: any[]; + apiKeys: ApiKeyDocument[]; + preferences: UserPreferences; + totalSize: number; + cookieConsent: CookieConsent; + banned: boolean; + lastLoginTimestamp?: Date; +} diff --git a/types/userPreferences.ts b/types/userPreferences.ts new file mode 100644 index 0000000000..b5b9c5829b --- /dev/null +++ b/types/userPreferences.ts @@ -0,0 +1,28 @@ +export enum AppTheme { + LIGHT = 'light', + DARK = 'dark', + CONTRAST = 'contrast' +} + +export interface UserPreferences { + fontSize: number; + lineNumbers: boolean; + indentationAmount: number; + isTabIndent: boolean; + autosave: boolean; + linewrap: boolean; + lintWarning: boolean; + textOutput: boolean; + gridOutput: boolean; + theme: AppTheme; + autorefresh: boolean; + language: string; + autocloseBracketsQuotes: boolean; + autocompleteHinter: boolean; +} + +export enum CookieConsent { + NONE = 'none', + ESSENTIAL = 'essential', + ALL = 'all' +} From 88e33a9d5287817ffe75afe02cfa69613021f211 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 00:13:15 +0100 Subject: [PATCH 05/15] clean up mongoose type file imports --- types/mongoose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/mongoose.ts b/types/mongoose.ts index 0cc579c97a..788ad3d6ba 100644 --- a/types/mongoose.ts +++ b/types/mongoose.ts @@ -1,4 +1,4 @@ -import mongoose, { Document, Schema, Model, Types } from 'mongoose'; +import { Document, Types } from 'mongoose'; export interface DocumentTimestamp { updatedAt?: Date; From fc9fbf2cf4ea9eb072f153eea72f0f3e62320aa2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 01:20:37 +0100 Subject: [PATCH 06/15] model/users: temporarily remove EmailConfirmation. Move type definitions to server/types --- server/models/user.ts | 35 ++++++---------- server/types/apiKey.ts | 11 +++++ {types => server/types}/email.ts | 0 server/types/index.ts | 5 +++ {types => server/types}/mongoose.ts | 0 server/types/user.ts | 48 ++++++++++++++++++++++ {types => server/types}/userPreferences.ts | 6 +-- types/apiKey.ts | 5 --- types/index.ts | 6 +-- types/user.ts | 23 ----------- 10 files changed, 80 insertions(+), 59 deletions(-) create mode 100644 server/types/apiKey.ts rename {types => server/types}/email.ts (100%) create mode 100644 server/types/index.ts rename {types => server/types}/mongoose.ts (100%) create mode 100644 server/types/user.ts rename {types => server/types}/userPreferences.ts (84%) delete mode 100644 types/apiKey.ts delete mode 100644 types/user.ts diff --git a/server/models/user.ts b/server/models/user.ts index fe86df2cb2..781e9dad74 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -1,22 +1,13 @@ /* eslint-disable no-underscore-dangle */ -import mongoose, { Schema } from 'mongoose'; +import mongoose, { Schema, Model } from 'mongoose'; import bcrypt from 'bcryptjs'; import { - ApiKey, - DocumentWithTimestampAndVirtualId, - User, - CookieConsent -} from '../../types'; - -const EmailConfirmationStates = { - Verified: 'verified', - Sent: 'sent', - Resent: 'resent' -}; - -export interface ApiKeyDocument - extends ApiKey, - DocumentWithTimestampAndVirtualId {} + ApiKeyDocument, + UserDocument, + UserModel, + EmailConfirmationStates, + CookieConsentOptions +} from '../types'; const apiKeySchema = new Schema( { @@ -54,9 +45,7 @@ apiKeySchema.set('toJSON', { transform: apiKeyMetadata }); -export interface UserDocument extends User, DocumentWithTimestampAndVirtualId {} - -const userSchema = new Schema( +const userSchema = new Schema( { name: { type: String, default: '' }, username: { type: String, required: true, unique: true }, @@ -90,8 +79,8 @@ const userSchema = new Schema( totalSize: { type: Number, default: 0 }, cookieConsent: { type: String, - enum: Object.values(CookieConsent), - default: CookieConsent.NONE + enum: Object.values(CookieConsentOptions), + default: CookieConsentOptions.NONE }, banned: { type: Boolean, default: false }, lastLoginTimestamp: { type: Date } @@ -244,7 +233,7 @@ userSchema.methods.findMatchingKey = async function findMatchingKey( */ userSchema.statics.findByEmail = async function findByEmail( email: string | string[] -): Promise { +): Promise { const user = this; const query = Array.isArray(email) ? { email: { $in: email } } : { email }; @@ -382,7 +371,7 @@ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsernam return foundUser; }; -userSchema.statics.EmailConfirmation = EmailConfirmationStates; +// userSchema.statics.EmailConfirmation = EmailConfirmationStates; userSchema.index({ username: 1 }, { collation: { locale: 'en', strength: 2 } }); userSchema.index({ email: 1 }, { collation: { locale: 'en', strength: 2 } }); diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts new file mode 100644 index 0000000000..7703f7e010 --- /dev/null +++ b/server/types/apiKey.ts @@ -0,0 +1,11 @@ +import { DocumentWithTimestampAndVirtualId } from './mongoose'; + +export interface ApiKey { + label: string; + lastUsedAt?: Date; + hashedKey: string; +} + +export interface ApiKeyDocument + extends ApiKey, + DocumentWithTimestampAndVirtualId {} diff --git a/types/email.ts b/server/types/email.ts similarity index 100% rename from types/email.ts rename to server/types/email.ts diff --git a/server/types/index.ts b/server/types/index.ts new file mode 100644 index 0000000000..6efd8a00fb --- /dev/null +++ b/server/types/index.ts @@ -0,0 +1,5 @@ +export * from './apiKey'; +export * from './email'; +export * from './mongoose'; +export * from './user'; +export * from './userPreferences'; diff --git a/types/mongoose.ts b/server/types/mongoose.ts similarity index 100% rename from types/mongoose.ts rename to server/types/mongoose.ts diff --git a/server/types/user.ts b/server/types/user.ts new file mode 100644 index 0000000000..7364754f53 --- /dev/null +++ b/server/types/user.ts @@ -0,0 +1,48 @@ +import { Model } from 'mongoose'; +import { UserPreferences, CookieConsentOptions } from './userPreferences'; +import { EmailConfirmationStates } from './email'; +import { ApiKeyDocument } from './apiKey'; +import { DocumentWithTimestampAndVirtualId } from './mongoose'; + +export interface UserInterface { + name: string; + username: string; + password?: string; + resetPasswordToken?: string; + resetPasswordExpires?: Date; + verified?: string; + verifiedToken?: string; + verifiedTokenExpires?: Date; + github?: string; + google?: string; + email?: string; + tokens?: any[]; + apiKeys: ApiKeyDocument[]; + preferences: UserPreferences; + totalSize: number; + cookieConsent: CookieConsentOptions; + banned: boolean; + lastLoginTimestamp?: Date; +} + +export interface UserDocument + extends UserInterface, + DocumentWithTimestampAndVirtualId {} + +export interface UserModel extends Model { + findByEmail(email: string | string[]): Promise; + findAllByEmails(emails: string[]): Promise; + findByUsername( + username: string, + options?: { caseInsensitive: boolean } + ): Promise; + findByEmailOrUsername( + value: string, + options?: { caseInsensitive: boolean; valueType: 'email' | 'username' } + ): Promise; + findByEmailAndUsername( + email: string, + username: string + ): Promise; + EmailConfirmation: EmailConfirmationStates; +} diff --git a/types/userPreferences.ts b/server/types/userPreferences.ts similarity index 84% rename from types/userPreferences.ts rename to server/types/userPreferences.ts index b5b9c5829b..20fe3b116f 100644 --- a/types/userPreferences.ts +++ b/server/types/userPreferences.ts @@ -1,4 +1,4 @@ -export enum AppTheme { +export enum AppThemeOptions { LIGHT = 'light', DARK = 'dark', CONTRAST = 'contrast' @@ -14,14 +14,14 @@ export interface UserPreferences { lintWarning: boolean; textOutput: boolean; gridOutput: boolean; - theme: AppTheme; + theme: AppThemeOptions; autorefresh: boolean; language: string; autocloseBracketsQuotes: boolean; autocompleteHinter: boolean; } -export enum CookieConsent { +export enum CookieConsentOptions { NONE = 'none', ESSENTIAL = 'essential', ALL = 'all' diff --git a/types/apiKey.ts b/types/apiKey.ts deleted file mode 100644 index 3a83074909..0000000000 --- a/types/apiKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ApiKey { - label: string; - lastUsedAt?: Date; - hashedKey: string; -} diff --git a/types/index.ts b/types/index.ts index 6efd8a00fb..458c5808f2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1 @@ -export * from './apiKey'; -export * from './email'; -export * from './mongoose'; -export * from './user'; -export * from './userPreferences'; +export * from '../server/types'; diff --git a/types/user.ts b/types/user.ts deleted file mode 100644 index 52096fa387..0000000000 --- a/types/user.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { UserPreferences, CookieConsent } from './userPreferences'; -import { ApiKeyDocument } from '../server/models/user'; - -export interface User { - name: string; - username: string; - password?: string; - resetPasswordToken?: string; - resetPasswordExpires?: Date; - verified?: string; - verifiedToken?: string; - verifiedTokenExpires?: Date; - github?: string; - google?: string; - email?: string; - tokens?: any[]; - apiKeys: ApiKeyDocument[]; - preferences: UserPreferences; - totalSize: number; - cookieConsent: CookieConsent; - banned: boolean; - lastLoginTimestamp?: Date; -} From b20b8acd5a85fa8d697d2e65f04263b6fb77af0d Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 01:21:35 +0100 Subject: [PATCH 07/15] model/users: update to named export --- server/config/passport.js | 5 +- server/controllers/aws.controller.js | 2 +- .../collectionForUserExists.js | 2 +- .../collection.controller/listCollections.js | 2 +- server/controllers/project.controller.js | 2 +- .../__test__/deleteProject.test.js | 2 +- .../__test__/getProjectsForUser.test.js | 2 +- .../project.controller/getProjectsForUser.js | 2 +- server/controllers/user.controller.js | 2 +- .../user.controller/__tests__/apiKey.test.js | 2 +- server/controllers/user.controller/apiKey.js | 2 +- server/migrations/db_reformat.js | 55 ++++---- server/migrations/emailConsolidation.js | 5 +- server/migrations/moveBucket.js | 100 ++++++++------ server/migrations/populateTotalSize.js | 41 +++--- server/migrations/s3UnderUser.js | 2 +- server/models/project.js | 2 +- server/models/user.ts | 6 +- server/scripts/examples-gg-latest.js | 130 ++++++++++-------- server/scripts/examples.js | 2 +- server/views/404Page.js | 5 +- 21 files changed, 214 insertions(+), 159 deletions(-) diff --git a/server/config/passport.js b/server/config/passport.js index 4a6f0c461f..cd1b057173 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -8,7 +8,7 @@ import LocalStrategy from 'passport-local'; import GoogleStrategy from 'passport-google-oauth20'; import { BasicStrategy } from 'passport-http'; -import User from '../models/user'; +import { User } from '../models/user'; const accountSuspensionMessage = 'Account has been suspended. Please contact privacy@p5js.org if you believe this is an error.'; @@ -61,7 +61,8 @@ passport.use( await user.save(); return done(null, user); - } else { // eslint-disable-line + } else { + // eslint-disable-line return done(null, false, { msg: 'Invalid email or password' }); } } catch (err) { diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index c5d6daed03..286c57cc08 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -9,7 +9,7 @@ import { } from '@aws-sdk/client-s3'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const { ObjectId } = mongoose.Types; diff --git a/server/controllers/collection.controller/collectionForUserExists.js b/server/controllers/collection.controller/collectionForUserExists.js index 8b6bb05f87..3eb75c445d 100644 --- a/server/controllers/collection.controller/collectionForUserExists.js +++ b/server/controllers/collection.controller/collectionForUserExists.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * @param {string} username diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js index 055a22ac4f..e1ee11d28f 100644 --- a/server/controllers/collection.controller/listCollections.js +++ b/server/controllers/collection.controller/listCollections.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; async function getOwnerUserId(req) { if (req.params.username) { diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index 18611eafb5..397173083a 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -7,7 +7,7 @@ import isAfter from 'date-fns/isAfter'; import axios from 'axios'; import slugify from 'slugify'; import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import { resolvePathToFile } from '../utils/filePath'; import { generateFileSystemSafeName } from '../utils/generateFileSystemSafeName'; diff --git a/server/controllers/project.controller/__test__/deleteProject.test.js b/server/controllers/project.controller/__test__/deleteProject.test.js index 54d45a6d55..05ee2bb64b 100644 --- a/server/controllers/project.controller/__test__/deleteProject.test.js +++ b/server/controllers/project.controller/__test__/deleteProject.test.js @@ -4,7 +4,7 @@ import { Request, Response } from 'jest-express'; import Project from '../../../models/project'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import deleteProject from '../deleteProject'; import { deleteObjectsFromS3 } from '../../aws.controller'; diff --git a/server/controllers/project.controller/__test__/getProjectsForUser.test.js b/server/controllers/project.controller/__test__/getProjectsForUser.test.js index 9b7b8e128f..d612c0a054 100644 --- a/server/controllers/project.controller/__test__/getProjectsForUser.test.js +++ b/server/controllers/project.controller/__test__/getProjectsForUser.test.js @@ -3,7 +3,7 @@ */ import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import getProjectsForUser, { apiGetProjectsForUser } from '../getProjectsForUser'; diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index 072ae3b50b..21eafc6370 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -1,5 +1,5 @@ import Project from '../../models/project'; -import User from '../../models/user'; +import { User } from '../../models/user'; import { toApi as toApiProjectObject } from '../../domain-objects/Project'; /** diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index da5f4c615c..1ecf15c13e 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import User from '../models/user'; +import { User } from '../models/user'; import mail from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index 49d23697cb..87dc1320e8 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -3,7 +3,7 @@ import { last } from 'lodash'; import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.js index e826810a08..d614a27324 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * Generates a unique token to be used as a Personal Access Token diff --git a/server/migrations/db_reformat.js b/server/migrations/db_reformat.js index 50284c1f74..0d4ad8f8e7 100644 --- a/server/migrations/db_reformat.js +++ b/server/migrations/db_reformat.js @@ -2,16 +2,18 @@ import mongoose from 'mongoose'; import path from 'path'; import { uniqWith, isEqual } from 'lodash'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); const ObjectId = mongoose.Types.ObjectId; mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import s3 from '@auth0/s3'; @@ -19,36 +21,35 @@ let client = s3.createClient({ maxAsyncS3: 20, s3RetryCount: 3, s3RetryDelay: 1000, - multipartUploadThreshold: 20971520, // this is the default (20 MB) - multipartUploadSize: 15728640, // this is the default (15 MB) + multipartUploadThreshold: 20971520, // this is the default (20 MB) + multipartUploadSize: 15728640, // this is the default (15 MB) s3Options: { accessKeyId: `${process.env.AWS_ACCESS_KEY}`, secretAccessKey: `${process.env.AWS_SECRET_KEY}`, region: `${process.env.AWS_REGION}` - }, + } }); let s3Files = []; -Project.find({}) - .exec((err, projects) => { - projects.forEach((project, projectIndex) => { - project.files.forEach((file, fileIndex) => { - if (file.url && !file.url.includes("https://rawgit.com/")) { - s3Files.push(file.url.split('/').pop()); - } - }); +Project.find({}).exec((err, projects) => { + projects.forEach((project, projectIndex) => { + project.files.forEach((file, fileIndex) => { + if (file.url && !file.url.includes('https://rawgit.com/')) { + s3Files.push(file.url.split('/').pop()); + } }); - console.log(s3Files.length); - s3Files = uniqWith(s3Files, isEqual); - console.log(s3Files.length); }); + console.log(s3Files.length); + s3Files = uniqWith(s3Files, isEqual); + console.log(s3Files.length); +}); const uploadedFiles = []; -const params = {'s3Params': {'Bucket': `${process.env.S3_BUCKET}`}}; +const params = { s3Params: { Bucket: `${process.env.S3_BUCKET}` } }; let objectsResponse = client.listObjects(params); -objectsResponse.on('data', function(objects) { - objects.Contents.forEach(object => { +objectsResponse.on('data', function (objects) { + objects.Contents.forEach((object) => { uploadedFiles.push(object.Key); }); }); @@ -56,18 +57,18 @@ objectsResponse.on('data', function(objects) { const filesToDelete = []; objectsResponse.on('end', () => { console.log(uploadedFiles.length); - uploadedFiles.forEach(fileKey => { + uploadedFiles.forEach((fileKey) => { if (s3Files.indexOf(fileKey) === -1) { //delete file - filesToDelete.push({Key: fileKey}); + filesToDelete.push({ Key: fileKey }); // console.log("would delete file: ", fileKey); } }); let params = { Bucket: `${process.env.S3_BUCKET}`, Delete: { - Objects: filesToDelete, - }, + Objects: filesToDelete + } }; // let del = client.deleteObjects(params); // del.on('err', (err) => { @@ -76,9 +77,9 @@ objectsResponse.on('end', () => { // del.on('end', () => { // console.log('deleted extra S3 files!'); // }); - console.log("To delete: ", filesToDelete.length); - console.log("Total S3 files: ", uploadedFiles.length); - console.log("Total S3 files in mongo: ", s3Files.length); + console.log('To delete: ', filesToDelete.length); + console.log('Total S3 files: ', uploadedFiles.length); + console.log('Total S3 files in mongo: ', s3Files.length); }); // let projectsNotToUpdate; diff --git a/server/migrations/emailConsolidation.js b/server/migrations/emailConsolidation.js index 01af84943a..19a141eee5 100644 --- a/server/migrations/emailConsolidation.js +++ b/server/migrations/emailConsolidation.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import fs from 'fs'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import Collection from '../models/collection'; import { @@ -31,7 +31,8 @@ mongoose.connection.on('error', () => { * https://mongodb.github.io/node-mongodb-native */ -const agg = [ // eslint-disable-line +const agg = [ + // eslint-disable-line { $project: { email: { diff --git a/server/migrations/moveBucket.js b/server/migrations/moveBucket.js index fa833e3845..10ef1a38f4 100644 --- a/server/migrations/moveBucket.js +++ b/server/migrations/moveBucket.js @@ -2,13 +2,15 @@ import s3 from '@auth0/s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); @@ -16,8 +18,8 @@ mongoose.connection.on('error', () => { // maxAsyncS3: 20, // s3RetryCount: 3, // s3RetryDelay: 1000, -// multipartUploadThreshold: 20971520, // this is the default (20 MB) -// multipartUploadSize: 15728640, // this is the default (15 MB) +// multipartUploadThreshold: 20971520, // this is the default (20 MB) +// multipartUploadSize: 15728640, // this is the default (15 MB) // s3Options: { // accessKeyId: `${process.env.AWS_ACCESS_KEY}`, // secretAccessKey: `${process.env.AWS_SECRET_KEY}`, @@ -27,39 +29,57 @@ mongoose.connection.on('error', () => { const CHUNK = 100; Project.count({}) -.exec().then((numProjects) => { - console.log(numProjects); - let index = 0; - async.whilst( - () => { - return index < numProjects; - }, - (whilstCb) => { - Project.find({}).skip(index).limit(CHUNK).exec((err, projects) => { - async.eachSeries(projects, (project, cb) => { - console.log(project.name); - async.eachSeries(project.files, (file, fileCb) => { - if (file.url && file.url.includes('s3-us-west-2.amazonaws.com/')) { - file.url = file.url.replace('s3-us-west-2.amazonaws.com/', ''); - project.save((err, newProject) => { - console.log(`updated file ${file.url}`); - fileCb(); - }); - } else { - fileCb(); - } - }, () => { - cb(); + .exec() + .then((numProjects) => { + console.log(numProjects); + let index = 0; + async.whilst( + () => { + return index < numProjects; + }, + (whilstCb) => { + Project.find({}) + .skip(index) + .limit(CHUNK) + .exec((err, projects) => { + async.eachSeries( + projects, + (project, cb) => { + console.log(project.name); + async.eachSeries( + project.files, + (file, fileCb) => { + if ( + file.url && + file.url.includes('s3-us-west-2.amazonaws.com/') + ) { + file.url = file.url.replace( + 's3-us-west-2.amazonaws.com/', + '' + ); + project.save((err, newProject) => { + console.log(`updated file ${file.url}`); + fileCb(); + }); + } else { + fileCb(); + } + }, + () => { + cb(); + } + ); + }, + () => { + index += CHUNK; + whilstCb(); + } + ); }); - }, () => { - index += CHUNK; - whilstCb(); - }); - }); - }, - () => { - console.log('finished processing all documents.') - process.exit(0); - } - ); -}); + }, + () => { + console.log('finished processing all documents.'); + process.exit(0); + } + ); + }); diff --git a/server/migrations/populateTotalSize.js b/server/migrations/populateTotalSize.js index 19e9f62345..370843e7c4 100644 --- a/server/migrations/populateTotalSize.js +++ b/server/migrations/populateTotalSize.js @@ -1,29 +1,38 @@ /* eslint-disable */ import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import { listObjectsInS3ForUser } from '../controllers/aws.controller'; // Connect to MongoDB mongoose.Promise = global.Promise; mongoose.connect(process.env.MONGO_URL, { useMongoClient: true }); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); -User.find({}, {}, { timeout: true }).cursor().eachAsync((user) => { - console.log(user.id); - if (user.totalSize !== undefined) { - console.log('Already updated size for user: ' + user.username); - return Promise.resolve(); - } - return listObjectsInS3ForUser(user.id).then((objects) => { - return User.findByIdAndUpdate(user.id, { $set: { totalSize: objects.totalSize } }); - }).then(() => { - console.log('Updated new total size for user: ' + user.username); +User.find({}, {}, { timeout: true }) + .cursor() + .eachAsync((user) => { + console.log(user.id); + if (user.totalSize !== undefined) { + console.log('Already updated size for user: ' + user.username); + return Promise.resolve(); + } + return listObjectsInS3ForUser(user.id) + .then((objects) => { + return User.findByIdAndUpdate(user.id, { + $set: { totalSize: objects.totalSize } + }); + }) + .then(() => { + console.log('Updated new total size for user: ' + user.username); + }); + }) + .then(() => { + console.log('Done iterating over every user'); + process.exit(0); }); -}).then(() => { - console.log('Done iterating over every user'); - process.exit(0); -}); \ No newline at end of file diff --git a/server/migrations/s3UnderUser.js b/server/migrations/s3UnderUser.js index 0657b0edc3..55254d7cf7 100644 --- a/server/migrations/s3UnderUser.js +++ b/server/migrations/s3UnderUser.js @@ -6,7 +6,7 @@ import { } from '@aws-sdk/client-s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; import dotenv from 'dotenv'; diff --git a/server/models/project.js b/server/models/project.js index 1cbcc78483..182b9fe16d 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -3,7 +3,7 @@ import shortid from 'shortid'; import slugify from 'slugify'; // Register User model as it's referenced by Project -import './user'; +import { User } from './user'; const { Schema } = mongoose; diff --git a/server/models/user.ts b/server/models/user.ts index 781e9dad74..5c6ca46430 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -376,4 +376,8 @@ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsernam userSchema.index({ username: 1 }, { collation: { locale: 'en', strength: 2 } }); userSchema.index({ email: 1 }, { collation: { locale: 'en', strength: 2 } }); -export default mongoose.models.User || mongoose.model('User', userSchema); +export const User = + (mongoose.models.User as UserModel) || + mongoose.model('User', userSchema); + +// User.EmailConfirmation = EmailConfirmationStates; diff --git a/server/scripts/examples-gg-latest.js b/server/scripts/examples-gg-latest.js index 422bdb4cbe..f9b9453677 100644 --- a/server/scripts/examples-gg-latest.js +++ b/server/scripts/examples-gg-latest.js @@ -5,7 +5,7 @@ import objectID from 'bson-objectid'; import shortid from 'shortid'; import eachSeries from 'async/eachSeries'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; // TODO: change to true when testing! @@ -197,23 +197,31 @@ function getSketchItems(sketchList) { // const completeSketchPkg = []; /* eslint-disable */ - return Q.all(sketchList[0].map(async sketch => Q.all(sketch.tree.map((item) => { - if (item.name === 'data') { - const options = { - url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - const { data } = axios.request(options); - sketch.data = data; - return sketch; - } - // pass - })))).then(() => sketchList[0]); + return Q.all( + sketchList[0].map(async (sketch) => + Q.all( + sketch.tree.map((item) => { + if (item.name === 'data') { + const options = { + url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + const { data } = axios.request(options); + sketch.data = data; + return sketch; + } + // pass + }) + ) + ) + ).then(() => sketchList[0]); /* eslint-enable */ } @@ -378,8 +386,11 @@ function formatAllSketches(sketchList) { // get all the sketch data content and download to the newProjects array function getAllSketchContent(newProjectList) { /* eslint-disable */ - return Q.all(newProjectList.map(newProject => Q.all(newProject.files.map(async (sketchFile, i) => { - /* + return Q.all( + newProjectList.map((newProject) => + Q.all( + newProject.files.map(async (sketchFile, i) => { + /* sketchFile.name.endsWith(".mp4") !== true && sketchFile.name.endsWith(".ogg") !== true && sketchFile.name.endsWith(".otf") !== true && @@ -390,42 +401,49 @@ function getAllSketchContent(newProjectList) { sketchFile.name.endsWith(".svg") !== true */ - if (sketchFile.fileType === 'file' && - sketchFile.content != null && - sketchFile.name.endsWith('.html') !== true && - sketchFile.name.endsWith('.css') !== true && - sketchFile.name.endsWith('.js') === true - ) { - const options = { - url: newProject.files[i].content, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - // console.log("CONVERT ME!") - const { data } = await axios.request(options); - newProject.files[i].content = data; - return newProject; - } - if (newProject.files[i].url) { - return new Promise((resolve, reject) => { - // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", - // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png - // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; - const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${newProject.files[i].url.split(branchName)[1]}` - // console.log("🌈🌈🌈🌈🌈", sketchFile.name); - // console.log("🌈🌈🌈🌈🌈", cdnRef); - sketchFile.content = cdnRef; - sketchFile.url = cdnRef; - // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); - resolve(newProject); - }); - } - })) - )).then(() => newProjectList); + if ( + sketchFile.fileType === 'file' && + sketchFile.content != null && + sketchFile.name.endsWith('.html') !== true && + sketchFile.name.endsWith('.css') !== true && + sketchFile.name.endsWith('.js') === true + ) { + const options = { + url: newProject.files[i].content, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + // console.log("CONVERT ME!") + const { data } = await axios.request(options); + newProject.files[i].content = data; + return newProject; + } + if (newProject.files[i].url) { + return new Promise((resolve, reject) => { + // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", + // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png + // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; + const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${ + newProject.files[i].url.split(branchName)[1] + }`; + // console.log("🌈🌈🌈🌈🌈", sketchFile.name); + // console.log("🌈🌈🌈🌈🌈", cdnRef); + sketchFile.content = cdnRef; + sketchFile.url = cdnRef; + // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); + resolve(newProject); + }); + } + }) + ) + ) + ).then(() => newProjectList); /* eslint-enable */ } diff --git a/server/scripts/examples.js b/server/scripts/examples.js index 39e974a873..9ea067e845 100644 --- a/server/scripts/examples.js +++ b/server/scripts/examples.js @@ -4,7 +4,7 @@ import mongoose from 'mongoose'; import objectID from 'bson-objectid'; import shortid from 'shortid'; import { defaultCSS, defaultHTML } from '../domain-objects/createDefaultFiles'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const clientId = process.env.GITHUB_ID; diff --git a/server/views/404Page.js b/server/views/404Page.js index 7c864f4009..50f40ea206 100644 --- a/server/views/404Page.js +++ b/server/views/404Page.js @@ -1,9 +1,10 @@ -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const insertErrorMessage = (htmlFile) => { const html = htmlFile.split(''); - const metaDescription = 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line + const metaDescription = + 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line html[0] = ` ${html[0]} From 9610411e7a50ea72aaca28eb48cb4a27e33edf78 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 02:32:34 +0100 Subject: [PATCH 08/15] controllers/User: update to ts, no-verify --- server/controllers/{user.controller.js => user.controller.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/controllers/{user.controller.js => user.controller.ts} (100%) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.ts similarity index 100% rename from server/controllers/user.controller.js rename to server/controllers/user.controller.ts From eec419a8f67c04ce333c455ef81cae3c1977257e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 15:13:59 +0100 Subject: [PATCH 09/15] turn off no-underscore-dangle rule -- used in mongoose ._id --- .eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index 137d893825..22dddfeffb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -135,7 +135,7 @@ "import/no-extraneous-dependencies": "off", "no-unused-vars": "off", "import/no-default-export": "warn", - "no-underscore-dangle": "warn", + "no-underscore-dangle": "off", "react/require-default-props": "off", "no-shadow": "off", "@typescript-eslint/no-shadow": "error" From 562c75ca0264ac9755ffcc87ceb7ce1cdd8b29e9 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 15:16:14 +0100 Subject: [PATCH 10/15] models/user: fix emailConfirmationStates --- server/models/user.ts | 4 ---- server/types/user.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/server/models/user.ts b/server/models/user.ts index 5c6ca46430..47cbe1b422 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -371,13 +371,9 @@ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsernam return foundUser; }; -// userSchema.statics.EmailConfirmation = EmailConfirmationStates; - userSchema.index({ username: 1 }, { collation: { locale: 'en', strength: 2 } }); userSchema.index({ email: 1 }, { collation: { locale: 'en', strength: 2 } }); export const User = (mongoose.models.User as UserModel) || mongoose.model('User', userSchema); - -// User.EmailConfirmation = EmailConfirmationStates; diff --git a/server/types/user.ts b/server/types/user.ts index 7364754f53..a48ae20971 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -44,5 +44,5 @@ export interface UserModel extends Model { email: string, username: string ): Promise; - EmailConfirmation: EmailConfirmationStates; + EmailConfirmation: typeof EmailConfirmationStates; } From cf881fd922f6eddf0e7b527bd9e0a696051a9793 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 20:57:57 +0100 Subject: [PATCH 11/15] models/user: update interface to include comparePassword and findMatchingKey methods, add PublicUserDocument interface --- server/models/user.ts | 3 +-- server/types/mongoose.ts | 4 ++-- server/types/user.ts | 33 +++++++++++++++++++++++++++------ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/server/models/user.ts b/server/models/user.ts index 47cbe1b422..5b8d4735f4 100644 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -1,11 +1,10 @@ /* eslint-disable no-underscore-dangle */ -import mongoose, { Schema, Model } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; import bcrypt from 'bcryptjs'; import { ApiKeyDocument, UserDocument, UserModel, - EmailConfirmationStates, CookieConsentOptions } from '../types'; diff --git a/server/types/mongoose.ts b/server/types/mongoose.ts index 788ad3d6ba..eba38c599e 100644 --- a/server/types/mongoose.ts +++ b/server/types/mongoose.ts @@ -1,4 +1,4 @@ -import { Document, Types } from 'mongoose'; +import mongoose, { Document, Types } from 'mongoose'; export interface DocumentTimestamp { updatedAt?: Date; @@ -6,7 +6,7 @@ export interface DocumentTimestamp { } export interface VirtualId { - id: string; + id: string | mongoose.ObjectId; } /** Mongoose document with virtual id and timestamps enabled */ diff --git a/server/types/user.ts b/server/types/user.ts index a48ae20971..86a0eaa713 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -9,14 +9,14 @@ export interface UserInterface { username: string; password?: string; resetPasswordToken?: string; - resetPasswordExpires?: Date; + resetPasswordExpires?: number; verified?: string; - verifiedToken?: string; - verifiedTokenExpires?: Date; + verifiedToken?: string | null; + verifiedTokenExpires?: number | null; github?: string; google?: string; - email?: string; - tokens?: any[]; + email: string; + tokens: { kind: string }[]; apiKeys: ApiKeyDocument[]; preferences: UserPreferences; totalSize: number; @@ -25,9 +25,30 @@ export interface UserInterface { lastLoginTimestamp?: Date; } +/** Sanitised version of the user document without sensitive info */ +export interface PublicUserDocument + extends Pick< + UserDocument, + | 'email' + | 'username' + | 'preferences' + | 'apiKeys' + | 'verified' + | 'id' + | 'totalSize' + | 'github' + | 'google' + | 'cookieConsent' + > {} + export interface UserDocument extends UserInterface, - DocumentWithTimestampAndVirtualId {} + DocumentWithTimestampAndVirtualId { + comparePassword(candidatePassword: string): Promise; + findMatchingKey( + candidateKey: string + ): Promise<{ isMatch: boolean; keyDocument: UserDocument | null }>; +} export interface UserModel extends Model { findByEmail(email: string | string[]): Promise; From fce7b0cba72dedc0ef2fec55bdf694cf73a12f5b Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 20:59:09 +0100 Subject: [PATCH 12/15] controllers/user: add type definitions using Express Request & Response --- server/controllers/user.controller.ts | 180 ++++++++++++++++++++++---- 1 file changed, 157 insertions(+), 23 deletions(-) diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 1ecf15c13e..e4dde5b612 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -1,19 +1,27 @@ import crypto from 'crypto'; - +import { Request, Response } from 'express'; import { User } from '../models/user'; +import { + CookieConsentOptions, + UserDocument, + UserPreferences, + PublicUserDocument +} from '../types'; import mail from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; export * from './user.controller/apiKey'; -export function userResponse(user) { +export function userResponse( + user: UserDocument | PublicUserDocument +): PublicUserDocument { return { email: user.email, username: user.username, preferences: user.preferences, apiKeys: user.apiKeys, verified: user.verified, - id: user._id, + id: '_id' in user ? String(user._id) : user.id, totalSize: user.totalSize, github: user.github, google: user.google, @@ -26,7 +34,7 @@ export function userResponse(user) { * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback * @return Promise */ -async function generateToken() { +async function generateToken(): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { @@ -39,7 +47,17 @@ async function generateToken() { }); } -export async function createUser(req, res) { +// ===== CREATE USER ===== +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +interface CreateUserRequest extends Request<{}, {}, CreateUserRequestBody> { + user: PublicUserDocument; + logIn: (user: any, callback: (err?: any) => void) => void; +} +export async function createUser(req: CreateUserRequest, res: Response) { try { const { username, email, password } = req.body; const emailLowerCase = email.toLowerCase(); @@ -66,7 +84,7 @@ export async function createUser(req, res) { await user.save(); - req.logIn(user, async (loginErr) => { + req.logIn(user, async (loginErr: any) => { if (loginErr) { console.error(loginErr); res.status(500).json({ error: 'Failed to log in user.' }); @@ -96,10 +114,29 @@ export async function createUser(req, res) { } } -export async function duplicateUserCheck(req, res) { +// ===== DUPLICATE USER CHECK ===== +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'string'; + email: string; + username: string; +} +interface DuplicateUserCheckRequest + extends Request<{}, {}, {}, DuplicateUserCheckQuery> { + user: PublicUserDocument; + logIn: (user: any, callback: (err?: any) => void) => void; +} + +export async function duplicateUserCheck( + req: DuplicateUserCheckRequest, + res: Response +) { const checkType = req.query.check_type; - const value = req.query[checkType]; - const options = { caseInsensitive: true, valueType: checkType }; + const value = req.query[checkType as 'email' | 'username']; + const options = { + caseInsensitive: true, + valueType: checkType as 'email' | 'username' + }; const user = await User.findByEmailOrUsername(value, options); if (user) { return res.json({ @@ -114,7 +151,18 @@ export async function duplicateUserCheck(req, res) { }); } -export async function updatePreferences(req, res) { +// ===== UPDATE USER PREFERENCES ===== +export interface UpdateUserPreferencesRequestBody { + preferences: Partial; +} +interface UpdateUserPreferencesRequest + extends Request<{}, {}, UpdateUserPreferencesRequestBody> { + user: PublicUserDocument; +} +export async function updatePreferences( + req: UpdateUserPreferencesRequest, + res: Response +) { try { const user = await User.findById(req.user.id).exec(); if (!user) { @@ -130,7 +178,18 @@ export async function updatePreferences(req, res) { } } -export async function resetPasswordInitiate(req, res) { +// ===== RESET PASSWORD INITIATE ===== +export interface ResetPasswordInitiateRequestBody { + email: string; +} +interface ResetPasswordInitiateRequest + extends Request<{}, {}, ResetPasswordInitiateRequestBody> { + user: PublicUserDocument; +} +export async function resetPasswordInitiate( + req: ResetPasswordInitiateRequest, + res: Response +) { try { const token = await generateToken(); const user = await User.findByEmail(req.body.email); @@ -167,7 +226,18 @@ export async function resetPasswordInitiate(req, res) { } } -export async function validateResetPasswordToken(req, res) { +// ===== RESET PASSWORD INITIATE ===== +export interface ValidateResetPasswordTokenRequestParams { + token: string; +} +interface ValidateResetPasswordTokenRequest + extends Request { + user: PublicUserDocument; +} +export async function validateResetPasswordToken( + req: ValidateResetPasswordTokenRequest, + res: Response +) { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -182,7 +252,14 @@ export async function validateResetPasswordToken(req, res) { res.json({ success: true }); } -export async function emailVerificationInitiate(req, res) { +// ===== EMAIL VERIFICATION INITIATE ===== +interface EmailVerificationInitiateRequest extends Request { + user: UserDocument; +} +export async function emailVerificationInitiate( + req: EmailVerificationInitiateRequest, + res: Response +) { try { const token = await generateToken(); const user = await User.findById(req.user.id).exec(); @@ -220,7 +297,16 @@ export async function emailVerificationInitiate(req, res) { } } -export async function verifyEmail(req, res) { +// ===== VERIFY EMAIL ===== +export interface VerifyEmailRequestQuery { + t: string; +} +interface VerifyEmailRequest + extends Request<{}, {}, {}, VerifyEmailRequestQuery> { + user: PublicUserDocument; + logIn: (user: any, callback: (err?: any) => void) => void; +} +export async function verifyEmail(req: VerifyEmailRequest, res: Response) { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -240,7 +326,22 @@ export async function verifyEmail(req, res) { res.json({ success: true }); } -export async function updatePassword(req, res) { +// ===== UPDATE PASSWORD ===== +export interface UpdatePasswordRequestParams { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} +interface UpdatePasswordRequest + extends Request { + user: PublicUserDocument; + logIn: (user: any, callback: (err?: any) => void) => void; +} +export async function updatePassword( + req: UpdatePasswordRequest, + res: Response +) { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -258,7 +359,7 @@ export async function updatePassword(req, res) { user.resetPasswordExpires = undefined; await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.user))); + req.logIn(user, (loginErr: any) => res.json(userResponse(req.user))); // eventually send email that the password has been reset } @@ -266,7 +367,7 @@ export async function updatePassword(req, res) { * @param {string} username * @return {Promise} */ -export async function userExists(username) { +export async function userExists(username: string) { const user = await User.findByUsername(username); return user != null; } @@ -277,7 +378,7 @@ export async function userExists(username) { * @param res * @param user */ -export async function saveUser(res, user) { +export async function saveUser(res: Response, user: UserDocument) { try { await user.save(); res.json(userResponse(user)); @@ -286,7 +387,21 @@ export async function saveUser(res, user) { } } -export async function updateSettings(req, res) { +// ===== UPDATE USER PREFERENCES ====== +export interface UpdateUserSettingsRequestBody { + username: string; + newPassword: string; + currentPassword: string; + email: string; +} +interface UpdateUserSettingsRequest + extends Request<{}, {}, UpdateUserSettingsRequestBody> { + user: PublicUserDocument; +} +export async function updateSettings( + req: UpdateUserSettingsRequest, + res: Response +) { try { const user = await User.findById(req.user.id); if (!user) { @@ -343,7 +458,11 @@ export async function updateSettings(req, res) { } } -export async function unlinkGithub(req, res) { +// ===== UNLINK GITHUB ====== +interface UnlinkGithubRequest extends Request { + user: UserDocument; +} +export async function unlinkGithub(req: UnlinkGithubRequest, res: Response) { if (req.user) { req.user.github = undefined; req.user.tokens = req.user.tokens.filter( @@ -358,11 +477,15 @@ export async function unlinkGithub(req, res) { }); } -export async function unlinkGoogle(req, res) { +// ===== UNLINK GITHUB ====== +interface UnlinkGoogleRequest extends Request { + user: UserDocument; +} +export async function unlinkGoogle(req: UnlinkGoogleRequest, res: Response) { if (req.user) { req.user.google = undefined; req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'google' + (token: { kind: string }) => token.kind !== 'google' ); await saveUser(res, req.user); return; @@ -373,7 +496,18 @@ export async function unlinkGoogle(req, res) { }); } -export async function updateCookieConsent(req, res) { +// ===== UPDATE COOKIE CONSENT ====== +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} +interface UpdateCookieConsentRequest + extends Request<{}, {}, UpdateCookieConsentRequestBody> { + user: UserDocument; +} +export async function updateCookieConsent( + req: UpdateCookieConsentRequest, + res: Response +) { try { const user = await User.findById(req.user.id).exec(); if (!user) { From 9a3741a769cbfa5c5ba97b006cef9f7d380f98a2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 21:04:04 +0100 Subject: [PATCH 13/15] routes/user: update to ts, no-verify --- server/routes/{user.routes.js => user.routes.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/routes/{user.routes.js => user.routes.ts} (100%) diff --git a/server/routes/user.routes.js b/server/routes/user.routes.ts similarity index 100% rename from server/routes/user.routes.js rename to server/routes/user.routes.ts From 0cb1eaca34c237bc24cc4735af366bbc333c48b7 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 22:37:28 +0100 Subject: [PATCH 14/15] controllers/user: update to use RequestHandler and refactor to migrate helper functions out --- server/controllers/user.controller.ts | 349 +++++++----------- server/controllers/user.controller/helpers.ts | 63 ++++ server/routes/user.routes.ts | 34 +- server/types/express.d.ts | 13 + 4 files changed, 233 insertions(+), 226 deletions(-) create mode 100644 server/controllers/user.controller/helpers.ts create mode 100644 server/types/express.d.ts diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index e4dde5b612..2774256227 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -1,78 +1,45 @@ -import crypto from 'crypto'; -import { Request, Response } from 'express'; +/* eslint-disable consistent-return */ + +import { RequestHandler } from 'express-serve-static-core'; import { User } from '../models/user'; -import { - CookieConsentOptions, - UserDocument, - UserPreferences, - PublicUserDocument -} from '../types'; +import { CookieConsentOptions, UserPreferences } from '../types'; import mail from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; +import { + userResponse, + generateToken, + saveUser +} from './user.controller/helpers'; export * from './user.controller/apiKey'; -export function userResponse( - user: UserDocument | PublicUserDocument -): PublicUserDocument { - return { - email: user.email, - username: user.username, - preferences: user.preferences, - apiKeys: user.apiKeys, - verified: user.verified, - id: '_id' in user ? String(user._id) : user.id, - totalSize: user.totalSize, - github: user.github, - google: user.google, - cookieConsent: user.cookieConsent - }; -} - -/** - * Create a new verification token. - * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback - * @return Promise - */ -async function generateToken(): Promise { - return new Promise((resolve, reject) => { - crypto.randomBytes(20, (err, buf) => { - if (err) { - reject(err); - } else { - const token = buf.toString('hex'); - resolve(token); - } - }); - }); -} - -// ===== CREATE USER ===== +// POST /signup export interface CreateUserRequestBody { username: string; email: string; password: string; } -interface CreateUserRequest extends Request<{}, {}, CreateUserRequestBody> { - user: PublicUserDocument; - logIn: (user: any, callback: (err?: any) => void) => void; -} -export async function createUser(req: CreateUserRequest, res: Response) { +export const createUser: RequestHandler< + {}, + any, + CreateUserRequestBody +> = async (req, res) => { try { const { username, email, password } = req.body; const emailLowerCase = email.toLowerCase(); + const existingUser = await User.findByEmailAndUsername(email, username); if (existingUser) { const fieldInUse = existingUser.email.toLowerCase() === emailLowerCase ? 'Email' : 'Username'; - res.status(422).send({ error: `${fieldInUse} is in use` }); - return; + return res.status(422).send({ error: `${fieldInUse} is in use` }); } - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours const token = await generateToken(); + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + const user = new User({ username, email: emailLowerCase, @@ -84,11 +51,10 @@ export async function createUser(req: CreateUserRequest, res: Response) { await user.save(); - req.logIn(user, async (loginErr: any) => { + req.logIn(user, async (loginErr) => { if (loginErr) { console.error(loginErr); - res.status(500).json({ error: 'Failed to log in user.' }); - return; + return res.status(500).json({ error: 'Failed to log in user.' }); } const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; @@ -97,7 +63,7 @@ export async function createUser(req: CreateUserRequest, res: Response) { domain: `${protocol}://${req.headers.host}`, link: `${protocol}://${req.headers.host}/verify?t=${token}` }, - to: req.user.email + to: req.user!.email }); try { @@ -112,25 +78,21 @@ export async function createUser(req: CreateUserRequest, res: Response) { console.error(err); res.status(500).json({ error: err }); } -} +}; -// ===== DUPLICATE USER CHECK ===== +// GET /signup/duplicate_check export interface DuplicateUserCheckQuery { // eslint-disable-next-line camelcase check_type: 'email' | 'string'; email: string; username: string; } -interface DuplicateUserCheckRequest - extends Request<{}, {}, {}, DuplicateUserCheckQuery> { - user: PublicUserDocument; - logIn: (user: any, callback: (err?: any) => void) => void; -} - -export async function duplicateUserCheck( - req: DuplicateUserCheckRequest, - res: Response -) { +export const duplicateUserCheck: RequestHandler< + {}, + any, + any, + DuplicateUserCheckQuery +> = async (req, res) => { const checkType = req.query.check_type; const value = req.query[checkType as 'email' | 'username']; const options = { @@ -149,20 +111,20 @@ export async function duplicateUserCheck( exists: false, type: checkType }); -} +}; -// ===== UPDATE USER PREFERENCES ===== +// PUT /preferences export interface UpdateUserPreferencesRequestBody { preferences: Partial; } -interface UpdateUserPreferencesRequest - extends Request<{}, {}, UpdateUserPreferencesRequestBody> { - user: PublicUserDocument; -} -export async function updatePreferences( - req: UpdateUserPreferencesRequest, - res: Response -) { +export const updatePreferences: RequestHandler< + {}, + any, + UpdateUserPreferencesRequestBody +> = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } try { const user = await User.findById(req.user.id).exec(); if (!user) { @@ -176,20 +138,17 @@ export async function updatePreferences( } catch (err) { res.status(500).json({ error: err }); } -} +}; -// ===== RESET PASSWORD INITIATE ===== +// POST /reset-password export interface ResetPasswordInitiateRequestBody { email: string; } -interface ResetPasswordInitiateRequest - extends Request<{}, {}, ResetPasswordInitiateRequestBody> { - user: PublicUserDocument; -} -export async function resetPasswordInitiate( - req: ResetPasswordInitiateRequest, - res: Response -) { +export const resetPasswordInitiate: RequestHandler< + {}, + any, + ResetPasswordInitiateRequestBody +> = async (req, res) => { try { const token = await generateToken(); const user = await User.findByEmail(req.body.email); @@ -224,20 +183,16 @@ export async function resetPasswordInitiate( console.log(err); res.json({ success: false }); } -} +}; -// ===== RESET PASSWORD INITIATE ===== +// GET /reset-password/:token export interface ValidateResetPasswordTokenRequestParams { token: string; } -interface ValidateResetPasswordTokenRequest - extends Request { - user: PublicUserDocument; -} -export async function validateResetPasswordToken( - req: ValidateResetPasswordTokenRequest, - res: Response -) { +export const validateResetPasswordToken: RequestHandler = async ( + req, + res +) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -250,16 +205,47 @@ export async function validateResetPasswordToken( return; } res.json({ success: true }); -} +}; -// ===== EMAIL VERIFICATION INITIATE ===== -interface EmailVerificationInitiateRequest extends Request { - user: UserDocument; +// POST /reset-password/:token +export interface UpdatePasswordRequestParams { + token: string; } -export async function emailVerificationInitiate( - req: EmailVerificationInitiateRequest, - res: Response -) { +export interface UpdatePasswordRequestBody { + password: string; +} +export const updatePassword: RequestHandler< + UpdatePasswordRequestParams, + any, + UpdatePasswordRequestBody +> = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + await user.save(); + req.logIn(user, (loginErr: any) => res.json(userResponse(user))); + // eventually send email that the password has been reset +}; + +// POST /verify/send +export const emailVerificationInitiate: RequestHandler = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } try { const token = await generateToken(); const user = await User.findById(req.user.id).exec(); @@ -295,18 +281,18 @@ export async function emailVerificationInitiate( } catch (err) { res.status(500).json({ error: err }); } -} +}; -// ===== VERIFY EMAIL ===== +// GET /verify export interface VerifyEmailRequestQuery { t: string; } -interface VerifyEmailRequest - extends Request<{}, {}, {}, VerifyEmailRequestQuery> { - user: PublicUserDocument; - logIn: (user: any, callback: (err?: any) => void) => void; -} -export async function verifyEmail(req: VerifyEmailRequest, res: Response) { +export const verifyEmail: RequestHandler< + {}, + any, + any, + VerifyEmailRequestQuery +> = async (req, res) => { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -324,86 +310,29 @@ export async function verifyEmail(req: VerifyEmailRequest, res: Response) { user.verifiedTokenExpires = null; await user.save(); res.json({ success: true }); -} - -// ===== UPDATE PASSWORD ===== -export interface UpdatePasswordRequestParams { - token: string; -} -export interface UpdatePasswordRequestBody { - password: string; -} -interface UpdatePasswordRequest - extends Request { - user: PublicUserDocument; - logIn: (user: any, callback: (err?: any) => void) => void; -} -export async function updatePassword( - req: UpdatePasswordRequest, - res: Response -) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } +}; - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - - await user.save(); - req.logIn(user, (loginErr: any) => res.json(userResponse(req.user))); - // eventually send email that the password has been reset -} - -/** - * @param {string} username - * @return {Promise} - */ -export async function userExists(username: string) { - const user = await User.findByUsername(username); - return user != null; -} - -/** - * Updates the user object and sets the response. - * Response is the user or a 500 error. - * @param res - * @param user - */ -export async function saveUser(res: Response, user: UserDocument) { - try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} - -// ===== UPDATE USER PREFERENCES ====== +// PUT /account export interface UpdateUserSettingsRequestBody { username: string; newPassword: string; currentPassword: string; email: string; } -interface UpdateUserSettingsRequest - extends Request<{}, {}, UpdateUserSettingsRequestBody> { - user: PublicUserDocument; -} -export async function updateSettings( - req: UpdateUserSettingsRequest, - res: Response -) { +export const updateSettings: RequestHandler< + {}, + any, + UpdateUserSettingsRequestBody +> = async (req, res) => { + if (!req.user) { + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); + } + try { - const user = await User.findById(req.user.id); + const user = await User.findById(req.user?.id); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -456,13 +385,10 @@ export async function updateSettings( } catch (err) { res.status(500).json({ error: err }); } -} +}; -// ===== UNLINK GITHUB ====== -interface UnlinkGithubRequest extends Request { - user: UserDocument; -} -export async function unlinkGithub(req: UnlinkGithubRequest, res: Response) { +// DELETE /auth/github +export const unlinkGithub: RequestHandler = async (req, res) => { if (req.user) { req.user.github = undefined; req.user.tokens = req.user.tokens.filter( @@ -475,13 +401,10 @@ export async function unlinkGithub(req: UnlinkGithubRequest, res: Response) { success: false, message: 'You must be logged in to complete this action.' }); -} +}; -// ===== UNLINK GITHUB ====== -interface UnlinkGoogleRequest extends Request { - user: UserDocument; -} -export async function unlinkGoogle(req: UnlinkGoogleRequest, res: Response) { +// DELETE /auth/google +export const unlinkGoogle: RequestHandler = async (req, res) => { if (req.user) { req.user.google = undefined; req.user.tokens = req.user.tokens.filter( @@ -494,30 +417,26 @@ export async function unlinkGoogle(req: UnlinkGoogleRequest, res: Response) { success: false, message: 'You must be logged in to complete this action.' }); -} +}; -// ===== UPDATE COOKIE CONSENT ====== +// PUT /cookie-consent export interface UpdateCookieConsentRequestBody { cookieConsent: CookieConsentOptions; } -interface UpdateCookieConsentRequest - extends Request<{}, {}, UpdateCookieConsentRequestBody> { - user: UserDocument; -} -export async function updateCookieConsent( - req: UpdateCookieConsentRequest, - res: Response -) { +export const updateCookieConsent: RequestHandler< + {}, + any, + UpdateCookieConsentRequestBody +> = async (req, res) => { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + try { const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const { cookieConsent } = req.body; - user.cookieConsent = cookieConsent; + if (!user) return res.status(404).json({ error: 'User not found' }); + + user.cookieConsent = req.body.cookieConsent; await saveUser(res, user); } catch (err) { res.status(500).json({ error: err }); } -} +}; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts new file mode 100644 index 0000000000..e3d8955fc0 --- /dev/null +++ b/server/controllers/user.controller/helpers.ts @@ -0,0 +1,63 @@ +import crypto from 'crypto'; +import { Response } from 'express'; +import { UserDocument, PublicUserDocument } from '../../types'; +import { User } from '../../models/user'; + +export function userResponse( + user: UserDocument | PublicUserDocument +): PublicUserDocument { + return { + email: user.email, + username: user.username, + preferences: user.preferences, + apiKeys: user.apiKeys, + verified: user.verified, + id: '_id' in user ? String(user._id) : user.id, + totalSize: user.totalSize, + github: user.github, + google: user.google, + cookieConsent: user.cookieConsent + }; +} + +/** + * Create a new verification token. + * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback + * @return Promise + */ +export async function generateToken(): Promise { + return new Promise((resolve, reject) => { + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + } else { + const token = buf.toString('hex'); + resolve(token); + } + }); + }); +} + +/** + * @param {string} username + * @return {Promise} + */ +export async function userExists(username: string) { + const user = await User.findByUsername(username); + return user != null; +} + +/** + * Updates the user object and sets the response. + * Response is the user or a 500 error. + * @param res + * @param user + */ +export async function saveUser(res: Response, user: UserDocument) { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index a507c328bd..2fab82eb36 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -2,27 +2,34 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; import isAuthenticated from '../utils/isAuthenticated'; -const router = new Router(); +const router = Router(); +// POST /signup router.post('/signup', UserController.createUser); +// GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); +// PUT /preferences router.put('/preferences', isAuthenticated, UserController.updatePreferences); +// POST /reset-password router.post('/reset-password', UserController.resetPasswordInitiate); +// GET /reset-password/:token router.get('/reset-password/:token', UserController.validateResetPasswordToken); +// POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); -router.put('/account', isAuthenticated, UserController.updateSettings); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); -router.put( - '/cookie-consent', - isAuthenticated, - UserController.updateCookieConsent -); +// GET /verify +router.get('/verify', UserController.verifyEmail); + +// PUT /account +router.put('/account', isAuthenticated, UserController.updateSettings); router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); @@ -32,11 +39,16 @@ router.delete( UserController.removeApiKey ); -router.post('/verify/send', UserController.emailVerificationInitiate); - -router.get('/verify', UserController.verifyEmail); - +// DELETE /auth/github router.delete('/auth/github', UserController.unlinkGithub); + +// DELETE /auth/google router.delete('/auth/google', UserController.unlinkGoogle); +// PUT /cookie-consent +router.put( + '/cookie-consent', + isAuthenticated, + UserController.updateCookieConsent +); export default router; diff --git a/server/types/express.d.ts b/server/types/express.d.ts new file mode 100644 index 0000000000..128c9faa29 --- /dev/null +++ b/server/types/express.d.ts @@ -0,0 +1,13 @@ +import { UserDocument } from './user'; + +declare global { + namespace Express { + interface User extends UserDocument {} + interface Request { + user?: UserDocument; + logIn: (user: UserDocument, callback: (err?: any) => void) => void; + logOut: (callback: (err?: any) => void) => void; + isAuthenticated: () => boolean; + } + } +} From e6a16b83a418965e0bc548b486674b07e46d7f5b Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 7 Sep 2025 23:18:48 +0100 Subject: [PATCH 15/15] controller/user & routes/user: refactor controller to barrel pattern & organise by subdomain --- server/controllers/user.controller.ts | 442 ------------------ .../user.controller/authManagement.ts | 214 +++++++++ server/controllers/user.controller/helpers.ts | 1 + server/controllers/user.controller/index.ts | 4 + server/controllers/user.controller/signup.ts | 177 +++++++ .../user.controller/userPreferences.ts | 54 +++ server/routes/user.routes.ts | 47 +- 7 files changed, 484 insertions(+), 455 deletions(-) delete mode 100644 server/controllers/user.controller.ts create mode 100644 server/controllers/user.controller/authManagement.ts create mode 100644 server/controllers/user.controller/index.ts create mode 100644 server/controllers/user.controller/signup.ts create mode 100644 server/controllers/user.controller/userPreferences.ts diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts deleted file mode 100644 index 2774256227..0000000000 --- a/server/controllers/user.controller.ts +++ /dev/null @@ -1,442 +0,0 @@ -/* eslint-disable consistent-return */ - -import { RequestHandler } from 'express-serve-static-core'; -import { User } from '../models/user'; -import { CookieConsentOptions, UserPreferences } from '../types'; -import mail from '../utils/mail'; -import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; -import { - userResponse, - generateToken, - saveUser -} from './user.controller/helpers'; - -export * from './user.controller/apiKey'; - -// POST /signup -export interface CreateUserRequestBody { - username: string; - email: string; - password: string; -} -export const createUser: RequestHandler< - {}, - any, - CreateUserRequestBody -> = async (req, res) => { - try { - const { username, email, password } = req.body; - const emailLowerCase = email.toLowerCase(); - - const existingUser = await User.findByEmailAndUsername(email, username); - if (existingUser) { - const fieldInUse = - existingUser.email.toLowerCase() === emailLowerCase - ? 'Email' - : 'Username'; - return res.status(422).send({ error: `${fieldInUse} is in use` }); - } - - const token = await generateToken(); - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - - const user = new User({ - username, - email: emailLowerCase, - password, - verified: User.EmailConfirmation.Sent, - verifiedToken: token, - verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME - }); - - await user.save(); - - req.logIn(user, async (loginErr) => { - if (loginErr) { - console.error(loginErr); - return res.status(500).json({ error: 'Failed to log in user.' }); - } - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: req.user!.email - }); - - try { - await mail.send(mailOptions); - res.json(userResponse(user)); - } catch (mailErr) { - console.error(mailErr); - res.status(500).json({ error: 'Failed to send verification email.' }); - } - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err }); - } -}; - -// GET /signup/duplicate_check -export interface DuplicateUserCheckQuery { - // eslint-disable-next-line camelcase - check_type: 'email' | 'string'; - email: string; - username: string; -} -export const duplicateUserCheck: RequestHandler< - {}, - any, - any, - DuplicateUserCheckQuery -> = async (req, res) => { - const checkType = req.query.check_type; - const value = req.query[checkType as 'email' | 'username']; - const options = { - caseInsensitive: true, - valueType: checkType as 'email' | 'username' - }; - const user = await User.findByEmailOrUsername(value, options); - if (user) { - return res.json({ - exists: true, - message: `This ${checkType} is already taken.`, - type: checkType - }); - } - return res.json({ - exists: false, - type: checkType - }); -}; - -// PUT /preferences -export interface UpdateUserPreferencesRequestBody { - preferences: Partial; -} -export const updatePreferences: RequestHandler< - {}, - any, - UpdateUserPreferencesRequestBody -> = async (req, res) => { - if (!req.user) { - return res.status(401).json({ error: 'Unauthorized' }); - } - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - // Shallow merge the new preferences with the existing. - user.preferences = { ...user.preferences, ...req.body.preferences }; - await user.save(); - res.json(user.preferences); - } catch (err) { - res.status(500).json({ error: err }); - } -}; - -// POST /reset-password -export interface ResetPasswordInitiateRequestBody { - email: string; -} -export const resetPasswordInitiate: RequestHandler< - {}, - any, - ResetPasswordInitiateRequestBody -> = async (req, res) => { - try { - const token = await generateToken(); - const user = await User.findByEmail(req.body.email); - if (!user) { - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - return; - } - user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 3600000; // 1 hour - - await user.save(); - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderResetPassword({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/reset-password/${token}` - }, - to: user.email - }); - - await mail.send(mailOptions); - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - } catch (err) { - console.log(err); - res.json({ success: false }); - } -}; - -// GET /reset-password/:token -export interface ValidateResetPasswordTokenRequestParams { - token: string; -} -export const validateResetPasswordToken: RequestHandler = async ( - req, - res -) => { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - res.json({ success: true }); -}; - -// POST /reset-password/:token -export interface UpdatePasswordRequestParams { - token: string; -} -export interface UpdatePasswordRequestBody { - password: string; -} -export const updatePassword: RequestHandler< - UpdatePasswordRequestParams, - any, - UpdatePasswordRequestBody -> = async (req, res) => { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - - await user.save(); - req.logIn(user, (loginErr: any) => res.json(userResponse(user))); - // eventually send email that the password has been reset -}; - -// POST /verify/send -export const emailVerificationInitiate: RequestHandler = async (req, res) => { - if (!req.user) { - return res.status(401).json({ error: 'Unauthorized' }); - } - try { - const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - if (user.verified === User.EmailConfirmation.Verified) { - res.status(409).json({ error: 'Email already verified' }); - return; - } - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - try { - await mail.send(mailOptions); - } catch (mailErr) { - res.status(500).send({ error: 'Error sending mail' }); - return; - } - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Resent; - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours - await user.save(); - - res.json(userResponse(req.user)); - } catch (err) { - res.status(500).json({ error: err }); - } -}; - -// GET /verify -export interface VerifyEmailRequestQuery { - t: string; -} -export const verifyEmail: RequestHandler< - {}, - any, - any, - VerifyEmailRequestQuery -> = async (req, res) => { - const token = req.query.t; - const user = await User.findOne({ - verifiedToken: token, - verifiedTokenExpires: { $gt: new Date() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Token is invalid or has expired.' - }); - return; - } - user.verified = User.EmailConfirmation.Verified; - user.verifiedToken = null; - user.verifiedTokenExpires = null; - await user.save(); - res.json({ success: true }); -}; - -// PUT /account -export interface UpdateUserSettingsRequestBody { - username: string; - newPassword: string; - currentPassword: string; - email: string; -} -export const updateSettings: RequestHandler< - {}, - any, - UpdateUserSettingsRequestBody -> = async (req, res) => { - if (!req.user) { - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); - } - - try { - const user = await User.findById(req.user?.id); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - user.username = req.body.username; - - if (req.body.newPassword) { - if (user.password === undefined) { - user.password = req.body.newPassword; - saveUser(res, user); - } - if (!req.body.currentPassword) { - res.status(401).json({ error: 'Current password is not provided.' }); - return; - } - } - if (req.body.currentPassword) { - const isMatch = await user.comparePassword(req.body.currentPassword); - if (!isMatch) { - res.status(401).json({ error: 'Current password is invalid.' }); - return; - } - user.password = req.body.newPassword; - await saveUser(res, user); - } else if (user.email !== req.body.email) { - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Sent; - - user.email = req.body.email; - - const token = await generateToken(); - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; - - await saveUser(res, user); - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - - await mail.send(mailOptions); - } else { - await saveUser(res, user); - } - } catch (err) { - res.status(500).json({ error: err }); - } -}; - -// DELETE /auth/github -export const unlinkGithub: RequestHandler = async (req, res) => { - if (req.user) { - req.user.github = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'github' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -}; - -// DELETE /auth/google -export const unlinkGoogle: RequestHandler = async (req, res) => { - if (req.user) { - req.user.google = undefined; - req.user.tokens = req.user.tokens.filter( - (token: { kind: string }) => token.kind !== 'google' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -}; - -// PUT /cookie-consent -export interface UpdateCookieConsentRequestBody { - cookieConsent: CookieConsentOptions; -} -export const updateCookieConsent: RequestHandler< - {}, - any, - UpdateCookieConsentRequestBody -> = async (req, res) => { - if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); - - try { - const user = await User.findById(req.user.id).exec(); - if (!user) return res.status(404).json({ error: 'User not found' }); - - user.cookieConsent = req.body.cookieConsent; - await saveUser(res, user); - } catch (err) { - res.status(500).json({ error: err }); - } -}; diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts new file mode 100644 index 0000000000..5a428c0f5c --- /dev/null +++ b/server/controllers/user.controller/authManagement.ts @@ -0,0 +1,214 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { saveUser, generateToken, userResponse } from './helpers'; +import mail from '../../utils/mail'; +import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; + +// POST /reset-password +export interface ResetPasswordInitiateRequestBody { + email: string; +} +export const resetPasswordInitiate: RequestHandler< + {}, + any, + ResetPasswordInitiateRequestBody +> = async (req, res) => { + try { + const token = await generateToken(); + const user = await User.findByEmail(req.body.email); + if (!user) { + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + return; + } + user.resetPasswordToken = token; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour + + await user.save(); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderResetPassword({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/reset-password/${token}` + }, + to: user.email + }); + + await mail.send(mailOptions); + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + } catch (err) { + console.log(err); + res.json({ success: false }); + } +}; + +// GET /reset-password/:token +export interface ValidateResetPasswordTokenRequestParams { + token: string; +} +export const validateResetPasswordToken: RequestHandler = async ( + req, + res +) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + res.json({ success: true }); +}; + +// POST /reset-password/:token +export interface UpdatePasswordRequestParams { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} +export const updatePassword: RequestHandler< + UpdatePasswordRequestParams, + any, + UpdatePasswordRequestBody +> = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + await user.save(); + req.logIn(user, (loginErr: any) => res.json(userResponse(user))); + // eventually send email that the password has been reset +}; + +// PUT /account +export interface UpdateUserSettingsRequestBody { + username: string; + newPassword: string; + currentPassword: string; + email: string; +} +export const updateSettings: RequestHandler< + {}, + any, + UpdateUserSettingsRequestBody +> = async (req, res) => { + if (!req.user) { + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); + } + + try { + const user = await User.findById(req.user?.id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + user.username = req.body.username; + + if (req.body.newPassword) { + if (user.password === undefined) { + user.password = req.body.newPassword; + saveUser(res, user); + } + if (!req.body.currentPassword) { + res.status(401).json({ error: 'Current password is not provided.' }); + return; + } + } + if (req.body.currentPassword) { + const isMatch = await user.comparePassword(req.body.currentPassword); + if (!isMatch) { + res.status(401).json({ error: 'Current password is invalid.' }); + return; + } + user.password = req.body.newPassword; + await saveUser(res, user); + } else if (user.email !== req.body.email) { + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation.Sent; + + user.email = req.body.email; + + const token = await generateToken(); + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + await saveUser(res, user); + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + + await mail.send(mailOptions); + } else { + await saveUser(res, user); + } + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// DELETE /auth/github +export const unlinkGithub: RequestHandler = async (req, res) => { + if (req.user) { + req.user.github = undefined; + req.user.tokens = req.user.tokens.filter( + (token) => token.kind !== 'github' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; + +// DELETE /auth/google +export const unlinkGoogle: RequestHandler = async (req, res) => { + if (req.user) { + req.user.google = undefined; + req.user.tokens = req.user.tokens.filter( + (token: { kind: string }) => token.kind !== 'google' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index e3d8955fc0..7d99d51449 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -3,6 +3,7 @@ import { Response } from 'express'; import { UserDocument, PublicUserDocument } from '../../types'; import { User } from '../../models/user'; +/** Sanitised user response without sensitive data */ export function userResponse( user: UserDocument | PublicUserDocument ): PublicUserDocument { diff --git a/server/controllers/user.controller/index.ts b/server/controllers/user.controller/index.ts new file mode 100644 index 0000000000..ac23dfcd4b --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,4 @@ +export * from './signup'; +export * from './apiKey'; +export * from './userPreferences'; +export * from './authManagement'; diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts new file mode 100644 index 0000000000..252d7b841c --- /dev/null +++ b/server/controllers/user.controller/signup.ts @@ -0,0 +1,177 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express-serve-static-core'; +import { User } from '../../models/user'; +import { generateToken, userResponse } from './helpers'; +import { renderEmailConfirmation } from '../../views/mail'; +import mail from '../../utils/mail'; + +// POST /signup +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +export const createUser: RequestHandler< + {}, + any, + CreateUserRequestBody +> = async (req, res) => { + try { + const { username, email, password } = req.body; + const emailLowerCase = email.toLowerCase(); + + const existingUser = await User.findByEmailAndUsername(email, username); + if (existingUser) { + const fieldInUse = + existingUser.email.toLowerCase() === emailLowerCase + ? 'Email' + : 'Username'; + return res.status(422).send({ error: `${fieldInUse} is in use` }); + } + + const token = await generateToken(); + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + + const user = new User({ + username, + email: emailLowerCase, + password, + verified: User.EmailConfirmation.Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME + }); + + await user.save(); + + req.logIn(user, async (loginErr) => { + if (loginErr) { + console.error(loginErr); + return res.status(500).json({ error: 'Failed to log in user.' }); + } + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: req.user!.email + }); + + try { + await mail.send(mailOptions); + res.json(userResponse(user)); + } catch (mailErr) { + console.error(mailErr); + res.status(500).json({ error: 'Failed to send verification email.' }); + } + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err }); + } +}; + +// GET /signup/duplicate_check +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'string'; + email: string; + username: string; +} +export const duplicateUserCheck: RequestHandler< + {}, + any, + any, + DuplicateUserCheckQuery +> = async (req, res) => { + const checkType = req.query.check_type; + const value = req.query[checkType as 'email' | 'username']; + const options = { + caseInsensitive: true, + valueType: checkType as 'email' | 'username' + }; + const user = await User.findByEmailOrUsername(value, options); + if (user) { + return res.json({ + exists: true, + message: `This ${checkType} is already taken.`, + type: checkType + }); + } + return res.json({ + exists: false, + type: checkType + }); +}; + +// POST /verify/send +export const emailVerificationInitiate: RequestHandler = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const token = await generateToken(); + const user = await User.findById(req.user.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + if (user.verified === User.EmailConfirmation.Verified) { + res.status(409).json({ error: 'Email already verified' }); + return; + } + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + try { + await mail.send(mailOptions); + } catch (mailErr) { + res.status(500).send({ error: 'Error sending mail' }); + return; + } + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation.Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + await user.save(); + + res.json(userResponse(req.user)); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// GET /verify +export interface VerifyEmailRequestQuery { + t: string; +} +export const verifyEmail: RequestHandler< + {}, + any, + any, + VerifyEmailRequestQuery +> = async (req, res) => { + const token = req.query.t; + const user = await User.findOne({ + verifiedToken: token, + verifiedTokenExpires: { $gt: new Date() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Token is invalid or has expired.' + }); + return; + } + user.verified = User.EmailConfirmation.Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + await user.save(); + res.json({ success: true }); +}; diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts new file mode 100644 index 0000000000..6bd4b14909 --- /dev/null +++ b/server/controllers/user.controller/userPreferences.ts @@ -0,0 +1,54 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { CookieConsentOptions, UserPreferences } from '../../types'; +import { User } from '../../models/user'; +import { saveUser } from './helpers'; + +// PUT /preferences +export interface UpdateUserPreferencesRequestBody { + preferences: Partial; +} +export const updatePreferences: RequestHandler< + {}, + any, + UpdateUserPreferencesRequestBody +> = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const user = await User.findById(req.user.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + // Shallow merge the new preferences with the existing. + user.preferences = { ...user.preferences, ...req.body.preferences }; + await user.save(); + res.json(user.preferences); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// PUT /cookie-consent +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} +export const updateCookieConsent: RequestHandler< + {}, + any, + UpdateCookieConsentRequestBody +> = async (req, res) => { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + + try { + const user = await User.findById(req.user.id).exec(); + if (!user) return res.status(404).json({ error: 'User not found' }); + + user.cookieConsent = req.body.cookieConsent; + await saveUser(res, user); + } catch (err) { + res.status(500).json({ error: err }); + } +}; diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 2fab82eb36..71f160f3b3 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -4,15 +4,28 @@ import isAuthenticated from '../utils/isAuthenticated'; const router = Router(); +/** + * =============== + * SIGN UP + * =============== + */ // POST /signup router.post('/signup', UserController.createUser); // GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); -// PUT /preferences -router.put('/preferences', isAuthenticated, UserController.updatePreferences); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); + +// GET /verify +router.get('/verify', UserController.verifyEmail); +/** + * =============== + * AUTH MANAGEMENT + * =============== + */ // POST /reset-password router.post('/reset-password', UserController.resetPasswordInitiate); @@ -22,15 +35,20 @@ router.get('/reset-password/:token', UserController.validateResetPasswordToken); // POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); -// POST /verify/send -router.post('/verify/send', UserController.emailVerificationInitiate); - -// GET /verify -router.get('/verify', UserController.verifyEmail); - // PUT /account router.put('/account', isAuthenticated, UserController.updateSettings); +// DELETE /auth/github +router.delete('/auth/github', UserController.unlinkGithub); + +// DELETE /auth/google +router.delete('/auth/google', UserController.unlinkGoogle); + +/** + * =============== + * API KEYS + * =============== + */ router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); router.delete( @@ -39,11 +57,13 @@ router.delete( UserController.removeApiKey ); -// DELETE /auth/github -router.delete('/auth/github', UserController.unlinkGithub); - -// DELETE /auth/google -router.delete('/auth/google', UserController.unlinkGoogle); +/** + * =============== + * USER PREFERENCES + * =============== + */ +// PUT /preferences +router.put('/preferences', isAuthenticated, UserController.updatePreferences); // PUT /cookie-consent router.put( @@ -51,4 +71,5 @@ router.put( isAuthenticated, UserController.updateCookieConsent ); + export default router;