Compare commits

..

3 Commits

Author SHA1 Message Date
Aman Harwara
84cbeaf3be chore: release latest code 2025-04-29 13:07:38 +02:00
Karol Sójko
578ce0e74e Fix puppeteer sandbox issue (#1077)
Force merging for the rules to apply in basic workflows
2025-04-29 13:05:12 +02:00
Aman Harwara
532be7c358 chore: upgrade github actions (#1076)
Some checks failed
E2E Test Suite / E2E (push) Has been cancelled
Publish Packages / build (push) Has been cancelled
Publish Packages / lint (push) Has been cancelled
Publish Packages / test (push) Has been cancelled
Publish Packages / E2E Base Suite (push) Has been cancelled
Publish Packages / E2E Vaults Suite (push) Has been cancelled
Publish Packages / Publish Self Hosting Docker Image (push) Has been cancelled
Publish Packages / publish-services (push) Has been cancelled
2025-04-25 20:03:20 +05:30
36 changed files with 367 additions and 46 deletions

View File

@@ -46,7 +46,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -94,11 +94,11 @@ jobs:
run: for i in {1..30}; do curl -s http://localhost:3123/healthcheck && break || sleep 1; done run: for i in {1..30}; do curl -s http://localhost:3123/healthcheck && break || sleep 1; done
- name: Run E2E Test Suite - name: Run E2E Test Suite
run: yarn dlx mocha-headless-chrome --timeout 3600000 -f http://localhost:9001/mocha/test.html?suite=${{ inputs.suite }} run: yarn dlx mocha-headless-chrome --timeout 3600000 -a no-sandbox -a disable-setuid-sandbox -f http://localhost:9001/mocha/test.html?suite=${{ inputs.suite }}
- name: Archive failed run logs - name: Archive failed run logs
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: home-server-failure-logs-${{ inputs.suite }}-${{ matrix.db_type }}-${{ matrix.cache_type }} name: home-server-failure-logs-${{ inputs.suite }}-${{ matrix.db_type }}-${{ matrix.cache_type }}
retention-days: 5 retention-days: 5

View File

@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -57,11 +57,11 @@ jobs:
run: docker/is-available.sh http://localhost:3123 $(pwd)/logs run: docker/is-available.sh http://localhost:3123 $(pwd)/logs
- name: Run E2E Test Suite - name: Run E2E Test Suite
run: yarn dlx mocha-headless-chrome --timeout 3600000 -f http://localhost:9001/mocha/test.html?suite=${{ inputs.suite }} run: yarn dlx mocha-headless-chrome --timeout 3600000 -a no-sandbox -a disable-setuid-sandbox -f http://localhost:9001/mocha/test.html?suite=${{ inputs.suite }}
- name: Archive failed run logs - name: Archive failed run logs
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: self-hosted-failure-logs-${{ inputs.suite }} name: self-hosted-failure-logs-${{ inputs.suite }}
retention-days: 5 retention-days: 5

View File

@@ -13,14 +13,14 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
key: ${{ runner.os }}-build-${{ github.sha }} key: ${{ runner.os }}-build-${{ github.sha }}
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -41,14 +41,14 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
key: ${{ runner.os }}-build-${{ github.sha }} key: ${{ runner.os }}-build-${{ github.sha }}
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -73,14 +73,14 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
key: ${{ runner.os }}-build-${{ github.sha }} key: ${{ runner.os }}-build-${{ github.sha }}
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'

View File

@@ -16,7 +16,7 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
@@ -44,7 +44,7 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
@@ -76,7 +76,7 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
@@ -134,7 +134,7 @@ jobs:
- name: Cache build - name: Cache build
id: cache-build id: cache-build
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
packages/**/dist packages/**/dist
@@ -154,7 +154,7 @@ jobs:
git_commit_gpgsign: true git_commit_gpgsign: true
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'

1
.pnp.cjs generated
View File

@@ -6817,6 +6817,7 @@ const RAW_RUNTIME_STATE =
["@standardnotes/scheduler-server", "workspace:packages/scheduler"],\ ["@standardnotes/scheduler-server", "workspace:packages/scheduler"],\
["@aws-sdk/client-sns", "npm:3.484.0"],\ ["@aws-sdk/client-sns", "npm:3.484.0"],\
["@aws-sdk/client-sqs", "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-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\ ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\ ["@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 { StatisticMeasureName } from '../Statistics/StatisticMeasureName'
import { Period } from '../Time/Period' import { Period } from '../Time/Period'
import { safeHtml } from '@standardnotes/common'
const countActiveUsers = (measureName: string, data: any): { yesterday: number; last30Days: number } => { const countActiveUsers = (measureName: string, data: any): { yesterday: number; last30Days: number } => {
const totalActiveUsersLast30DaysIncludingToday = data.statisticsOverTime.find( const totalActiveUsersLast30DaysIncludingToday = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === measureName && a.period === 27, (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 totalActivePlusUsers = countActiveUsers(StatisticMeasureName.NAMES.ActivePlusUsers, data)
const totalActiveProUsers = countActiveUsers(StatisticMeasureName.NAMES.ActiveProUsers, data) const totalActiveProUsers = countActiveUsers(StatisticMeasureName.NAMES.ActiveProUsers, data)
return ` <div> return safeHtml` <div>
<p>Hello,</p> <p>Hello,</p>
<p> <p>
<strong>Here are some statistics from yesterday:</strong> <strong>Here are some statistics from yesterday:</strong>

View File

@@ -4,7 +4,7 @@
"engines": { "engines": {
"node": ">=18.0.0 <21.0.0" "node": ">=18.0.0 <21.0.0"
}, },
"description": "Auth Server", "description": "Auth Server for SN",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"typings": "dist/src/index.d.ts", "typings": "dist/src/index.d.ts",
"author": "Karol Sójko <karol@standardnotes.com>", "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 static">
<div class="sk-panel-content"> <div class="sk-panel-content">
<div class="sk-panel-section"> <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>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> <p>
<a href='https://app.standardnotes.com/?accept-subscription-invite=${inviteUuid}'>Accept Invite</a> <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>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> <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>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> <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> <div>
<p>Hello,</p> <p>Hello,</p>
<p>We've detected a new sign-in to your account ${email}</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 userRepository: UserRepositoryInterface
let crypter: CrypterInterface let crypter: CrypterInterface
let user: User let user: User
const encryptedValue =
'{"version":1,"encrypted":{"iv":"foobar","tag":"foobar","aad":"","ciphertext":"foobar","encoding":"utf-8"}}'
const createDecrypter = () => new SettingCrypter(userRepository, crypter) const createDecrypter = () => new SettingCrypter(userRepository, crypter)
@@ -32,14 +34,14 @@ describe('SettingCrypter', () => {
it('should encrypt a string value', async () => { it('should encrypt a string value', async () => {
const string = 'decrypted' const string = 'decrypted'
crypter.encryptForUser = jest.fn().mockReturnValue('encrypted') crypter.encryptForUser = jest.fn().mockReturnValue(encryptedValue)
const encrypted = await createDecrypter().encryptValue( const encrypted = await createDecrypter().encryptValue(
string, string,
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), 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 () => { 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 () => { it('should decrypt an encrypted value of a setting', async () => {
const setting = Setting.create({ const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets, name: SettingName.NAMES.ListedAuthorSecrets,
value: 'encrypted', value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false, 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 () => { it('should throw if the user could not be found', async () => {
const setting = Setting.create({ const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets, name: SettingName.NAMES.ListedAuthorSecrets,
value: 'encrypted', value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false, sensitive: false,
@@ -131,7 +148,7 @@ describe('SettingCrypter', () => {
it('should throw if the user uuid is invalid', async () => { it('should throw if the user uuid is invalid', async () => {
const setting = Setting.create({ const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets, name: SettingName.NAMES.ListedAuthorSecrets,
value: 'encrypted', value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false, sensitive: false,
@@ -153,7 +170,7 @@ describe('SettingCrypter', () => {
it('should decrypt an encrypted value of a setting', async () => { it('should decrypt an encrypted value of a setting', async () => {
const setting = SubscriptionSetting.create({ const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey, name: SettingName.NAMES.ExtensionKey,
value: 'encrypted', value: encryptedValue,
sensitive: true, sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), 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 () => { it('should throw if the user could not be found', async () => {
const setting = SubscriptionSetting.create({ const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey, name: SettingName.NAMES.ExtensionKey,
value: 'encrypted', value: encryptedValue,
sensitive: true, sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), 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 () => { it('should throw if the user uuid is invalid', async () => {
const setting = SubscriptionSetting.create({ const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey, name: SettingName.NAMES.ExtensionKey,
value: 'encrypted', value: encryptedValue,
sensitive: true, sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default, serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), 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}`) throw new Error(`Could not find user with uuid: ${userUuid.value}`)
} }
if (!this.isValidJSONSubjectForDecryption(value)) {
return value
}
return this.crypter.decryptForUser(value, user) return this.crypter.decryptForUser(value, user)
} }
return value 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>> { async execute(dto: ActivatePremiumFeaturesDTO): Promise<Result<string>> {
const usernameOrError = Username.create(dto.username) const usernameOrError = Username.create(dto.username, { skipValidation: true })
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
return Result.fail(usernameOrError.getError()) return Result.fail(usernameOrError.getError())
} }

View File

@@ -22,7 +22,7 @@ export class GetUserKeyParams implements UseCaseInterface {
async execute(dto: GetUserKeyParamsDTO): Promise<GetUserKeyParamsResponse> { async execute(dto: GetUserKeyParamsDTO): Promise<GetUserKeyParamsResponse> {
let user: User | null = null let user: User | null = null
if (dto.email !== undefined) { if (dto.email !== undefined) {
const usernameOrError = Username.create(dto.email) const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
throw Error(usernameOrError.getError()) throw Error(usernameOrError.getError())
} }

View File

@@ -62,7 +62,8 @@ export class SignIn implements UseCaseInterface {
} }
const apiVersion = apiVersionOrError.getValue() 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()) { if (usernameOrError.isFailed()) {
return { return {
success: false, success: false,

View File

@@ -32,7 +32,7 @@ export class VerifyMFA implements UseCaseInterface {
async execute(dto: VerifyMFADTO): Promise<VerifyMFAResponse> { async execute(dto: VerifyMFADTO): Promise<VerifyMFAResponse> {
try { try {
const usernameOrError = Username.create(dto.email) const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
return { return {
success: false, success: false,

View File

@@ -135,7 +135,7 @@ export class BaseUsersController extends BaseHttpController {
400, 400,
) )
} }
const usernameOrError = Username.create(locals.user.email) const usernameOrError = Username.create(locals.user.email, { skipValidation: true })
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
return this.json( return this.json(
{ {

View File

@@ -19,7 +19,7 @@ export class LockMiddleware extends BaseMiddleware {
async handler(request: Request, response: Response, next: NextFunction): Promise<void> { async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
try { try {
let identifier = request.body.email ?? request.body.username let identifier = request.body.email ?? request.body.username
const usernameOrError = Username.create(identifier) const usernameOrError = Username.create(identifier, { skipValidation: true })
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
response.status(400).send({ response.status(400).send({
error: { 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/Either'
export * from './Type/Only' export * from './Type/Only'
export * from './User/UserRequestType' export * from './User/UserRequestType'
export * from './Html/SafeHtml'

View File

@@ -31,4 +31,178 @@ describe('Username', () => {
expect(value.isPotentiallyAPrivateUsernameAccount()).toBeFalsy() 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 { ValueObject } from '../Core/ValueObject'
import { Result } from '../Core/Result' import { Result } from '../Core/Result'
import { UsernameProps } from './UsernameProps' import { UsernameProps } from './UsernameProps'
@@ -12,7 +13,7 @@ export class Username extends ValueObject<UsernameProps> {
super(props) super(props)
} }
static create(username: string): Result<Username> { static create(username: string, options: { skipValidation?: boolean } = {}): Result<Username> {
if (Validator.isString(username).isFailed()) { if (Validator.isString(username).isFailed()) {
return Result.fail<Username>('Username must be a string') 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') 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 })) return Result.ok<Username>(new Username({ value: trimmedAndLowerCasedUsername }))
} }

View File

@@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-sns": "^3.484.0", "@aws-sdk/client-sns": "^3.484.0",
"@aws-sdk/client-sqs": "^3.484.0", "@aws-sdk/client-sqs": "^3.484.0",
"@standardnotes/common": "workspace:^",
"@standardnotes/domain-core": "workspace:^", "@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*", "@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "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> <p>
Did you know you can enable daily email backups for your account? This <strong>free</strong> feature sends an 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. 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>Hi there,</p>
<p> <p>
We hope you've been finding great use out of Standard Notes. We built Standard Notes to be a secure place for 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> <p>
We're truly sad to see you leave. Our mission is simple: build the best, most private, and most secure 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. 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>We recently tried backing up your data to <strong>Dropbox</strong>, but an issue prevented us from doing so.</p>
<p> <p>
The usual cause is an expired or revoked token from your sync provider. Please follow 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> <p>
Your encrypted data backup is attached for ${email}. You can import this file using 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 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 <p>We recently tried backing up your data to <strong>Google Drive Sync</strong>, but an issue prevented us from
doing doing
so.</p> 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 <p>We recently tried backing up your data to <strong>OneDrive Sync</strong>, but an issue prevented us from doing
so.</p> so.</p>
<p> <p>

View File

@@ -6164,6 +6164,7 @@ __metadata:
dependencies: dependencies:
"@aws-sdk/client-sns": "npm:^3.484.0" "@aws-sdk/client-sns": "npm:^3.484.0"
"@aws-sdk/client-sqs": "npm:^3.484.0" "@aws-sdk/client-sqs": "npm:^3.484.0"
"@standardnotes/common": "workspace:^"
"@standardnotes/domain-core": "workspace:^" "@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events": "workspace:*" "@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*" "@standardnotes/domain-events-infra": "workspace:*"