From 0d43dbef76fea79eee062eeb0a555ac370627b62 Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Thu, 12 Sep 2024 17:10:07 +0200 Subject: [PATCH] feat(url): store presence of password in fragment hash --- .../src/modules/notes/notes.services.ts | 3 - .../modules/notes/pages/view-note.page.tsx | 17 +-- .../e2e/create-and-view-note.e2e.test.ts | 4 - .../e2e/size-limited-note-cration.e2e.test.ts | 1 - .../src/modules/notes/notes.models.test.ts | 2 - .../modules/notes/notes.repository.test.ts | 10 -- .../src/modules/notes/notes.repository.ts | 3 - .../src/modules/notes/notes.routes.ts | 5 +- .../src/modules/notes/notes.types.ts | 4 - .../src/modules/notes/notes.usecases.test.ts | 5 - .../tasks/delete-expired-notes.tasks.test.ts | 9 -- packages/lib/src/api/api.client.ts | 2 +- packages/lib/src/index.node.ts | 3 + packages/lib/src/index.web.ts | 3 + packages/lib/src/notes/notes.models.test.ts | 101 +++++++++++++++++- packages/lib/src/notes/notes.models.ts | 64 +++++++++-- packages/lib/src/notes/notes.services.ts | 4 - packages/lib/src/notes/notes.usecases.ts | 12 ++- 18 files changed, 183 insertions(+), 69 deletions(-) diff --git a/packages/app-client/src/modules/notes/notes.services.ts b/packages/app-client/src/modules/notes/notes.services.ts index 79a2ff4d..81bf348d 100644 --- a/packages/app-client/src/modules/notes/notes.services.ts +++ b/packages/app-client/src/modules/notes/notes.services.ts @@ -4,14 +4,12 @@ export { storeNote, fetchNoteById }; async function storeNote({ payload, - isPasswordProtected, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat, }: { payload: string; - isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean; encryptionAlgorithm: string; @@ -22,7 +20,6 @@ async function storeNote({ method: 'POST', body: { payload, - isPasswordProtected, ttlInSeconds, deleteAfterReading, serializationFormat, diff --git a/packages/app-client/src/modules/notes/pages/view-note.page.tsx b/packages/app-client/src/modules/notes/pages/view-note.page.tsx index e84e578f..5144fcc7 100644 --- a/packages/app-client/src/modules/notes/pages/view-note.page.tsx +++ b/packages/app-client/src/modules/notes/pages/view-note.page.tsx @@ -1,6 +1,6 @@ import { useLocation, useParams } from '@solidjs/router'; import { type Component, Match, Show, Switch, createSignal, onMount } from 'solid-js'; -import { decryptNote, noteAssetsToFiles } from '@enclosed/lib'; +import { decryptNote, noteAssetsToFiles, parseNoteUrlHashFragment } from '@enclosed/lib'; import JSZip from 'jszip'; import { formatBytes, safely } from '@corentinth/chisels'; import { fetchNoteById } from '../notes.services'; @@ -74,14 +74,19 @@ export const ViewNotePage: Component = () => { const [fileAssets, setFileAssets] = createSignal([]); const [isDownloadingAllLoading, setIsDownloadingAllLoading] = createSignal(false); - const getEncryptionKey = () => location.hash.slice(1); + const parseHashFragment = () => parseNoteUrlHashFragment({ hashFragment: location.hash }); + const getEncryptionKey = () => parseHashFragment().encryptionKey; + const getIsPasswordProtected = () => parseHashFragment().isPasswordProtected; onMount(async () => { - if (!getEncryptionKey()) { + const encryptionKey = getEncryptionKey(); + + if (!encryptionKey) { setError({ title: 'Invalid note URL', description: 'This note URL is invalid. Please make sure you are using the correct URL.', }); + return; } const [fetchedNote, fetchError] = await safely(fetchNoteById({ noteId: params.noteId })); @@ -114,7 +119,7 @@ export const ViewNotePage: Component = () => { setNote(note); - if (note.isPasswordProtected) { + if (getIsPasswordProtected()) { return; } @@ -122,7 +127,7 @@ export const ViewNotePage: Component = () => { const [decryptedNoteResult, decryptionError] = await safely(decryptNote({ encryptedPayload: payload, - encryptionKey: getEncryptionKey(), + encryptionKey, encryptionAlgorithm: encryptionAlgorithm as 'aes-256-gcm', // TODO: export type from lib serializationFormat: serializationFormat as 'cbor-array', // TODO: export type from lib })); @@ -209,7 +214,7 @@ export const ViewNotePage: Component = () => { )} - + diff --git a/packages/app-server/src/modules/notes/e2e/create-and-view-note.e2e.test.ts b/packages/app-server/src/modules/notes/e2e/create-and-view-note.e2e.test.ts index 9a3b044d..73d1b9a3 100644 --- a/packages/app-server/src/modules/notes/e2e/create-and-view-note.e2e.test.ts +++ b/packages/app-server/src/modules/notes/e2e/create-and-view-note.e2e.test.ts @@ -14,7 +14,6 @@ describe('e2e', () => { const note = { payload: '', - isPasswordProtected: false, deleteAfterReading: false, ttlInSeconds: 600, encryptionAlgorithm: 'aes-256-gcm', @@ -45,7 +44,6 @@ describe('e2e', () => { expect(omit(retrievedNote, 'expirationDate')).to.eql({ payload: '', - isPasswordProtected: false, encryptionAlgorithm: 'aes-256-gcm', serializationFormat: 'cbor-array', }); @@ -64,7 +62,6 @@ describe('e2e', () => { method: 'POST', body: JSON.stringify({ payload: '', - isPasswordProtected: false, deleteAfterReading: false, ttlInSeconds: 600, encryptionAlgorithm: 'aes-256-gcm', @@ -102,7 +99,6 @@ describe('e2e', () => { method: 'POST', body: JSON.stringify({ payload: '', - isPasswordProtected: false, deleteAfterReading: false, ttlInSeconds: 600, encryptionAlgorithm: 'foo', // <- invalid encryption algorithm diff --git a/packages/app-server/src/modules/notes/e2e/size-limited-note-cration.e2e.test.ts b/packages/app-server/src/modules/notes/e2e/size-limited-note-cration.e2e.test.ts index 399d0c24..dff77242 100644 --- a/packages/app-server/src/modules/notes/e2e/size-limited-note-cration.e2e.test.ts +++ b/packages/app-server/src/modules/notes/e2e/size-limited-note-cration.e2e.test.ts @@ -18,7 +18,6 @@ describe('e2e', () => { }); const note = { - isPasswordProtected: false, deleteAfterReading: false, ttlInSeconds: 600, payload: 'a'.repeat(1024 * 1024 + 1), diff --git a/packages/app-server/src/modules/notes/notes.models.test.ts b/packages/app-server/src/modules/notes/notes.models.test.ts index a6bdbe9c..c5dd5247 100644 --- a/packages/app-server/src/modules/notes/notes.models.test.ts +++ b/packages/app-server/src/modules/notes/notes.models.test.ts @@ -33,7 +33,6 @@ describe('notes models', () => { test('the expiration date and the flag stating the note should be deleted after read are omitted when formatting a note for the API', () => { const storedNote = { payload: '', - isPasswordProtected: false, expirationDate: new Date('2024-01-01T00:00:00Z'), deleteAfterReading: false, serializationFormat: 'cbor-array', @@ -43,7 +42,6 @@ describe('notes models', () => { expect(formatNoteForApi({ note: storedNote })).to.eql({ apiNote: { payload: '', - isPasswordProtected: false, encryptionAlgorithm: 'aes-256-gcm', serializationFormat: 'cbor-array', }, diff --git a/packages/app-server/src/modules/notes/notes.repository.test.ts b/packages/app-server/src/modules/notes/notes.repository.test.ts index 0c401d1b..0e65de59 100644 --- a/packages/app-server/src/modules/notes/notes.repository.test.ts +++ b/packages/app-server/src/modules/notes/notes.repository.test.ts @@ -10,7 +10,6 @@ describe('notes repository', () => { storage.setItem('note-1', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -23,7 +22,6 @@ describe('notes repository', () => { expect(note).to.eql({ payload: '', - isPasswordProtected: false, expirationDate: new Date('2024-01-01T00:01:00.000Z'), deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -36,7 +34,6 @@ describe('notes repository', () => { storage.setItem('note-1', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -55,7 +52,6 @@ describe('notes repository', () => { storage.setItem('note-1', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -64,7 +60,6 @@ describe('notes repository', () => { storage.setItem('note-2', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -93,7 +88,6 @@ describe('notes repository', () => { storage.setItem('note-1', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: true, encryptionAlgorithm: 'aes-256-gcm', @@ -116,7 +110,6 @@ describe('notes repository', () => { storage.setItem('note-1', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -125,7 +118,6 @@ describe('notes repository', () => { storage.setItem('note-2', { payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', @@ -163,7 +155,6 @@ describe('notes repository', () => { const { noteId } = await saveNote({ payload: '', - isPasswordProtected: false, ttlInSeconds: 60, deleteAfterReading: false, generateNoteId: () => `note-${noteIdIndex++}`, @@ -177,7 +168,6 @@ describe('notes repository', () => { expect(await storage.getItem('note-1')).to.eql({ payload: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, encryptionAlgorithm: 'aes-256-gcm', diff --git a/packages/app-server/src/modules/notes/notes.repository.ts b/packages/app-server/src/modules/notes/notes.repository.ts index fb036f90..f4b3362a 100644 --- a/packages/app-server/src/modules/notes/notes.repository.ts +++ b/packages/app-server/src/modules/notes/notes.repository.ts @@ -30,7 +30,6 @@ async function getNotesIds({ storage }: { storage: Storage }) { async function saveNote( { payload, - isPasswordProtected, ttlInSeconds, deleteAfterReading, storage, @@ -41,7 +40,6 @@ async function saveNote( }: { payload: string; - isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean; storage: Storage; @@ -58,7 +56,6 @@ async function saveNote( noteId, { payload, - isPasswordProtected, expirationDate: expirationDate.toISOString(), deleteAfterReading, encryptionAlgorithm, diff --git a/packages/app-server/src/modules/notes/notes.routes.ts b/packages/app-server/src/modules/notes/notes.routes.ts index 0b51ae9a..b22222c7 100644 --- a/packages/app-server/src/modules/notes/notes.routes.ts +++ b/packages/app-server/src/modules/notes/notes.routes.ts @@ -37,7 +37,6 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { validateJsonBody( z.object({ payload: z.string(), - isPasswordProtected: z.boolean(), deleteAfterReading: z.boolean(), ttlInSeconds: z.number() .min(TEN_MINUTES_IN_SECONDS) @@ -62,12 +61,12 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { }, async (context) => { - const { payload, isPasswordProtected, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat } = context.req.valid('json'); + const { payload, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat } = context.req.valid('json'); const storage = context.get('storage'); const notesRepository = createNoteRepository({ storage }); - const { noteId } = await notesRepository.saveNote({ payload, isPasswordProtected, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat }); + const { noteId } = await notesRepository.saveNote({ payload, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat }); return context.json({ noteId }); }, diff --git a/packages/app-server/src/modules/notes/notes.types.ts b/packages/app-server/src/modules/notes/notes.types.ts index ee461b8d..4fe0e516 100644 --- a/packages/app-server/src/modules/notes/notes.types.ts +++ b/packages/app-server/src/modules/notes/notes.types.ts @@ -12,8 +12,4 @@ export type StoredNote = { // compressionAlgorithm: string // keyDerivationAlgorithm: string; - /** - * @deprecated Password protection information should be stored in the url - */ - isPasswordProtected: boolean; }; diff --git a/packages/app-server/src/modules/notes/notes.usecases.test.ts b/packages/app-server/src/modules/notes/notes.usecases.test.ts index 889ce82c..35a37d77 100644 --- a/packages/app-server/src/modules/notes/notes.usecases.test.ts +++ b/packages/app-server/src/modules/notes/notes.usecases.test.ts @@ -11,7 +11,6 @@ describe('notes usecases', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, }); @@ -24,7 +23,6 @@ describe('notes usecases', () => { expect(note).to.eql({ content: '', - isPasswordProtected: false, deleteAfterReading: false, expirationDate: new Date('2024-01-01T00:01:00.000Z'), }); @@ -35,7 +33,6 @@ describe('notes usecases', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, }); @@ -54,7 +51,6 @@ describe('notes usecases', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:00:00.000Z', deleteAfterReading: false, }); @@ -73,7 +69,6 @@ describe('notes usecases', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-02T00:00:00.000Z', deleteAfterReading: true, }); diff --git a/packages/app-server/src/modules/notes/tasks/delete-expired-notes.tasks.test.ts b/packages/app-server/src/modules/notes/tasks/delete-expired-notes.tasks.test.ts index 30da7422..02aa3a99 100644 --- a/packages/app-server/src/modules/notes/tasks/delete-expired-notes.tasks.test.ts +++ b/packages/app-server/src/modules/notes/tasks/delete-expired-notes.tasks.test.ts @@ -12,21 +12,18 @@ describe('delete-expired-notes tasks', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, }); storage.setItem('note-2', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-02T00:00:00.000Z', deleteAfterReading: false, }); storage.setItem('note-3', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-03T00:00:00.000Z', deleteAfterReading: false, }); @@ -49,21 +46,18 @@ describe('delete-expired-notes tasks', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, }); storage.setItem('note-2', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-02T00:00:00.000Z', deleteAfterReading: false, }); storage.setItem('note-3', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-03T00:00:00.000Z', deleteAfterReading: false, }); @@ -100,21 +94,18 @@ describe('delete-expired-notes tasks', () => { storage.setItem('note-1', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-01T00:01:00.000Z', deleteAfterReading: false, }); storage.setItem('note-2', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-02T00:00:00.000Z', deleteAfterReading: false, }); storage.setItem('note-3', { content: '', - isPasswordProtected: false, expirationDate: '2024-01-03T00:00:00.000Z', deleteAfterReading: false, }); diff --git a/packages/lib/src/api/api.client.ts b/packages/lib/src/api/api.client.ts index be367297..8a080b37 100644 --- a/packages/lib/src/api/api.client.ts +++ b/packages/lib/src/api/api.client.ts @@ -36,7 +36,7 @@ async function apiClient({ baseURL: baseUrl, onResponseError: async ({ response }) => { throw Object.assign( - new Error('Failed to fetch note'), + new Error('Failed to make API request'), { response: { status: response.status, diff --git a/packages/lib/src/index.node.ts b/packages/lib/src/index.node.ts index f37289c1..a86844dd 100644 --- a/packages/lib/src/index.node.ts +++ b/packages/lib/src/index.node.ts @@ -6,6 +6,7 @@ import { isApiClientErrorWithCode, isApiClientErrorWithStatusCode } from './api/ import { fileToNoteAsset, filesToNoteAssets, noteAssetToFile, noteAssetsToFiles } from './files/files.models'; import { encryptionAlgorithms, getDecryptionMethod, getEncryptionMethod } from './crypto/node/encryption-algorithms/encryption-algorithms.registry'; import { serializationFormats } from './crypto/serialization/serialization.registry'; +import { createNoteUrlHashFragment, parseNoteUrlHashFragment } from './notes/notes.models'; const { encryptNote } = createEncryptUsecase({ generateBaseKey, deriveMasterKey, getEncryptionMethod }); const { decryptNote } = createDecryptUsecase({ deriveMasterKey, getDecryptionMethod }); @@ -28,4 +29,6 @@ export { parseNoteUrl, serializationFormats, encryptionAlgorithms, + parseNoteUrlHashFragment, + createNoteUrlHashFragment, }; diff --git a/packages/lib/src/index.web.ts b/packages/lib/src/index.web.ts index 75f16166..4812cab1 100644 --- a/packages/lib/src/index.web.ts +++ b/packages/lib/src/index.web.ts @@ -6,6 +6,7 @@ import { isApiClientErrorWithCode, isApiClientErrorWithStatusCode } from './api/ import { fileToNoteAsset, filesToNoteAssets, noteAssetToFile, noteAssetsToFiles } from './files/files.models'; import { encryptionAlgorithms, getDecryptionMethod, getEncryptionMethod } from './crypto/web/encryption-algorithms/encryption-algorithms.registry'; import { serializationFormats } from './crypto/serialization/serialization.registry'; +import { createNoteUrlHashFragment, parseNoteUrlHashFragment } from './notes/notes.models'; const { encryptNote } = createEncryptUsecase({ generateBaseKey, deriveMasterKey, getEncryptionMethod }); const { decryptNote } = createDecryptUsecase({ deriveMasterKey, getDecryptionMethod }); @@ -28,4 +29,6 @@ export { parseNoteUrl, serializationFormats, encryptionAlgorithms, + parseNoteUrlHashFragment, + createNoteUrlHashFragment, }; diff --git a/packages/lib/src/notes/notes.models.test.ts b/packages/lib/src/notes/notes.models.test.ts index 8d8a5075..725121db 100644 --- a/packages/lib/src/notes/notes.models.test.ts +++ b/packages/lib/src/notes/notes.models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { createNoteUrl, parseNoteUrl } from './notes.models'; +import { createNoteUrl, createNoteUrlHashFragment, parseNoteUrl, parseNoteUrlHashFragment } from './notes.models'; describe('note models', () => { describe('createNoteUrl', () => { @@ -11,6 +11,14 @@ describe('note models', () => { }); }); + test('a note protected with a password is indicated in the hash fragment', () => { + expect( + createNoteUrl({ noteId: '123', encryptionKey: 'abc', clientBaseUrl: 'https://example.com', isPasswordProtected: true }), + ).to.eql({ + noteUrl: 'https://example.com/123#pw:abc', + }); + }); + test('trailing slash in the base url is handled', () => { expect( createNoteUrl({ noteId: '123', encryptionKey: 'abc', clientBaseUrl: 'https://example.com/' }), @@ -27,6 +35,17 @@ describe('note models', () => { ).to.eql({ noteId: '123', encryptionKey: 'abc', + isPasswordProtected: false, + }); + }); + + test('a note protected with a password is indicated in the hash fragment', () => { + expect( + parseNoteUrl({ noteUrl: 'https://example.com/123#pw:abc' }), + ).to.eql({ + noteId: '123', + encryptionKey: 'abc', + isPasswordProtected: true, }); }); @@ -36,6 +55,8 @@ describe('note models', () => { ).to.eql({ noteId: '123', encryptionKey: 'abc', + isPasswordProtected: false, + }); }); @@ -45,6 +66,7 @@ describe('note models', () => { ).to.eql({ noteId: '456', encryptionKey: 'abc', + isPasswordProtected: false, }); }); @@ -55,15 +77,88 @@ describe('note models', () => { expect(() => { parseNoteUrl({ noteUrl: 'https://example.com/123#' }); - }).to.throw('Invalid note url'); + }).to.throw('Hash fragment is missing'); expect(() => { parseNoteUrl({ noteUrl: 'https://example.com/123' }); - }).to.throw('Invalid note url'); + }).to.throw('Hash fragment is missing'); expect(() => { parseNoteUrl({ noteUrl: 'https://example.com/' }); }).to.throw('Invalid note url'); }); }); + + describe('creation + parsing', () => { + test('a note url can be parsed back to its original parts', () => { + const { noteUrl } = createNoteUrl({ noteId: '123', encryptionKey: 'abc', clientBaseUrl: 'https://example.com', isPasswordProtected: true }); + const { noteId, encryptionKey, isPasswordProtected } = parseNoteUrl({ noteUrl }); + + expect(noteId).to.equal('123'); + expect(encryptionKey).to.equal('abc'); + expect(isPasswordProtected).to.equal(true); + }); + }); + + describe('createNoteUrlHashFragment', () => { + test('creates a hash fragment from an encryption key', () => { + expect( + createNoteUrlHashFragment({ encryptionKey: 'abc' }), + ).to.equal('abc'); + }); + + test('indicates that the note is password protected', () => { + expect( + createNoteUrlHashFragment({ encryptionKey: 'abc', isPasswordProtected: true }), + ).to.equal('pw:abc'); + }); + }); + + describe('parseNoteUrlHashFragment', () => { + test('parses an encryption key from a hash fragment', () => { + expect( + parseNoteUrlHashFragment({ hashFragment: 'abc' }), + ).to.eql({ + encryptionKey: 'abc', + isPasswordProtected: false, + }); + }); + + test('the fragment can indicate that the note is password protected', () => { + expect( + parseNoteUrlHashFragment({ hashFragment: 'pw:abc' }), + ).to.eql({ + encryptionKey: 'abc', + isPasswordProtected: true, + }); + }); + + test('the fragment can start with a #', () => { + expect( + parseNoteUrlHashFragment({ hashFragment: '#abc' }), + ).to.eql({ + encryptionKey: 'abc', + isPasswordProtected: false, + }); + + expect( + parseNoteUrlHashFragment({ hashFragment: '#pw:abc' }), + ).to.eql({ + encryptionKey: 'abc', + isPasswordProtected: true, + }); + }); + + test('throws an error if the hash fragment has more than two segments', () => { + expect(() => { + parseNoteUrlHashFragment({ hashFragment: 'pw:abc:123' }); + }).to.throw('Invalid hash fragment'); + }); + + test('throws an error if the hash fragment is empty', () => { + expect(() => { + parseNoteUrlHashFragment({ hashFragment: '' }); + }).to.throw('Hash fragment is missing'); + }); + }); }); diff --git a/packages/lib/src/notes/notes.models.ts b/packages/lib/src/notes/notes.models.ts index da79219b..40aaedd6 100644 --- a/packages/lib/src/notes/notes.models.ts +++ b/packages/lib/src/notes/notes.models.ts @@ -1,23 +1,73 @@ -export { createNoteUrl, parseNoteUrl }; +import { isEmpty } from 'lodash-es'; + +export { createNoteUrl, parseNoteUrl, createNoteUrlHashFragment, parseNoteUrlHashFragment }; + +function createNoteUrlHashFragment({ encryptionKey, isPasswordProtected }: { encryptionKey: string; isPasswordProtected?: boolean }) { + const hashFragment = [ + isPasswordProtected && 'pw', + encryptionKey, + ].filter(Boolean).join(':'); + + return hashFragment; +} + +function parseNoteUrlHashFragment({ hashFragment }: { hashFragment: string }) { + const cleanedHashFragment = hashFragment.replace(/^#/, ''); + + if (isEmpty(cleanedHashFragment)) { + throw new Error('Hash fragment is missing'); + } + + const segments = cleanedHashFragment.split(':'); + + if (segments.length === 1) { + return { + isPasswordProtected: false, + encryptionKey: segments[0], + }; + } + + if (segments.length === 2 && segments[0] === 'pw') { + return { + isPasswordProtected: true, + encryptionKey: segments[1], + }; + } + + throw new Error('Invalid hash fragment'); +} + +function createNoteUrl({ + noteId, + encryptionKey, + clientBaseUrl, + isPasswordProtected, +}: { + noteId: string; + encryptionKey: string; + clientBaseUrl: string; + isPasswordProtected?: boolean; +}): { noteUrl: string } { + const hashFragment = createNoteUrlHashFragment({ encryptionKey, isPasswordProtected }); -function createNoteUrl({ noteId, encryptionKey, clientBaseUrl }: { noteId: string; encryptionKey: string; clientBaseUrl: string }): { noteUrl: string } { const url = new URL(`/${noteId}`, clientBaseUrl); - url.hash = encryptionKey; + url.hash = hashFragment; const noteUrl = url.toString(); return { noteUrl }; } -function parseNoteUrl({ noteUrl }: { noteUrl: string }): { noteId: string; encryptionKey: string } { +function parseNoteUrl({ noteUrl }: { noteUrl: string }): { noteId: string; encryptionKey: string; isPasswordProtected: boolean } { const url = new URL(noteUrl); const noteId = url.pathname.split('/').filter(Boolean).pop(); - const encryptionKey = url.hash.replace(/^#/, ''); - if (!noteId || !encryptionKey) { + if (!noteId) { throw new Error('Invalid note url'); } - return { noteId, encryptionKey }; + const { encryptionKey, isPasswordProtected } = parseNoteUrlHashFragment({ hashFragment: url.hash }); + + return { noteId, encryptionKey, isPasswordProtected }; } diff --git a/packages/lib/src/notes/notes.services.ts b/packages/lib/src/notes/notes.services.ts index 98233ef8..d986eb82 100644 --- a/packages/lib/src/notes/notes.services.ts +++ b/packages/lib/src/notes/notes.services.ts @@ -4,7 +4,6 @@ export { storeNote, fetchNote }; async function storeNote({ payload, - isPasswordProtected, ttlInSeconds, deleteAfterReading, apiBaseUrl, @@ -12,7 +11,6 @@ async function storeNote({ encryptionAlgorithm, }: { payload: string; - isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean; apiBaseUrl?: string; @@ -25,7 +23,6 @@ async function storeNote({ method: 'POST', body: { payload, - isPasswordProtected, ttlInSeconds, deleteAfterReading, serializationFormat, @@ -45,7 +42,6 @@ async function fetchNote({ }) { const { note } = await apiClient<{ note: { payload: string; - isPasswordProtected: boolean; }; }>({ path: `api/notes/${noteId}`, baseUrl: apiBaseUrl, diff --git a/packages/lib/src/notes/notes.usecases.ts b/packages/lib/src/notes/notes.usecases.ts index 51de3226..c224ab0a 100644 --- a/packages/lib/src/notes/notes.usecases.ts +++ b/packages/lib/src/notes/notes.usecases.ts @@ -27,7 +27,6 @@ function createEnclosedLib({ // decryptNote: (args: { encryptedContent: string; encryptionKey: string }) => Promise<{ content: string }>; storeNote: (params: { payload: string; - isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean; apiBaseUrl?: string; @@ -64,10 +63,10 @@ function createEnclosedLib({ noteId: string; encryptionKey: string; clientBaseUrl: string; + isPasswordProtected: boolean; }) => { noteUrl: string }; storeNote?: (params: { payload: string; - isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean; encryptionAlgorithm: EncryptionAlgorithm; @@ -75,17 +74,22 @@ function createEnclosedLib({ }) => Promise<{ noteId: string }>; }) => { const { encryptedPayload, encryptionKey } = await encryptNote({ content, password, assets, encryptionAlgorithm, serializationFormat }); + const isPasswordProtected = Boolean(password); const { noteId } = await storeNote({ payload: encryptedPayload, - isPasswordProtected: Boolean(password), ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat, }); - const { noteUrl } = createNoteUrl({ noteId, encryptionKey, clientBaseUrl }); + const { noteUrl } = createNoteUrl({ + noteId, + encryptionKey, + clientBaseUrl, + isPasswordProtected, + }); return { encryptedPayload,