mirror of
https://github.com/standardnotes/server.git
synced 2026-01-11 19:56:40 +00:00
chore: release latest code (#1075)
This commit is contained in:
parent
578ce0e74e
commit
0d82819cba
32 changed files with 350 additions and 29 deletions
1
.pnp.cjs
generated
1
.pnp.cjs
generated
|
|
@ -6817,6 +6817,7 @@ const RAW_RUNTIME_STATE =
|
|||
["@standardnotes/scheduler-server", "workspace:packages/scheduler"],\
|
||||
["@aws-sdk/client-sns", "npm:3.484.0"],\
|
||||
["@aws-sdk/client-sqs", "npm:3.484.0"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
|
||||
|
|
|
|||
Binary file not shown.
BIN
.yarn/cache/fsevents-patch-19706e7e35-10.zip
vendored
BIN
.yarn/cache/fsevents-patch-19706e7e35-10.zip
vendored
Binary file not shown.
|
|
@ -5,6 +5,8 @@ import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
|||
import { StatisticMeasureName } from '../Statistics/StatisticMeasureName'
|
||||
import { Period } from '../Time/Period'
|
||||
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
const countActiveUsers = (measureName: string, data: any): { yesterday: number; last30Days: number } => {
|
||||
const totalActiveUsersLast30DaysIncludingToday = data.statisticsOverTime.find(
|
||||
(a: { name: string; period: number }) => a.name === measureName && a.period === 27,
|
||||
|
|
@ -567,7 +569,7 @@ export const html = (data: any, timer: TimerInterface) => {
|
|||
const totalActivePlusUsers = countActiveUsers(StatisticMeasureName.NAMES.ActivePlusUsers, data)
|
||||
const totalActiveProUsers = countActiveUsers(StatisticMeasureName.NAMES.ActiveProUsers, data)
|
||||
|
||||
return ` <div>
|
||||
return safeHtml` <div>
|
||||
<p>Hello,</p>
|
||||
<p>
|
||||
<strong>Here are some statistics from yesterday:</strong>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"engines": {
|
||||
"node": ">=18.0.0 <21.0.0"
|
||||
},
|
||||
"description": "Auth Server",
|
||||
"description": "Auth Server for SN",
|
||||
"main": "dist/src/index.js",
|
||||
"typings": "dist/src/index.d.ts",
|
||||
"author": "Karol Sójko <karol@standardnotes.com>",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => `<div class="sn-component">
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => safeHtml`<div class="sn-component">
|
||||
<div class="sk-panel static">
|
||||
<div class="sk-panel-content">
|
||||
<div class="sk-panel-section">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (inviterIdentifier: string, inviteUuid: string) => `<p>Hello,</p>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (inviterIdentifier: string, inviteUuid: string) => safeHtml`<p>Hello,</p>
|
||||
<p>You've been invited to join a Standard Notes premium subscription at no cost. ${inviterIdentifier} has invited you to share the benefits of their subscription plan.</p>
|
||||
<p>
|
||||
<a href='https://app.standardnotes.com/?accept-subscription-invite=${inviteUuid}'>Accept Invite</a>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (newEmail: string) => `
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (newEmail: string) => safeHtml`
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>We are writing to inform you that your request to update your email address has been successfully processed. The email address associated with your Standard Notes account has now been changed to the following:</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = () => `
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = () => safeHtml`
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>You've been invited to join a shared vault. This shared workspace will help you collaborate and securely manage notes and files.</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (email: string, device: string, browser: string, timeAndDate: string) => `
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (email: string, device: string, browser: string, timeAndDate: string) => safeHtml`
|
||||
<div>
|
||||
<p>Hello,</p>
|
||||
<p>We've detected a new sign-in to your account ${email}</p>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ describe('SettingCrypter', () => {
|
|||
let userRepository: UserRepositoryInterface
|
||||
let crypter: CrypterInterface
|
||||
let user: User
|
||||
const encryptedValue =
|
||||
'{"version":1,"encrypted":{"iv":"foobar","tag":"foobar","aad":"","ciphertext":"foobar","encoding":"utf-8"}}'
|
||||
|
||||
const createDecrypter = () => new SettingCrypter(userRepository, crypter)
|
||||
|
||||
|
|
@ -32,14 +34,14 @@ describe('SettingCrypter', () => {
|
|||
it('should encrypt a string value', async () => {
|
||||
const string = 'decrypted'
|
||||
|
||||
crypter.encryptForUser = jest.fn().mockReturnValue('encrypted')
|
||||
crypter.encryptForUser = jest.fn().mockReturnValue(encryptedValue)
|
||||
|
||||
const encrypted = await createDecrypter().encryptValue(
|
||||
string,
|
||||
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
)
|
||||
|
||||
expect(encrypted).toEqual('encrypted')
|
||||
expect(encrypted).toEqual(encryptedValue)
|
||||
})
|
||||
|
||||
it('should return null when trying to encrypt a null value', async () => {
|
||||
|
|
@ -67,7 +69,7 @@ describe('SettingCrypter', () => {
|
|||
it('should decrypt an encrypted value of a setting', async () => {
|
||||
const setting = Setting.create({
|
||||
name: SettingName.NAMES.ListedAuthorSecrets,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
sensitive: false,
|
||||
|
|
@ -107,10 +109,25 @@ describe('SettingCrypter', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('should return unencrypted value if the setting has unencrypted value but the encryption version indicates otherwise', async () => {
|
||||
const setting = Setting.create({
|
||||
name: SettingName.NAMES.ListedAuthorSecrets,
|
||||
value: 'test',
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
sensitive: false,
|
||||
timestamps: Timestamps.create(123, 123).getValue(),
|
||||
}).getValue()
|
||||
|
||||
expect(await createDecrypter().decryptSettingValue(setting, '00000000-0000-0000-0000-000000000000')).toEqual(
|
||||
'test',
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw if the user could not be found', async () => {
|
||||
const setting = Setting.create({
|
||||
name: SettingName.NAMES.ListedAuthorSecrets,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
sensitive: false,
|
||||
|
|
@ -131,7 +148,7 @@ describe('SettingCrypter', () => {
|
|||
it('should throw if the user uuid is invalid', async () => {
|
||||
const setting = Setting.create({
|
||||
name: SettingName.NAMES.ListedAuthorSecrets,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
sensitive: false,
|
||||
|
|
@ -153,7 +170,7 @@ describe('SettingCrypter', () => {
|
|||
it('should decrypt an encrypted value of a setting', async () => {
|
||||
const setting = SubscriptionSetting.create({
|
||||
name: SettingName.NAMES.ExtensionKey,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
sensitive: true,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
|
|
@ -198,7 +215,7 @@ describe('SettingCrypter', () => {
|
|||
it('should throw if the user could not be found', async () => {
|
||||
const setting = SubscriptionSetting.create({
|
||||
name: SettingName.NAMES.ExtensionKey,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
sensitive: true,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
|
|
@ -219,7 +236,7 @@ describe('SettingCrypter', () => {
|
|||
it('should throw if the user uuid is invalid', async () => {
|
||||
const setting = SubscriptionSetting.create({
|
||||
name: SettingName.NAMES.ExtensionKey,
|
||||
value: 'encrypted',
|
||||
value: encryptedValue,
|
||||
sensitive: true,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
|
||||
|
|
|
|||
|
|
@ -52,9 +52,22 @@ export class SettingCrypter implements SettingCrypterInterface {
|
|||
throw new Error(`Could not find user with uuid: ${userUuid.value}`)
|
||||
}
|
||||
|
||||
if (!this.isValidJSONSubjectForDecryption(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return this.crypter.decryptForUser(value, user)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private isValidJSONSubjectForDecryption(value: string): boolean {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class ActivatePremiumFeatures implements UseCaseInterface<string> {
|
|||
) {}
|
||||
|
||||
async execute(dto: ActivatePremiumFeaturesDTO): Promise<Result<string>> {
|
||||
const usernameOrError = Username.create(dto.username)
|
||||
const usernameOrError = Username.create(dto.username, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
return Result.fail(usernameOrError.getError())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class GetUserKeyParams implements UseCaseInterface {
|
|||
async execute(dto: GetUserKeyParamsDTO): Promise<GetUserKeyParamsResponse> {
|
||||
let user: User | null = null
|
||||
if (dto.email !== undefined) {
|
||||
const usernameOrError = Username.create(dto.email)
|
||||
const usernameOrError = Username.create(dto.email, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
throw Error(usernameOrError.getError())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export class SignIn implements UseCaseInterface {
|
|||
}
|
||||
const apiVersion = apiVersionOrError.getValue()
|
||||
|
||||
const usernameOrError = Username.create(dto.email)
|
||||
/** Skip validation which was newly added in 2025, to allow existing users to continue to sign in */
|
||||
const usernameOrError = Username.create(dto.email, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class VerifyMFA implements UseCaseInterface {
|
|||
|
||||
async execute(dto: VerifyMFADTO): Promise<VerifyMFAResponse> {
|
||||
try {
|
||||
const usernameOrError = Username.create(dto.email)
|
||||
const usernameOrError = Username.create(dto.email, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export class BaseUsersController extends BaseHttpController {
|
|||
400,
|
||||
)
|
||||
}
|
||||
const usernameOrError = Username.create(locals.user.email)
|
||||
const usernameOrError = Username.create(locals.user.email, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
return this.json(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class LockMiddleware extends BaseMiddleware {
|
|||
async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
let identifier = request.body.email ?? request.body.username
|
||||
const usernameOrError = Username.create(identifier)
|
||||
const usernameOrError = Username.create(identifier, { skipValidation: true })
|
||||
if (usernameOrError.isFailed()) {
|
||||
response.status(400).send({
|
||||
error: {
|
||||
|
|
|
|||
16
packages/common/src/Domain/Html/SafeHtml.spec.ts
Normal file
16
packages/common/src/Domain/Html/SafeHtml.spec.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { safeHtml } from './SafeHtml'
|
||||
|
||||
describe('html', () => {
|
||||
test('Should escape html from user input', () => {
|
||||
const basicStringInput = '<h1>User</h1>'
|
||||
const numberValue = 10
|
||||
expect(safeHtml`<p>Hello world, ${basicStringInput} ${numberValue}</p>`).toBe(
|
||||
'<p>Hello world, <h1>User</h1> 10</p>',
|
||||
)
|
||||
})
|
||||
|
||||
test('Should join arrays and escape', () => {
|
||||
const arrayOfStrings = ['<h1>User</h1>', '<p>Test</p>']
|
||||
expect(safeHtml`<p>${arrayOfStrings}</p>`).toBe('<p><h1>User</h1><p>Test</p></p>')
|
||||
})
|
||||
})
|
||||
32
packages/common/src/Domain/Html/SafeHtml.ts
Normal file
32
packages/common/src/Domain/Html/SafeHtml.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
function escapeHTML(str: string) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/`/g, '`')
|
||||
}
|
||||
|
||||
/**
|
||||
* Template handler that does basic HTML escaping for substitutions
|
||||
*/
|
||||
export function safeHtml(literals: TemplateStringsArray, ...substitutions: Array<string | number | string[]>) {
|
||||
const raw = literals.raw
|
||||
|
||||
let result = raw[0]
|
||||
|
||||
for (let index = 1; index < raw.length; index++) {
|
||||
const literal = raw[index]
|
||||
let substitution = substitutions[index - 1]
|
||||
if (Array.isArray(substitution)) {
|
||||
substitution = substitution.join('')
|
||||
} else if (typeof substitution === 'number') {
|
||||
substitution = substitution.toString()
|
||||
}
|
||||
substitution = escapeHTML(substitution)
|
||||
result += substitution + literal
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -18,3 +18,4 @@ export * from './Subscription/SubscriptionName'
|
|||
export * from './Type/Either'
|
||||
export * from './Type/Only'
|
||||
export * from './User/UserRequestType'
|
||||
export * from './Html/SafeHtml'
|
||||
|
|
|
|||
|
|
@ -31,4 +31,178 @@ describe('Username', () => {
|
|||
|
||||
expect(value.isPotentiallyAPrivateUsernameAccount()).toBeFalsy()
|
||||
})
|
||||
|
||||
describe('username validation', () => {
|
||||
describe('valid usernames', () => {
|
||||
const validUsernames = [
|
||||
'johndoe',
|
||||
'john_doe',
|
||||
'john.doe',
|
||||
'john-doe',
|
||||
'john@doe',
|
||||
'john123',
|
||||
'j0hn.d0e',
|
||||
'user+name',
|
||||
'username_with_single_underscore',
|
||||
// Maximum length
|
||||
'a'.repeat(100),
|
||||
// Minimum length
|
||||
'abc',
|
||||
// Email variants
|
||||
'user@example.com',
|
||||
'user.name@example.com',
|
||||
'user+test@example.com',
|
||||
'user-name@example.com',
|
||||
'user_name@example.com',
|
||||
'user123@example.com',
|
||||
'u@example.com',
|
||||
'user@sub.example.com',
|
||||
'user@example-site.com',
|
||||
'user@example.co.uk',
|
||||
'user+test+extra@example.com',
|
||||
'user-name-extra@example.com',
|
||||
'user.name.extra@example.com',
|
||||
'user.name+test-extra@example.com',
|
||||
'user-name.test+extra@example.com',
|
||||
]
|
||||
|
||||
test.each(validUsernames)('should accept valid username: %s', (username) => {
|
||||
const result = Username.create(username)
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe(username.toLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid usernames', () => {
|
||||
const invalidUsernames = [
|
||||
// Length violations
|
||||
['ab', 'Username must be at least 3 characters long'],
|
||||
['a'.repeat(101), 'Username cannot be longer than 100 characters'],
|
||||
|
||||
// Empty or whitespace
|
||||
['', 'Username cannot be empty'],
|
||||
[' ', 'Username cannot be empty'],
|
||||
[' ', 'Username cannot be empty'],
|
||||
|
||||
// Whitespace in username
|
||||
['user name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user\tname', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user\nname', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
|
||||
// Starting/ending with special characters
|
||||
[
|
||||
'_username',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
[
|
||||
'username_',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
[
|
||||
'.username',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
[
|
||||
'username.',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
|
||||
// Consecutive special characters
|
||||
[
|
||||
'user__name',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
[
|
||||
'user..name',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
[
|
||||
'user.-name',
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
],
|
||||
|
||||
// Invalid special characters
|
||||
['user{name}', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user#name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user$name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user&name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user*name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user!name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user/name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user\\name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user"name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
["user'name", 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user:name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
['user=name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
|
||||
// HTML-like patterns
|
||||
['<script>', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
[
|
||||
'user<tag>name',
|
||||
'Username can only contain letters, numbers, and the following special characters: . _ - @ +',
|
||||
],
|
||||
['<>', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
|
||||
|
||||
// Invalid types
|
||||
[undefined, 'Username must be a string'],
|
||||
[null, 'Username must be a string'],
|
||||
[123, 'Username must be a string'],
|
||||
[true, 'Username must be a string'],
|
||||
[{}, 'Username must be a string'],
|
||||
[[], 'Username must be a string'],
|
||||
] as [unknown, string][]
|
||||
|
||||
test.each(invalidUsernames)('should reject invalid username: %s', (username, expectedError) => {
|
||||
const result = Username.create(username as string)
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toBe(expectedError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case sensitivity and trimming', () => {
|
||||
it('should convert username to lowercase', () => {
|
||||
const result = Username.create('UserName')
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe('username')
|
||||
})
|
||||
|
||||
it('should trim whitespace from username', () => {
|
||||
const result = Username.create(' username ')
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe('username')
|
||||
})
|
||||
|
||||
it('should trim and convert to lowercase', () => {
|
||||
const result = Username.create(' UserName ')
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe('username')
|
||||
})
|
||||
})
|
||||
|
||||
describe('special patterns', () => {
|
||||
it('should handle single special characters correctly', () => {
|
||||
const result = Username.create('user_name.test-email@domain+plus')
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe('user_name.test-email@domain+plus')
|
||||
})
|
||||
|
||||
it('should reject consecutive special characters', () => {
|
||||
const consecutivePatterns = ['user__name', 'user..name', 'user.-name', 'user@_name', 'user+.name']
|
||||
|
||||
consecutivePatterns.forEach((username) => {
|
||||
const result = Username.create(username)
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toBe(
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow special characters separated by alphanumeric characters', () => {
|
||||
const result = Username.create('user_name.test-email@domain+plus')
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue().value).toBe('user_name.test-email@domain+plus')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable no-inline-comments */
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { UsernameProps } from './UsernameProps'
|
||||
|
|
@ -12,7 +13,7 @@ export class Username extends ValueObject<UsernameProps> {
|
|||
super(props)
|
||||
}
|
||||
|
||||
static create(username: string): Result<Username> {
|
||||
static create(username: string, options: { skipValidation?: boolean } = {}): Result<Username> {
|
||||
if (Validator.isString(username).isFailed()) {
|
||||
return Result.fail<Username>('Username must be a string')
|
||||
}
|
||||
|
|
@ -23,6 +24,43 @@ export class Username extends ValueObject<UsernameProps> {
|
|||
return Result.fail<Username>('Username cannot be empty')
|
||||
}
|
||||
|
||||
if (!options.skipValidation) {
|
||||
// Username format validation
|
||||
// Allows: letters, numbers, underscore, period, hyphen, @, plus
|
||||
// More restrictive set of special characters for better security
|
||||
const usernameRegex = /^[a-zA-Z0-9._\-@+]+$/
|
||||
if (!usernameRegex.test(trimmedAndLowerCasedUsername)) {
|
||||
return Result.fail<Username>(
|
||||
'Username can only contain letters, numbers, and the following special characters: . _ - @ +',
|
||||
)
|
||||
}
|
||||
|
||||
// Check minimum and maximum length
|
||||
if (trimmedAndLowerCasedUsername.length < 3) {
|
||||
return Result.fail<Username>('Username must be at least 3 characters long')
|
||||
}
|
||||
|
||||
if (trimmedAndLowerCasedUsername.length > 100) {
|
||||
return Result.fail<Username>('Username cannot be longer than 100 characters')
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
const dangerousPatterns = [
|
||||
/\s/, // Any whitespace
|
||||
/^[._\-@+]/, // Cannot start with special chars
|
||||
/[._\-@+]$/, // Cannot end with special chars
|
||||
/[._\-@+]{2,}/, // No consecutive special chars
|
||||
]
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(trimmedAndLowerCasedUsername)) {
|
||||
return Result.fail<Username>(
|
||||
'Username cannot start or end with special characters, and cannot have consecutive special characters',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok<Username>(new Username({ value: trimmedAndLowerCasedUsername }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-sns": "^3.484.0",
|
||||
"@aws-sdk/client-sqs": "^3.484.0",
|
||||
"@standardnotes/common": "workspace:^",
|
||||
"@standardnotes/domain-core": "workspace:^",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
"@standardnotes/domain-events-infra": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = `<div>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = safeHtml`<div>
|
||||
<p>
|
||||
Did you know you can enable daily email backups for your account? This <strong>free</strong> feature sends an
|
||||
email to your inbox with an encrypted backup file including all your notes and tags.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (registrationDate: string, annualPlusPrice: number, annualProPrice: number) => `<div>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (registrationDate: string, annualPlusPrice: number, annualProPrice: number) => safeHtml`<div>
|
||||
<p>Hi there,</p>
|
||||
<p>
|
||||
We hope you've been finding great use out of Standard Notes. We built Standard Notes to be a secure place for
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = `<div>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = safeHtml`<div>
|
||||
<p>
|
||||
We're truly sad to see you leave. Our mission is simple: build the best, most private, and most secure
|
||||
note-taking app available. It's clear we've fallen short of your expectations somewhere along the way.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = `<p>Hello,</p>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = safeHtml`<p>Hello,</p>
|
||||
<p>We recently tried backing up your data to <strong>Dropbox</strong>, but an issue prevented us from doing so.</p>
|
||||
<p>
|
||||
The usual cause is an expired or revoked token from your sync provider. Please follow
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = (email: string) => `
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = (email: string) => safeHtml`
|
||||
<p>
|
||||
Your encrypted data backup is attached for ${email}. You can import this file using
|
||||
the Standard Notes web or desktop app, or by using the offline decryption script available at
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = `<p>Hello,</p>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = safeHtml`<p>Hello,</p>
|
||||
<p>We recently tried backing up your data to <strong>Google Drive Sync</strong>, but an issue prevented us from
|
||||
doing
|
||||
so.</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const html = `<p>Hello,</p>
|
||||
import { safeHtml } from '@standardnotes/common'
|
||||
|
||||
export const html = safeHtml`<p>Hello,</p>
|
||||
<p>We recently tried backing up your data to <strong>OneDrive Sync</strong>, but an issue prevented us from doing
|
||||
so.</p>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -6164,6 +6164,7 @@ __metadata:
|
|||
dependencies:
|
||||
"@aws-sdk/client-sns": "npm:^3.484.0"
|
||||
"@aws-sdk/client-sqs": "npm:^3.484.0"
|
||||
"@standardnotes/common": "workspace:^"
|
||||
"@standardnotes/domain-core": "workspace:^"
|
||||
"@standardnotes/domain-events": "workspace:*"
|
||||
"@standardnotes/domain-events-infra": "workspace:*"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue