From d1dbc62bf5ad8f686aedc63953bdc16c3534b6eb Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Sat, 16 Nov 2024 20:20:07 +0100 Subject: [PATCH] feat(config): added the option to create note without expiration --- packages/app-client/src/locales/en.json | 1 + packages/app-client/src/locales/fr.json | 1 + .../src/modules/config/config.constants.ts | 2 + .../src/modules/config/config.types.ts | 2 + .../src/modules/notes/notes.usecases.ts | 2 +- .../modules/notes/pages/create-note.page.tsx | 16 +++- .../src/modules/app/config/config.ts | 22 +++++ .../notes/e2e/no-expiration-delay.e2e.test.ts | 83 +++++++++++++++++++ .../src/modules/notes/notes.errors.ts | 6 ++ .../src/modules/notes/notes.models.test.ts | 5 ++ .../src/modules/notes/notes.models.ts | 12 ++- .../src/modules/notes/notes.repository.ts | 34 +++++--- .../src/modules/notes/notes.routes.ts | 12 ++- .../src/modules/notes/notes.types.ts | 7 +- packages/docs/src/data/configuration.data.ts | 1 + packages/lib/src/notes/notes.services.ts | 2 +- packages/lib/src/notes/notes.usecases.ts | 5 +- 17 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 packages/app-server/src/modules/notes/e2e/no-expiration-delay.e2e.test.ts diff --git a/packages/app-client/src/locales/en.json b/packages/app-client/src/locales/en.json index 7d8562b2..6b68b791 100644 --- a/packages/app-client/src/locales/en.json +++ b/packages/app-client/src/locales/en.json @@ -68,6 +68,7 @@ "placeholder": "Password..." }, "expiration": "Expiration delay", + "no-expiration": "The note never expires", "delays": { "1h": "1 hour", "1d": "1 day", diff --git a/packages/app-client/src/locales/fr.json b/packages/app-client/src/locales/fr.json index ba6bdc5f..62492a6e 100644 --- a/packages/app-client/src/locales/fr.json +++ b/packages/app-client/src/locales/fr.json @@ -68,6 +68,7 @@ "placeholder": "Mot de passe..." }, "expiration": "Délai d'expiration", + "no-expiration": "La note n'expirera jamais", "delays": { "1h": "1 heure", "1d": "1 jour", diff --git a/packages/app-client/src/modules/config/config.constants.ts b/packages/app-client/src/modules/config/config.constants.ts index 4e40fc11..ef72d18f 100644 --- a/packages/app-client/src/modules/config/config.constants.ts +++ b/packages/app-client/src/modules/config/config.constants.ts @@ -7,4 +7,6 @@ export const buildTimeConfig: Config = { isAuthenticationRequired: import.meta.env.VITE_IS_AUTHENTICATION_REQUIRED === 'true', defaultDeleteNoteAfterReading: import.meta.env.VITE_DEFAULT_DELETE_NOTE_AFTER_READING === 'true', defaultNoteTtlSeconds: Number(import.meta.env.VITE_DEFAULT_NOTE_TTL_SECONDS ?? 3600), + defaultNoteNoExpiration: import.meta.env.VITE_DEFAULT_NOTE_NO_EXPIRATION === 'true', + isSettingNoExpirationAllowed: import.meta.env.VITE_IS_SETTING_NO_EXPIRATION_ALLOWED === 'true', }; diff --git a/packages/app-client/src/modules/config/config.types.ts b/packages/app-client/src/modules/config/config.types.ts index 70dc0032..e65a33a2 100644 --- a/packages/app-client/src/modules/config/config.types.ts +++ b/packages/app-client/src/modules/config/config.types.ts @@ -5,4 +5,6 @@ export type Config = { enclosedVersion: string; defaultDeleteNoteAfterReading: boolean; defaultNoteTtlSeconds: number; + isSettingNoExpirationAllowed: boolean; + defaultNoteNoExpiration: boolean; }; diff --git a/packages/app-client/src/modules/notes/notes.usecases.ts b/packages/app-client/src/modules/notes/notes.usecases.ts index 14551b99..aa782cf8 100644 --- a/packages/app-client/src/modules/notes/notes.usecases.ts +++ b/packages/app-client/src/modules/notes/notes.usecases.ts @@ -6,7 +6,7 @@ export { encryptAndCreateNote }; async function encryptAndCreateNote(args: { content: string; password?: string; - ttlInSeconds: number; + ttlInSeconds?: number; deleteAfterReading: boolean; fileAssets: File[]; isPublic?: boolean; diff --git a/packages/app-client/src/modules/notes/pages/create-note.page.tsx b/packages/app-client/src/modules/notes/pages/create-note.page.tsx index b6d9af8d..9365307d 100644 --- a/packages/app-client/src/modules/notes/pages/create-note.page.tsx +++ b/packages/app-client/src/modules/notes/pages/create-note.page.tsx @@ -122,6 +122,7 @@ export const CreateNotePage: Component = () => { const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(config.defaultDeleteNoteAfterReading); const [getUploadedFiles, setUploadedFiles] = createSignal([]); const [getIsNoteCreating, setIsNoteCreating] = createSignal(false); + const [getHasNoExpiration, setHasNoExpiration] = createSignal(config.defaultNoteNoExpiration); function resetNoteForm() { setContent(''); @@ -160,7 +161,7 @@ export const CreateNotePage: Component = () => { const [createdNote, error] = await safely(encryptAndCreateNote({ content: getContent(), password: getPassword(), - ttlInSeconds: getTtlInSeconds(), + ttlInSeconds: getHasNoExpiration() ? undefined : getTtlInSeconds(), deleteAfterReading: getDeleteAfterReading(), fileAssets: getUploadedFiles(), isPublic: getIsPublic(), @@ -254,9 +255,22 @@ export const CreateNotePage: Component = () => { {t('create.settings.expiration')} + + {config.isSettingNoExpirationAllowed && ( + + + + + + {t('create.settings.no-expiration')} + + + )} + setTtlInSeconds(Number(value))} + disabled={getHasNoExpiration()} > diff --git a/packages/app-server/src/modules/app/config/config.ts b/packages/app-server/src/modules/app/config/config.ts index 2e6c77bb..8c43bed0 100644 --- a/packages/app-server/src/modules/app/config/config.ts +++ b/packages/app-server/src/modules/app/config/config.ts @@ -135,6 +135,28 @@ export const configDefinition = { default: 3600, env: 'PUBLIC_DEFAULT_NOTE_TTL_SECONDS', }, + isSettingNoExpirationAllowed: { + doc: 'Whether to allow the user to set the note to never expire', + schema: z + .string() + .trim() + .toLowerCase() + .transform(x => x === 'true') + .pipe(z.boolean()), + default: 'true', + env: 'PUBLIC_IS_SETTING_NO_EXPIRATION_ALLOWED', + }, + defaultNoteNoExpiration: { + doc: 'The default value for the `No expiration` checkbox in the note creation form (only used if setting no expiration is allowed)', + schema: z + .string() + .trim() + .toLowerCase() + .transform(x => x === 'true') + .pipe(z.boolean()), + default: 'false', + env: 'PUBLIC_DEFAULT_NOTE_NO_EXPIRATION', + }, }, authentication: { jwtSecret: { diff --git a/packages/app-server/src/modules/notes/e2e/no-expiration-delay.e2e.test.ts b/packages/app-server/src/modules/notes/e2e/no-expiration-delay.e2e.test.ts new file mode 100644 index 00000000..1aa9759e --- /dev/null +++ b/packages/app-server/src/modules/notes/e2e/no-expiration-delay.e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; +import { overrideConfig } from '../../app/config/config.test-utils'; +import { createServer } from '../../app/server'; +import { createMemoryStorage } from '../../storage/factories/memory.storage'; + +describe('e2e', () => { + describe('no expiration delay', async () => { + test('when the creation of notes without an expiration delay is allowed, a note can be created without an expiration delay', async () => { + const { storage } = createMemoryStorage(); + + const { app } = createServer({ + storageFactory: () => ({ storage }), + config: overrideConfig({ + public: { + isSettingNoExpirationAllowed: true, + }, + }), + }); + + const note = { + deleteAfterReading: false, + ttlInSeconds: undefined, + payload: 'aaaaaaaa', + encryptionAlgorithm: 'aes-256-gcm', + serializationFormat: 'cbor-array', + }; + + const createNoteResponse = await app.request( + '/api/notes', + { + method: 'POST', + body: JSON.stringify(note), + headers: new Headers({ 'Content-Type': 'application/json' }), + }, + ); + + const reply = await createNoteResponse.json(); + + expect(createNoteResponse.status).to.eql(200); + expect(reply.noteId).toBeTypeOf('string'); + }); + + test('when the ability to create notes without an expiration delay is disabled, a note cannot be created without an expiration delay', async () => { + const { storage } = createMemoryStorage(); + + const { app } = createServer({ + storageFactory: () => ({ storage }), + config: overrideConfig({ + public: { + isSettingNoExpirationAllowed: false, + }, + }), + }); + + const note = { + deleteAfterReading: false, + ttlInSeconds: undefined, + payload: 'aaaaaaaa', + encryptionAlgorithm: 'aes-256-gcm', + serializationFormat: 'cbor-array', + }; + + const createNoteResponse = await app.request( + '/api/notes', + { + method: 'POST', + body: JSON.stringify(note), + headers: new Headers({ 'Content-Type': 'application/json' }), + }, + ); + + const reply = await createNoteResponse.json(); + + expect(createNoteResponse.status).to.eql(400); + expect(reply).to.eql({ + error: { + code: 'note.expiration_delay_required', + message: 'Expiration delay is required', + }, + }); + }); + }); +}); diff --git a/packages/app-server/src/modules/notes/notes.errors.ts b/packages/app-server/src/modules/notes/notes.errors.ts index e6a8de99..097b710d 100644 --- a/packages/app-server/src/modules/notes/notes.errors.ts +++ b/packages/app-server/src/modules/notes/notes.errors.ts @@ -17,3 +17,9 @@ export const createCannotCreatePrivateNoteOnPublicInstanceError = createErrorFac code: 'note.cannot_create_private_note_on_public_instance', statusCode: 403, }); + +export const createExpirationDelayRequiredError = createErrorFactory({ + message: 'Expiration delay is required', + code: 'note.expiration_delay_required', + statusCode: 400, +}); 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 2b186228..46f0f193 100644 --- a/packages/app-server/src/modules/notes/notes.models.test.ts +++ b/packages/app-server/src/modules/notes/notes.models.test.ts @@ -27,6 +27,11 @@ describe('notes models', () => { }), ).to.eql(true); }); + + test('notes without an expiration date are not considered expired', () => { + expect(isNoteExpired({ note: {}, now: new Date('2024-01-02T00:00:00Z') })).to.eql(false); + expect(isNoteExpired({ note: { expirationDate: undefined }, now: new Date('2024-01-02T00:00:00Z') })).to.eql(false); + }); }); describe('formatNoteForApi', () => { diff --git a/packages/app-server/src/modules/notes/notes.models.ts b/packages/app-server/src/modules/notes/notes.models.ts index 8d3441a9..eb41b665 100644 --- a/packages/app-server/src/modules/notes/notes.models.ts +++ b/packages/app-server/src/modules/notes/notes.models.ts @@ -1,14 +1,18 @@ -import type { StoredNote } from './notes.types'; +import type { Note } from './notes.types'; import { addSeconds, isBefore, isEqual } from 'date-fns'; -import { omit } from 'lodash-es'; +import { isNil, omit } from 'lodash-es'; export { formatNoteForApi, getNoteExpirationDate, isNoteExpired }; -function isNoteExpired({ note, now = new Date() }: { note: { expirationDate: Date }; now?: Date }) { +function isNoteExpired({ note, now = new Date() }: { note: { expirationDate?: Date }; now?: Date }) { + if (isNil(note.expirationDate)) { + return false; + } + return isBefore(note.expirationDate, now) || isEqual(note.expirationDate, now); } -function formatNoteForApi({ note }: { note: StoredNote }) { +function formatNoteForApi({ note }: { note: Note }) { return { apiNote: omit(note, ['expirationDate', 'deleteAfterReading', 'isPublic']), }; diff --git a/packages/app-server/src/modules/notes/notes.repository.ts b/packages/app-server/src/modules/notes/notes.repository.ts index b49db1f0..73196f11 100644 --- a/packages/app-server/src/modules/notes/notes.repository.ts +++ b/packages/app-server/src/modules/notes/notes.repository.ts @@ -1,5 +1,5 @@ import type { Storage } from '../storage/storage.types'; -import type { StoredNote } from './notes.types'; +import type { DatabaseNote, Note } from './notes.types'; import { injectArguments } from '@corentinth/chisels'; import { generateId } from '../shared/utils/random'; import { createNoteNotFoundError } from './notes.errors'; @@ -42,28 +42,38 @@ async function saveNote( }: { payload: string; - ttlInSeconds: number; + ttlInSeconds?: number; deleteAfterReading: boolean; - storage: Storage; + storage: Storage; generateNoteId?: () => string; now?: Date; encryptionAlgorithm: string; serializationFormat: string; isPublic: boolean; }, -) { +): Promise<{ noteId: string }> { const noteId = generateNoteId(); + const baseNote = { + payload, + deleteAfterReading, + encryptionAlgorithm, + serializationFormat, + isPublic, + }; + + if (!ttlInSeconds) { + await storage.setItem(noteId, baseNote); + + return { noteId }; + } + const { expirationDate } = getNoteExpirationDate({ ttlInSeconds, now }); await storage.setItem( noteId, { - payload, + ...baseNote, expirationDate: expirationDate.toISOString(), - deleteAfterReading, - encryptionAlgorithm, - serializationFormat, - isPublic, }, { // Some storage drivers have a different API for setting TTLs @@ -76,8 +86,8 @@ async function saveNote( return { noteId }; } -async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage }) { - const note = await storage.getItem(noteId); +async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage }): Promise<{ note: Note }> { + const note = await storage.getItem(noteId); if (!note) { throw createNoteNotFoundError(); @@ -86,7 +96,7 @@ async function getNoteById({ noteId, storage }: { noteId: string; storage: Stora return { note: { ...note, - expirationDate: new Date(note.expirationDate), + expirationDate: note.expirationDate ? new Date(note.expirationDate) : undefined, }, }; } diff --git a/packages/app-server/src/modules/notes/notes.routes.ts b/packages/app-server/src/modules/notes/notes.routes.ts index e7fe7259..9eb51d3c 100644 --- a/packages/app-server/src/modules/notes/notes.routes.ts +++ b/packages/app-server/src/modules/notes/notes.routes.ts @@ -1,11 +1,12 @@ import type { ServerInstance } from '../app/server.types'; import { encryptionAlgorithms, serializationFormats } from '@enclosed/lib'; +import { isNil } from 'lodash-es'; import { z } from 'zod'; import { createUnauthorizedError } from '../app/auth/auth.errors'; import { protectedRouteMiddleware } from '../app/auth/auth.middleware'; import { validateJsonBody } from '../shared/validation/validation'; import { ONE_MONTH_IN_SECONDS, TEN_MINUTES_IN_SECONDS } from './notes.constants'; -import { createCannotCreatePrivateNoteOnPublicInstanceError, createNotePayloadTooLargeError } from './notes.errors'; +import { createCannotCreatePrivateNoteOnPublicInstanceError, createExpirationDelayRequiredError, createNotePayloadTooLargeError } from './notes.errors'; import { formatNoteForApi } from './notes.models'; import { createNoteRepository } from './notes.repository'; import { getRefreshedNote } from './notes.usecases'; @@ -92,7 +93,8 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { deleteAfterReading: z.boolean(), ttlInSeconds: z.number() .min(TEN_MINUTES_IN_SECONDS) - .max(ONE_MONTH_IN_SECONDS), + .max(ONE_MONTH_IN_SECONDS) + .optional(), // @ts-expect-error zod wants strict non empty array encryptionAlgorithm: z.enum(encryptionAlgorithms), @@ -105,7 +107,7 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { async (context, next) => { const config = context.get('config'); - const { payload, isPublic } = context.req.valid('json'); + const { payload, isPublic, ttlInSeconds } = context.req.valid('json'); if (payload.length > config.notes.maxEncryptedPayloadLength) { throw createNotePayloadTooLargeError(); @@ -115,6 +117,10 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { throw createCannotCreatePrivateNoteOnPublicInstanceError(); } + if (isNil(ttlInSeconds) && !config.public.isSettingNoExpirationAllowed) { + throw createExpirationDelayRequiredError(); + } + await next(); }, diff --git a/packages/app-server/src/modules/notes/notes.types.ts b/packages/app-server/src/modules/notes/notes.types.ts index b2845c9d..49ab3d13 100644 --- a/packages/app-server/src/modules/notes/notes.types.ts +++ b/packages/app-server/src/modules/notes/notes.types.ts @@ -1,12 +1,13 @@ +import type { Expand } from '@corentinth/chisels'; import type { createNoteRepository } from './notes.repository'; export type NotesRepository = ReturnType; -export type StoredNote = { +export type DatabaseNote = { payload: string; encryptionAlgorithm: string; serializationFormat: string; - expirationDate: Date; + expirationDate?: string; deleteAfterReading: boolean; isPublic: boolean; @@ -14,3 +15,5 @@ export type StoredNote = { // keyDerivationAlgorithm: string; }; + +export type Note = Expand & { expirationDate?: Date }>; diff --git a/packages/docs/src/data/configuration.data.ts b/packages/docs/src/data/configuration.data.ts index a6584b6e..4bebc247 100644 --- a/packages/docs/src/data/configuration.data.ts +++ b/packages/docs/src/data/configuration.data.ts @@ -53,6 +53,7 @@ const mdTable = [ ].join('\n'); export default { + watch: ['../../../app-server/src/modules/app/config/config.ts'], async load() { return md.render(mdTable); }, diff --git a/packages/lib/src/notes/notes.services.ts b/packages/lib/src/notes/notes.services.ts index bfc727c6..02406cbc 100644 --- a/packages/lib/src/notes/notes.services.ts +++ b/packages/lib/src/notes/notes.services.ts @@ -12,7 +12,7 @@ async function storeNote({ isPublic, }: { payload: string; - ttlInSeconds: number; + ttlInSeconds?: number; deleteAfterReading: boolean; apiBaseUrl?: string; serializationFormat: string; diff --git a/packages/lib/src/notes/notes.usecases.ts b/packages/lib/src/notes/notes.usecases.ts index c9d90b31..1f5cf784 100644 --- a/packages/lib/src/notes/notes.usecases.ts +++ b/packages/lib/src/notes/notes.usecases.ts @@ -7,13 +7,12 @@ import { storeNote as storeNoteImpl } from './notes.services'; export { createNote }; -const ONE_HOUR_IN_SECONDS = 60 * 60; const BASE_URL = 'https://enclosed.cc'; async function createNote({ content, password, - ttlInSeconds = ONE_HOUR_IN_SECONDS, + ttlInSeconds, deleteAfterReading = false, clientBaseUrl = BASE_URL, apiBaseUrl = clientBaseUrl, @@ -35,7 +34,7 @@ async function createNote({ isPublic?: boolean; storeNote?: (params: { payload: string; - ttlInSeconds: number; + ttlInSeconds?: number; deleteAfterReading: boolean; encryptionAlgorithm: EncryptionAlgorithm; serializationFormat: SerializationFormat;