@design-edito/cli 0.0.78 → 0.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/assets/list.txt +1 -2
- package/cli/assets/version.txt +1 -1
- package/make-template/assets/express/esbuild.config.js +19 -0
- package/make-template/assets/express/package.json +16 -12
- package/make-template/assets/express/src/index.ts +13 -49
- package/make-template/assets/express/src/public/index.css +7 -0
- package/make-template/assets/express/src/public/index.html +15 -0
- package/make-template/assets/express/src/public/index.js +31 -0
- package/make-template/assets/express/src/tsconfig.json +7 -3
- package/make-template/assets/express/src/www/index.ts +42 -0
- package/make-template/assets/express-api/Dockerfile +18 -0
- package/make-template/assets/express-api/Dockerfile.dev +8 -0
- package/make-template/assets/express-api/env +36 -0
- package/make-template/assets/express-api/esbuild.config.js +26 -0
- package/make-template/assets/express-api/gitignore +214 -0
- package/make-template/assets/express-api/package.json +60 -0
- package/make-template/assets/express-api/src/api/auth/_utils/index.ts +47 -0
- package/make-template/assets/express-api/src/api/auth/index.ts +25 -0
- package/make-template/assets/express-api/src/api/auth/login/index.ts +101 -0
- package/make-template/assets/express-api/src/api/auth/logout/index.ts +45 -0
- package/make-template/assets/express-api/src/api/auth/refresh-token/index.ts +54 -0
- package/make-template/assets/express-api/src/api/auth/request-email-verification-token/index.ts +45 -0
- package/make-template/assets/express-api/src/api/auth/request-new-password/index.ts +62 -0
- package/make-template/assets/express-api/src/api/auth/signup/index.ts +99 -0
- package/make-template/assets/express-api/src/api/auth/submit-new-password/index.ts +61 -0
- package/make-template/assets/express-api/src/api/auth/verify-email/index.ts +79 -0
- package/make-template/assets/express-api/src/api/auth/whoami/index.ts +68 -0
- package/make-template/assets/express-api/src/api/index.ts +18 -0
- package/make-template/assets/express-api/src/api/types.ts +76 -0
- package/make-template/assets/express-api/src/api/utils.ts +146 -0
- package/make-template/assets/express-api/src/database/index.ts +173 -0
- package/make-template/assets/express-api/src/email/index.ts +47 -0
- package/make-template/assets/express-api/src/env/index.ts +77 -0
- package/make-template/assets/express-api/src/errors/index.ts +128 -0
- package/make-template/assets/express-api/src/index.ts +42 -0
- package/make-template/assets/express-api/src/init/index.ts +156 -0
- package/make-template/assets/express-api/src/jwt/index.ts +105 -0
- package/make-template/assets/express-api/src/public/index.css +7 -0
- package/make-template/assets/express-api/src/public/index.html +15 -0
- package/make-template/assets/express-api/src/public/index.js +31 -0
- package/make-template/assets/express-api/src/schema/_history/index.ts +124 -0
- package/make-template/assets/express-api/src/schema/_meta/index.ts +113 -0
- package/make-template/assets/express-api/src/schema/index.ts +17 -0
- package/make-template/assets/express-api/src/schema/user/index.ts +117 -0
- package/make-template/assets/express-api/src/schema/user-email-validation-token/index.ts +20 -0
- package/make-template/assets/express-api/src/schema/user-password-renewal-token/index.ts +20 -0
- package/make-template/assets/express-api/src/schema/user-revoked-auth-token/index.ts +26 -0
- package/make-template/assets/express-api/src/tsconfig.json +16 -0
- package/make-template/assets/express-api/src/www/index.ts +43 -0
- package/make-template/index.js +2 -3
- package/make-template/index.js.map +3 -3
- package/package.json +7 -8
- package/make-template/assets/express/src/routes/index.ts +0 -7
- package/upgrade/index.js +0 -12
- package/upgrade/index.js.map +0 -7
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FilterQuery as MongooseFilterQuery,
|
|
3
|
+
Model as MongooseModel
|
|
4
|
+
} from 'mongoose'
|
|
5
|
+
import { Errors } from '@design-edito/tools/agnostic/errors'
|
|
6
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
7
|
+
|
|
8
|
+
export enum Codes {
|
|
9
|
+
// UNKNOWN
|
|
10
|
+
UNKNOWN_ERROR = 'unknown-error',
|
|
11
|
+
|
|
12
|
+
// AUTH
|
|
13
|
+
USER_NOT_AUTHENTICATED = 'user-not-authenticated',
|
|
14
|
+
USER_NOT_AUTHORIZED = 'user-not-authorized',
|
|
15
|
+
USER_DOES_NOT_EXIST = 'user-does-not-exist',
|
|
16
|
+
USER_EMAIL_DOES_NOT_EXIST = 'user-email-does-not-exist',
|
|
17
|
+
USERNAME_ALREADY_TAKEN = 'username-already-taken',
|
|
18
|
+
EMAIL_ADDRESS_ALREADY_TAKEN = 'email-address-already-taken',
|
|
19
|
+
USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED = 'user-email-verification-token-not-provided',
|
|
20
|
+
USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST = 'user-email-verification-token-does-not-exist',
|
|
21
|
+
USER_EMAIL_VERIFICATION_PROCESS_FAILED = 'user-email-verification-process-failed',
|
|
22
|
+
USER_EMAIL_ALREADY_VERIFIED = 'user-email-already-verified',
|
|
23
|
+
INVALID_CREDENTIALS = 'invalid-credentials',
|
|
24
|
+
USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST = 'user-password-renewal-token-does-not-exist',
|
|
25
|
+
|
|
26
|
+
// REQUESTS
|
|
27
|
+
INVALID_REQUEST_BODY = 'invalid-request-body',
|
|
28
|
+
|
|
29
|
+
// DATABASE
|
|
30
|
+
DB_ERROR = 'db-error',
|
|
31
|
+
DB_NO_DOCUMENT_MATCHES_FILTER = 'db-no-document-matches-filter'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const source = Errors.Register.makeSource({
|
|
35
|
+
[Codes.UNKNOWN_ERROR]: {
|
|
36
|
+
message: 'An unknown error occured',
|
|
37
|
+
detailsMaker: (details?: string) => details
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
[Codes.USER_NOT_AUTHENTICATED]: {
|
|
41
|
+
message: 'User must be authenticated.',
|
|
42
|
+
detailsMaker: (details?: string) => details
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
[Codes.USER_NOT_AUTHORIZED]: {
|
|
46
|
+
message: 'User has not sufficient permissions',
|
|
47
|
+
detailsMaker: (details?: string) => details
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
[Codes.USER_DOES_NOT_EXIST]: {
|
|
51
|
+
message: 'Impossible to retreive user information',
|
|
52
|
+
detailsMaker: (userId: string) => ({ userId })
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
[Codes.USER_EMAIL_DOES_NOT_EXIST]: {
|
|
56
|
+
message: 'This email is not tied to any user account.',
|
|
57
|
+
detailsMaker: (email: string) => ({ email })
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
[Codes.USERNAME_ALREADY_TAKEN]: {
|
|
61
|
+
message: 'This username is already taken',
|
|
62
|
+
detailsMaker: (username: string) => ({ username })
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
[Codes.EMAIL_ADDRESS_ALREADY_TAKEN]: {
|
|
66
|
+
message: 'This email address is already taken',
|
|
67
|
+
detailsMaker: (email: string) => ({ email })
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
[Codes.USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED]: {
|
|
71
|
+
message: 'This endpoint expects a token in the URL to process the validation',
|
|
72
|
+
detailsMaker: () => undefined
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
[Codes.USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST]: {
|
|
76
|
+
message: 'The email verification token provided does not exist',
|
|
77
|
+
detailsMaker: () => undefined
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
[Codes.USER_EMAIL_VERIFICATION_PROCESS_FAILED]: {
|
|
81
|
+
message: 'Something went wrong while updating the user verification status',
|
|
82
|
+
detailsMaker: (details: string) => details
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
[Codes.USER_EMAIL_ALREADY_VERIFIED]: {
|
|
86
|
+
message: 'This user already verified their email',
|
|
87
|
+
detailsMaker: (email: string) => ({ email })
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
[Codes.INVALID_CREDENTIALS]: {
|
|
91
|
+
message: 'The provided credentials are invalid',
|
|
92
|
+
detailsMaker: () => undefined
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
[Codes.USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST]: {
|
|
96
|
+
message: 'The password renewal token provided does not exist',
|
|
97
|
+
detailsMaker: (email: string, token: string) => ({ email, token })
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
[Codes.INVALID_REQUEST_BODY]: {
|
|
101
|
+
message: 'The request body provided could not be used for the operation',
|
|
102
|
+
detailsMaker: ((body: any, error: string) => ({ body, error }))
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
[Codes.DB_ERROR]: {
|
|
106
|
+
message: 'The database returned an error',
|
|
107
|
+
detailsMaker: ((dbError: string) => ({ dbError }))
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
[Codes.DB_NO_DOCUMENT_MATCHES_FILTER]: {
|
|
111
|
+
message: 'No document matches the provided filter',
|
|
112
|
+
detailsMaker: <M extends MongooseModel<any>>(
|
|
113
|
+
model: M,
|
|
114
|
+
filter: MongooseFilterQuery<M extends MongooseModel<infer DocType> ? DocType : never>
|
|
115
|
+
) => ({ collection: model.name, filter })
|
|
116
|
+
}
|
|
117
|
+
} as const)
|
|
118
|
+
|
|
119
|
+
export const register = Errors.Register.from(source)
|
|
120
|
+
|
|
121
|
+
export type ErrorData<Code extends Codes> = Errors.Register.ErrorData<typeof source, Code>
|
|
122
|
+
|
|
123
|
+
export function makeFailureOutcome<Code extends Codes> (
|
|
124
|
+
code: Code,
|
|
125
|
+
...params: Errors.Register.DetailsMakerParams<typeof register.source, Code>) {
|
|
126
|
+
const errData = register.getErrorData(code, ...params)
|
|
127
|
+
return Outcome.makeFailure(errData)
|
|
128
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import cookieParser from 'cookie-parser'
|
|
4
|
+
import cors from 'cors'
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import logger from 'morgan'
|
|
7
|
+
import * as Api from './api'
|
|
8
|
+
import { makeRouter } from './api/utils'
|
|
9
|
+
import * as Jwt from './jwt'
|
|
10
|
+
import * as Database from'./database'
|
|
11
|
+
import * as Init from './init'
|
|
12
|
+
import { serve } from './www'
|
|
13
|
+
|
|
14
|
+
// Prepare to gracefully shutdon the server if needed
|
|
15
|
+
Init.captureTerminationSignals()
|
|
16
|
+
|
|
17
|
+
// App setup
|
|
18
|
+
const app = express()
|
|
19
|
+
app.use(cors()) // [WIP] configure cors and allowed origins
|
|
20
|
+
app.use(logger('dev')) // [WIP] configure logger for prod an all
|
|
21
|
+
app.use(express.json())
|
|
22
|
+
app.use(express.urlencoded({ extended: false }))
|
|
23
|
+
app.use(cookieParser())
|
|
24
|
+
|
|
25
|
+
// Static files
|
|
26
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
const PUBLIC = path.join(ROOT, 'public')
|
|
28
|
+
app.use(express.static(PUBLIC))
|
|
29
|
+
|
|
30
|
+
// Authentication
|
|
31
|
+
app.use(Jwt.authenticate)
|
|
32
|
+
|
|
33
|
+
// API endpoints
|
|
34
|
+
app.use('/', makeRouter(Api.Endpoints))
|
|
35
|
+
|
|
36
|
+
// Database connection
|
|
37
|
+
await Database.connect()
|
|
38
|
+
await Init.ensureRootUser()
|
|
39
|
+
await Init.scheduleCronTasks()
|
|
40
|
+
|
|
41
|
+
// Start server
|
|
42
|
+
serve(app)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import Agenda from 'agenda'
|
|
2
|
+
import { Types as MongooseTypes } from 'mongoose'
|
|
3
|
+
import { Logs } from '@design-edito/tools/agnostic/misc/logs'
|
|
4
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
5
|
+
import { Duration } from '@design-edito/tools/agnostic/time/duration'
|
|
6
|
+
import * as Database from'../database'
|
|
7
|
+
import {
|
|
8
|
+
ROOT_USER_ID,
|
|
9
|
+
ROOT_USER_PWD,
|
|
10
|
+
ROOT_USER_EMAIL,
|
|
11
|
+
ROOT_USER_NAME,
|
|
12
|
+
DB_RESERVED_AGENDA_JOBS_COLLECTION_NAME,
|
|
13
|
+
REFRESH_TOKEN_EXPIRATION_SECONDS
|
|
14
|
+
} from '../env'
|
|
15
|
+
import * as User from '../schema/user'
|
|
16
|
+
import { UserEmailValidationTokenModel } from '../schema/user-email-validation-token'
|
|
17
|
+
import { UserPasswordRenewalTokenModel } from '../schema/user-password-renewal-token'
|
|
18
|
+
import { UserRevokedTokenModel } from '../schema/user-revoked-auth-token'
|
|
19
|
+
|
|
20
|
+
export async function ensureRootUser () {
|
|
21
|
+
console.log(Logs.styles.info('Ensuring ROOT user...'))
|
|
22
|
+
try {
|
|
23
|
+
// [WIP] use Database helpers here ?
|
|
24
|
+
const rootUsersViaRole = await User.LocalUserModel.find({ role: User.Role.ROOT }).exec()
|
|
25
|
+
const rootUsersViaId = await User.LocalUserModel.find({ _id: ROOT_USER_ID }).exec()
|
|
26
|
+
const rootUsersWithDuplicates = [...rootUsersViaRole, ...rootUsersViaId]
|
|
27
|
+
const rootUsersIdsSet = new Set(rootUsersWithDuplicates.map(usr => usr._id.toString()))
|
|
28
|
+
|
|
29
|
+
// No ROOT user found
|
|
30
|
+
if (rootUsersIdsSet.size === 0) {
|
|
31
|
+
const newRootUser = new User.LocalUserModel({
|
|
32
|
+
_id: new MongooseTypes.ObjectId(ROOT_USER_ID),
|
|
33
|
+
username: ROOT_USER_NAME,
|
|
34
|
+
email: ROOT_USER_EMAIL,
|
|
35
|
+
password: ROOT_USER_PWD,
|
|
36
|
+
role: User.Role.ROOT,
|
|
37
|
+
status: User.Status.ACTIVE,
|
|
38
|
+
verified: true
|
|
39
|
+
})
|
|
40
|
+
const rootUserInserted = await Database.insertOne<User.ILocalUser>(
|
|
41
|
+
User.LocalUserModel,
|
|
42
|
+
newRootUser,
|
|
43
|
+
{ initiatorId: ROOT_USER_ID }
|
|
44
|
+
)
|
|
45
|
+
if (!rootUserInserted.success) {
|
|
46
|
+
console.log(Logs.styles.danger('Something went wrong while creating ROOT user. Shutting down'))
|
|
47
|
+
const { message, details } = rootUserInserted.error
|
|
48
|
+
console.log(Logs.styles.error(message))
|
|
49
|
+
console.log(Logs.styles.error(details.dbError))
|
|
50
|
+
return await shutdown(1)
|
|
51
|
+
}
|
|
52
|
+
console.log(Logs.styles.important('ROOT user created'))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Many ROOT users found
|
|
57
|
+
if (rootUsersIdsSet.size > 1) {
|
|
58
|
+
console.log(Logs.styles.danger('Multiple ROOT users found, shutting down'))
|
|
59
|
+
return await shutdown(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Single ROOT user found
|
|
63
|
+
console.log(Logs.styles.regular('ROOT user exists'))
|
|
64
|
+
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.log(Logs.styles.danger('An unknown error occured while initing the database'))
|
|
67
|
+
console.log(Logs.styles.error(unknownToString(err)))
|
|
68
|
+
return await shutdown(1)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let agenda: Agenda | null = null
|
|
73
|
+
async function startAgenda (): Promise<void> {
|
|
74
|
+
if (agenda !== null) return;
|
|
75
|
+
agenda = new Agenda()
|
|
76
|
+
agenda.database(Database.connectionString, DB_RESERVED_AGENDA_JOBS_COLLECTION_NAME)
|
|
77
|
+
agenda.processEvery('1 minute')
|
|
78
|
+
await new Promise<void>((resolve, reject) => {
|
|
79
|
+
agenda!.once('ready', resolve)
|
|
80
|
+
agenda!.once('error', reject)
|
|
81
|
+
})
|
|
82
|
+
await agenda.start()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function scheduleCronTask (name: string, interval: string, worker: () => void | Promise<void>) {
|
|
86
|
+
try {
|
|
87
|
+
await startAgenda()
|
|
88
|
+
agenda!.define(name, worker)
|
|
89
|
+
await agenda!.every(interval, name)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.log(Logs.styles.danger('An unknown error occured while initing the cron tasks'))
|
|
92
|
+
console.log(Logs.styles.error(unknownToString(err)))
|
|
93
|
+
return await shutdown(1)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function scheduleCronTasks () {
|
|
98
|
+
await scheduleCronTask('ENSURE_ROOT_USER', '1 minute', () => ensureRootUser())
|
|
99
|
+
await scheduleCronTask('CLEANUP_USER_EMAIL_VERIFICATION_TOKENS', '5 minutes', async () => {
|
|
100
|
+
const now = new Date()
|
|
101
|
+
const deleted = await Database.deleteMany(
|
|
102
|
+
UserEmailValidationTokenModel,
|
|
103
|
+
{ expiresOn: { $lt: now } },
|
|
104
|
+
{ initiatorId: ROOT_USER_ID }
|
|
105
|
+
)
|
|
106
|
+
if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
|
|
107
|
+
return
|
|
108
|
+
})
|
|
109
|
+
await scheduleCronTask('CLEANUP_USER_REVOKED_AUTH_TOKENS', '5 minutes', async () => {
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
const refreshTokenLifetimeSeconds = REFRESH_TOKEN_EXPIRATION_SECONDS
|
|
112
|
+
const refreshTokenLifetimeMs = Duration.seconds(refreshTokenLifetimeSeconds).toMilliseconds()
|
|
113
|
+
const oldEnoughRevokedTokensThreshold = new Date(now - (refreshTokenLifetimeMs * 5))
|
|
114
|
+
const deleted = await Database.deleteMany(
|
|
115
|
+
UserRevokedTokenModel,
|
|
116
|
+
{ revokedOn: { $lt: oldEnoughRevokedTokensThreshold } },
|
|
117
|
+
{ initiatorId: ROOT_USER_ID }
|
|
118
|
+
)
|
|
119
|
+
if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
|
|
120
|
+
return
|
|
121
|
+
})
|
|
122
|
+
await scheduleCronTask('CLEANUP_USER_PASSWORD_RENEWAL_TOKENS', '5 minutes', async () => {
|
|
123
|
+
const now = new Date()
|
|
124
|
+
const deleted = await Database.deleteMany(
|
|
125
|
+
UserPasswordRenewalTokenModel,
|
|
126
|
+
{ expiresOn: { $lt: now } },
|
|
127
|
+
{ initiatorId: ROOT_USER_ID }
|
|
128
|
+
)
|
|
129
|
+
if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
|
|
130
|
+
return
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function captureTerminationSignals () {
|
|
135
|
+
process.on('SIGINT', async () => {
|
|
136
|
+
console.log('SIGINT received.')
|
|
137
|
+
await shutdown(0)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
process.on('SIGTERM', async () => {
|
|
141
|
+
console.log('SIGTERM received.')
|
|
142
|
+
await shutdown(0)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function gracefulShutdown () {
|
|
147
|
+
if (agenda !== null) await agenda.stop()
|
|
148
|
+
await Database.disconnect()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function shutdown (code: number) {
|
|
152
|
+
console.log('Running cleanup tasks...')
|
|
153
|
+
await gracefulShutdown()
|
|
154
|
+
console.log('Cleanup complete. Exiting...')
|
|
155
|
+
process.exit(code)
|
|
156
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express'
|
|
2
|
+
import jwt from 'jsonwebtoken'
|
|
3
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
4
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
5
|
+
import * as Database from '../database'
|
|
6
|
+
import {
|
|
7
|
+
MODE,
|
|
8
|
+
JWT_SECRET,
|
|
9
|
+
ACCESS_TOKEN_EXPIRATION_SECONDS,
|
|
10
|
+
REFRESH_TOKEN_EXPIRATION_SECONDS,
|
|
11
|
+
ACCESS_TOKEN_RENEWAL_THRESHOLD_SECONDS,
|
|
12
|
+
ACCESS_TOKEN_MAX_REFRESH_COUNT,
|
|
13
|
+
ROOT_USER_ID
|
|
14
|
+
} from '../env'
|
|
15
|
+
import { BaseUserModel } from '../schema/user'
|
|
16
|
+
import { UserRevokedTokenModel } from '../schema/user-revoked-auth-token'
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
namespace Express {
|
|
20
|
+
interface Locals {
|
|
21
|
+
accessTokenSigned?: string
|
|
22
|
+
accessTokenPayload?: Payload
|
|
23
|
+
refreshTokenSigned?: string
|
|
24
|
+
refreshTokenPayload?: Payload
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Payload = {
|
|
30
|
+
userId: string
|
|
31
|
+
exp: number
|
|
32
|
+
refreshCount: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isValidPayload (payload: unknown): payload is Payload {
|
|
36
|
+
if (!isNonNullObject(payload)) return false
|
|
37
|
+
if (!('userId' in payload)) return false
|
|
38
|
+
if (typeof payload.userId !== 'string') return false
|
|
39
|
+
if (!('exp' in payload)) return false
|
|
40
|
+
if (typeof payload.exp !== 'number') return false
|
|
41
|
+
if (!('refreshCount' in payload)) return false
|
|
42
|
+
if (typeof payload.refreshCount !== 'number') return false
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function generateAccessToken (userId: string, refreshCount: number) {
|
|
47
|
+
const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
|
|
48
|
+
if (!foundUser.success) return foundUser
|
|
49
|
+
const payload: Omit<Payload, 'exp'> = { userId, refreshCount }
|
|
50
|
+
const options: jwt.SignOptions = { expiresIn: ACCESS_TOKEN_EXPIRATION_SECONDS }
|
|
51
|
+
return Outcome.makeSuccess(jwt.sign(payload, JWT_SECRET, options))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function generateRefreshToken (userId: string, refreshCount: number) {
|
|
55
|
+
const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
|
|
56
|
+
if (!foundUser.success) return foundUser
|
|
57
|
+
const payload: Omit<Payload, 'exp'> = { userId, refreshCount }
|
|
58
|
+
const options: jwt.SignOptions = { expiresIn: REFRESH_TOKEN_EXPIRATION_SECONDS }
|
|
59
|
+
return Outcome.makeSuccess(jwt.sign(payload, JWT_SECRET, options))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function authenticate (req: Request, res: Response, next: NextFunction) {
|
|
63
|
+
const authHeader = req.headers.authorization
|
|
64
|
+
if (authHeader === undefined || !authHeader.startsWith('Bearer ')) return next()
|
|
65
|
+
const token = authHeader.split(' ')[1]
|
|
66
|
+
if (token === undefined) return next()
|
|
67
|
+
try {
|
|
68
|
+
const decoded = jwt.verify(token, JWT_SECRET)
|
|
69
|
+
const validated = isValidPayload(decoded)
|
|
70
|
+
if (!validated) return next()
|
|
71
|
+
const now = Math.floor(Date.now() / 1000)
|
|
72
|
+
const { userId, exp, refreshCount } = decoded
|
|
73
|
+
const accessTokenAlmostStale = exp - now < ACCESS_TOKEN_RENEWAL_THRESHOLD_SECONDS
|
|
74
|
+
const accessTokenCanRenew = refreshCount < ACCESS_TOKEN_MAX_REFRESH_COUNT
|
|
75
|
+
const accessTokenShouldRenew = accessTokenAlmostStale && accessTokenCanRenew
|
|
76
|
+
if (accessTokenShouldRenew) {
|
|
77
|
+
const foundRevokedToken = await Database.findOne(
|
|
78
|
+
UserRevokedTokenModel,
|
|
79
|
+
{ value: token },
|
|
80
|
+
{ initiatorId: ROOT_USER_ID }
|
|
81
|
+
)
|
|
82
|
+
if (foundRevokedToken.success) return next()
|
|
83
|
+
const newToken = generateAccessToken(userId, refreshCount + 1)
|
|
84
|
+
res.setHeader('Authorization', `Bearer ${newToken}`)
|
|
85
|
+
}
|
|
86
|
+
res.locals.accessTokenSigned = token
|
|
87
|
+
res.locals.accessTokenPayload = decoded
|
|
88
|
+
return next()
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// [WIP] Maybe log something ?
|
|
91
|
+
return next()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function attachAccessTokenToRes (res: Response, token: string) {
|
|
96
|
+
res.setHeader('Authorization', `Bearer ${token}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function attachRefreshTokenToRes (res: Response, token: string) {
|
|
100
|
+
res.cookie('refreshToken', token, {
|
|
101
|
+
httpOnly: true,
|
|
102
|
+
secure: MODE === 'production',
|
|
103
|
+
sameSite: 'strict'
|
|
104
|
+
})
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<script type="module" src="./index.js"></script>
|
|
7
|
+
<link rel="stylesheet" href="./index.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<h1>LM-Publisher</h1>
|
|
11
|
+
|
|
12
|
+
<button class="create-random-project">Create random project</button>
|
|
13
|
+
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const $responses = document.querySelector('.requests')
|
|
2
|
+
const $createRandomProject = document.querySelector('.create-random-project')
|
|
3
|
+
|
|
4
|
+
async function fetchAndLog (url, method, body) {
|
|
5
|
+
const id = Math.random().toString(36).slice(2)
|
|
6
|
+
console.log('%cSending request...', 'font-weight: 600')
|
|
7
|
+
console.log('id:', id)
|
|
8
|
+
console.log(`${method}:`, url)
|
|
9
|
+
const response = await window.fetch(url, {
|
|
10
|
+
method,
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
headers: { 'Content-Type': 'application/json' }
|
|
13
|
+
})
|
|
14
|
+
const json = await response.json()
|
|
15
|
+
console.log('%cReceived response', 'font-weight: 600')
|
|
16
|
+
console.log('id:', id)
|
|
17
|
+
console.log(`${method}:`, url)
|
|
18
|
+
console.log('body:', body)
|
|
19
|
+
console.log('response:', response)
|
|
20
|
+
console.log('json:', json)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
$createRandomProject.addEventListener('click', () => {
|
|
24
|
+
fetchAndLog('http://localhost:3000/projects/create', 'POST', {
|
|
25
|
+
name: 'Mon premier projet :\')',
|
|
26
|
+
publicationTargetDate: Date.now(),
|
|
27
|
+
sourcesIds: [],
|
|
28
|
+
articlesIds: [],
|
|
29
|
+
assetsIds: [],
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import Zlib from 'node:zlib'
|
|
2
|
+
import {
|
|
3
|
+
Types as MongooseTypes,
|
|
4
|
+
Schema as MongooseSchema,
|
|
5
|
+
Document as MongooseDocument,
|
|
6
|
+
CallbackWithoutResultAndOptionalError as MongooseCallbackWithoutResultAndOptionalError
|
|
7
|
+
} from 'mongoose'
|
|
8
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
9
|
+
import { DocumentWithLocals, QueryWithLocals } from '../../database'
|
|
10
|
+
|
|
11
|
+
// Document
|
|
12
|
+
export type IHistoryItem = {
|
|
13
|
+
updationTime: Date
|
|
14
|
+
updaterId: MongooseTypes.ObjectId,
|
|
15
|
+
stringifiedDocument: string
|
|
16
|
+
}
|
|
17
|
+
export type IHistory = Array<IHistoryItem>
|
|
18
|
+
|
|
19
|
+
export type WithHistory<T> = T & { _history: IHistory }
|
|
20
|
+
export type WithoutHistory<T> = Omit<T, '_history'> & { _history?: undefined }
|
|
21
|
+
|
|
22
|
+
// Schema
|
|
23
|
+
export const HistoryItemSchema = new MongooseSchema<IHistoryItem>({
|
|
24
|
+
updationTime: { type: Date, required: true },
|
|
25
|
+
updaterId: { type: MongooseSchema.ObjectId, required: true },
|
|
26
|
+
stringifiedDocument: { type: String, required: true }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export async function makeHistoryItem (document: MongooseDocument, initiatorObjectId: MongooseTypes.ObjectId): Promise<IHistoryItem> {
|
|
30
|
+
const docAsObject = document.toObject()
|
|
31
|
+
const coreEntries = Object.entries(docAsObject).filter(([key]) => !key.startsWith('_'))
|
|
32
|
+
const strippedToCore = Object.fromEntries(coreEntries)
|
|
33
|
+
const stringified = JSON.stringify(strippedToCore)
|
|
34
|
+
const compressed = await new Promise<Buffer>((resolve, reject) => {
|
|
35
|
+
Zlib.gzip(stringified, (err, compressed) => {
|
|
36
|
+
if (err !== null) return reject(err)
|
|
37
|
+
return resolve(compressed)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
const base64 = compressed.toString('base64')
|
|
41
|
+
const historyItem: IHistoryItem = {
|
|
42
|
+
updationTime: new Date(),
|
|
43
|
+
updaterId: initiatorObjectId,
|
|
44
|
+
stringifiedDocument: base64
|
|
45
|
+
}
|
|
46
|
+
return historyItem
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function withHistory<T extends Object> (inputSchema: MongooseSchema<T>): MongooseSchema<WithHistory<T>> {
|
|
50
|
+
const schema = inputSchema.clone() as MongooseSchema<WithHistory<T>>
|
|
51
|
+
schema.add(new MongooseSchema<WithHistory<{}>>({
|
|
52
|
+
_history: {
|
|
53
|
+
type: [HistoryItemSchema],
|
|
54
|
+
required: true,
|
|
55
|
+
default: []
|
|
56
|
+
}
|
|
57
|
+
}))
|
|
58
|
+
schema.pre('save', handleSave)
|
|
59
|
+
schema.pre('insertMany', handleInsertMany)
|
|
60
|
+
schema.pre('updateOne', handleUpdate)
|
|
61
|
+
schema.pre('findOneAndUpdate', handleUpdate)
|
|
62
|
+
|
|
63
|
+
async function handleSave (
|
|
64
|
+
this: WithHistory<DocumentWithLocals<{}>>,
|
|
65
|
+
next: MongooseCallbackWithoutResultAndOptionalError
|
|
66
|
+
) {
|
|
67
|
+
const context = this.$locals?.context
|
|
68
|
+
const initiatorId = context?.initiatorId ?? null
|
|
69
|
+
const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null
|
|
70
|
+
if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for save operation.'))
|
|
71
|
+
try {
|
|
72
|
+
const historyItem = await makeHistoryItem(this, initiatorObjectId)
|
|
73
|
+
if (this.isNew) { this._history = [historyItem] }
|
|
74
|
+
else this._history.push(historyItem)
|
|
75
|
+
next()
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const errStr = unknownToString(err)
|
|
78
|
+
next(new Error(errStr))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleInsertMany (
|
|
83
|
+
next: MongooseCallbackWithoutResultAndOptionalError,
|
|
84
|
+
docs: WithHistory<DocumentWithLocals<{}>>[]
|
|
85
|
+
) {
|
|
86
|
+
try {
|
|
87
|
+
for (const doc of docs) {
|
|
88
|
+
const context = doc.$locals?.context
|
|
89
|
+
const initiatorId = context?.initiatorId ?? null
|
|
90
|
+
const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null
|
|
91
|
+
if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for insertMany operation.'))
|
|
92
|
+
const historyItem = await makeHistoryItem(doc, initiatorObjectId)
|
|
93
|
+
doc._history = [historyItem]
|
|
94
|
+
}
|
|
95
|
+
next()
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const errStr = unknownToString(err)
|
|
98
|
+
next(new Error(errStr))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handleUpdate (
|
|
103
|
+
this: QueryWithLocals<WithHistory<{}>>,
|
|
104
|
+
next: MongooseCallbackWithoutResultAndOptionalError
|
|
105
|
+
) {
|
|
106
|
+
const context = this.getOptions().$locals?.context;
|
|
107
|
+
const initiatorId = context?.initiatorId ?? null;
|
|
108
|
+
const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null;
|
|
109
|
+
if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for update operation.'))
|
|
110
|
+
try {
|
|
111
|
+
const docPromise = this.model.findOne(this.getFilter()).exec() as Promise<WithHistory<MongooseDocument> | null>
|
|
112
|
+
const doc = await docPromise
|
|
113
|
+
if (doc === null) return next(new Error('Document not found for update.'))
|
|
114
|
+
const historyItem = await makeHistoryItem(doc, initiatorObjectId)
|
|
115
|
+
doc._history.push(historyItem)
|
|
116
|
+
this.set({ _history: doc._history })
|
|
117
|
+
next()
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const errStr = unknownToString(err)
|
|
120
|
+
next(new Error(errStr))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return schema
|
|
124
|
+
}
|