chore: release latest code (#1075)

This commit is contained in:
Aman Harwara 2025-04-29 17:03:15 +05:30 committed by GitHub
parent 578ce0e74e
commit 0d82819cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 350 additions and 29 deletions

1
.pnp.cjs generated
View file

@ -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.

View file

@ -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>

View file

@ -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>",

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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(),

View file

@ -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
}
}
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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,

View file

@ -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,

View file

@ -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(
{

View file

@ -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: {

View 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, &lt;h1&gt;User&lt;/h1&gt; 10</p>',
)
})
test('Should join arrays and escape', () => {
const arrayOfStrings = ['<h1>User</h1>', '<p>Test</p>']
expect(safeHtml`<p>${arrayOfStrings}</p>`).toBe('<p>&lt;h1&gt;User&lt;/h1&gt;&lt;p&gt;Test&lt;/p&gt;</p>')
})
})

View file

@ -0,0 +1,32 @@
function escapeHTML(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;')
}
/**
* 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
}

View file

@ -18,3 +18,4 @@ export * from './Subscription/SubscriptionName'
export * from './Type/Either'
export * from './Type/Only'
export * from './User/UserRequestType'
export * from './Html/SafeHtml'

View file

@ -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')
})
})
})
})

View file

@ -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 }))
}

View file

@ -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:*",

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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:*"