Skip to content

Commit 3e7431c

Browse files
authored
feat(config): added the option to create note without expiration (#341)
1 parent 0f60883 commit 3e7431c

18 files changed

+187
-28
lines changed

packages/app-client/src/locales/en.json

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"generate-random-password": "Generate random password"
7777
},
7878
"expiration": "Expiration delay",
79+
"no-expiration": "The note never expires",
7980
"delays": {
8081
"1h": "1 hour",
8182
"1d": "1 day",

packages/app-client/src/locales/fr.json

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"placeholder": "Mot de passe..."
6969
},
7070
"expiration": "Délai d'expiration",
71+
"no-expiration": "La note n'expirera jamais",
7172
"delays": {
7273
"1h": "1 heure",
7374
"1d": "1 jour",

packages/app-client/src/modules/config/config.constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ export const buildTimeConfig: Config = {
77
isAuthenticationRequired: import.meta.env.VITE_IS_AUTHENTICATION_REQUIRED === 'true',
88
defaultDeleteNoteAfterReading: import.meta.env.VITE_DEFAULT_DELETE_NOTE_AFTER_READING === 'true',
99
defaultNoteTtlSeconds: Number(import.meta.env.VITE_DEFAULT_NOTE_TTL_SECONDS ?? 3600),
10+
defaultNoteNoExpiration: import.meta.env.VITE_DEFAULT_NOTE_NO_EXPIRATION === 'true',
11+
isSettingNoExpirationAllowed: import.meta.env.VITE_IS_SETTING_NO_EXPIRATION_ALLOWED === 'true',
1012
};

packages/app-client/src/modules/config/config.types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ export type Config = {
55
enclosedVersion: string;
66
defaultDeleteNoteAfterReading: boolean;
77
defaultNoteTtlSeconds: number;
8+
isSettingNoExpirationAllowed: boolean;
9+
defaultNoteNoExpiration: boolean;
810
};

packages/app-client/src/modules/notes/notes.services.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ async function storeNote({
1111
isPublic,
1212
}: {
1313
payload: string;
14-
ttlInSeconds: number;
14+
ttlInSeconds?: number;
1515
deleteAfterReading: boolean;
1616
encryptionAlgorithm: string;
1717
serializationFormat: string;

packages/app-client/src/modules/notes/notes.usecases.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export { encryptAndCreateNote };
66
async function encryptAndCreateNote(args: {
77
content: string;
88
password?: string;
9-
ttlInSeconds: number;
9+
ttlInSeconds?: number;
1010
deleteAfterReading: boolean;
1111
fileAssets: File[];
1212
isPublic?: boolean;

packages/app-client/src/modules/notes/pages/create-note.page.tsx

+15-1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const CreateNotePage: Component = () => {
122122
const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(config.defaultDeleteNoteAfterReading);
123123
const [getUploadedFiles, setUploadedFiles] = createSignal<File[]>([]);
124124
const [getIsNoteCreating, setIsNoteCreating] = createSignal(false);
125+
const [getHasNoExpiration, setHasNoExpiration] = createSignal(config.defaultNoteNoExpiration);
125126

126127
function resetNoteForm() {
127128
setContent('');
@@ -160,7 +161,7 @@ export const CreateNotePage: Component = () => {
160161
const [createdNote, error] = await safely(encryptAndCreateNote({
161162
content: getContent(),
162163
password: getPassword(),
163-
ttlInSeconds: getTtlInSeconds(),
164+
ttlInSeconds: getHasNoExpiration() ? undefined : getTtlInSeconds(),
164165
deleteAfterReading: getDeleteAfterReading(),
165166
fileAssets: getUploadedFiles(),
166167
isPublic: getIsPublic(),
@@ -254,9 +255,22 @@ export const CreateNotePage: Component = () => {
254255
<TextFieldLabel>
255256
{t('create.settings.expiration')}
256257
</TextFieldLabel>
258+
259+
{config.isSettingNoExpirationAllowed && (
260+
<SwitchUiComponent class="flex items-center space-x-2 pb-1" checked={getHasNoExpiration()} onChange={setHasNoExpiration}>
261+
<SwitchControl data-test-id="no-expiration">
262+
<SwitchThumb />
263+
</SwitchControl>
264+
<SwitchLabel class="text-sm text-muted-foreground">
265+
{t('create.settings.no-expiration')}
266+
</SwitchLabel>
267+
</SwitchUiComponent>
268+
)}
269+
257270
<Tabs
258271
value={getTtlInSeconds().toString()}
259272
onChange={(value: string) => setTtlInSeconds(Number(value))}
273+
disabled={getHasNoExpiration()}
260274
>
261275
<TabsList>
262276
<TabsIndicator />

packages/app-server/src/modules/app/config/config.ts

+22
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ export const configDefinition = {
135135
default: 3600,
136136
env: 'PUBLIC_DEFAULT_NOTE_TTL_SECONDS',
137137
},
138+
isSettingNoExpirationAllowed: {
139+
doc: 'Whether to allow the user to set the note to never expire',
140+
schema: z
141+
.string()
142+
.trim()
143+
.toLowerCase()
144+
.transform(x => x === 'true')
145+
.pipe(z.boolean()),
146+
default: 'true',
147+
env: 'PUBLIC_IS_SETTING_NO_EXPIRATION_ALLOWED',
148+
},
149+
defaultNoteNoExpiration: {
150+
doc: 'The default value for the `No expiration` checkbox in the note creation form (only used if setting no expiration is allowed)',
151+
schema: z
152+
.string()
153+
.trim()
154+
.toLowerCase()
155+
.transform(x => x === 'true')
156+
.pipe(z.boolean()),
157+
default: 'false',
158+
env: 'PUBLIC_DEFAULT_NOTE_NO_EXPIRATION',
159+
},
138160
},
139161
authentication: {
140162
jwtSecret: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { overrideConfig } from '../../app/config/config.test-utils';
3+
import { createServer } from '../../app/server';
4+
import { createMemoryStorage } from '../../storage/factories/memory.storage';
5+
6+
describe('e2e', () => {
7+
describe('no expiration delay', async () => {
8+
test('when the creation of notes without an expiration delay is allowed, a note can be created without an expiration delay', async () => {
9+
const { storage } = createMemoryStorage();
10+
11+
const { app } = createServer({
12+
storageFactory: () => ({ storage }),
13+
config: overrideConfig({
14+
public: {
15+
isSettingNoExpirationAllowed: true,
16+
},
17+
}),
18+
});
19+
20+
const note = {
21+
deleteAfterReading: false,
22+
ttlInSeconds: undefined,
23+
payload: 'aaaaaaaa',
24+
encryptionAlgorithm: 'aes-256-gcm',
25+
serializationFormat: 'cbor-array',
26+
};
27+
28+
const createNoteResponse = await app.request(
29+
'/api/notes',
30+
{
31+
method: 'POST',
32+
body: JSON.stringify(note),
33+
headers: new Headers({ 'Content-Type': 'application/json' }),
34+
},
35+
);
36+
37+
const reply = await createNoteResponse.json<any>();
38+
39+
expect(createNoteResponse.status).to.eql(200);
40+
expect(reply.noteId).toBeTypeOf('string');
41+
});
42+
43+
test('when the ability to create notes without an expiration delay is disabled, a note cannot be created without an expiration delay', async () => {
44+
const { storage } = createMemoryStorage();
45+
46+
const { app } = createServer({
47+
storageFactory: () => ({ storage }),
48+
config: overrideConfig({
49+
public: {
50+
isSettingNoExpirationAllowed: false,
51+
},
52+
}),
53+
});
54+
55+
const note = {
56+
deleteAfterReading: false,
57+
ttlInSeconds: undefined,
58+
payload: 'aaaaaaaa',
59+
encryptionAlgorithm: 'aes-256-gcm',
60+
serializationFormat: 'cbor-array',
61+
};
62+
63+
const createNoteResponse = await app.request(
64+
'/api/notes',
65+
{
66+
method: 'POST',
67+
body: JSON.stringify(note),
68+
headers: new Headers({ 'Content-Type': 'application/json' }),
69+
},
70+
);
71+
72+
const reply = await createNoteResponse.json<any>();
73+
74+
expect(createNoteResponse.status).to.eql(400);
75+
expect(reply).to.eql({
76+
error: {
77+
code: 'note.expiration_delay_required',
78+
message: 'Expiration delay is required',
79+
},
80+
});
81+
});
82+
});
83+
});

packages/app-server/src/modules/notes/notes.errors.ts

+6
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ export const createCannotCreatePrivateNoteOnPublicInstanceError = createErrorFac
1717
code: 'note.cannot_create_private_note_on_public_instance',
1818
statusCode: 403,
1919
});
20+
21+
export const createExpirationDelayRequiredError = createErrorFactory({
22+
message: 'Expiration delay is required',
23+
code: 'note.expiration_delay_required',
24+
statusCode: 400,
25+
});

packages/app-server/src/modules/notes/notes.models.test.ts

+5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ describe('notes models', () => {
2727
}),
2828
).to.eql(true);
2929
});
30+
31+
test('notes without an expiration date are not considered expired', () => {
32+
expect(isNoteExpired({ note: {}, now: new Date('2024-01-02T00:00:00Z') })).to.eql(false);
33+
expect(isNoteExpired({ note: { expirationDate: undefined }, now: new Date('2024-01-02T00:00:00Z') })).to.eql(false);
34+
});
3035
});
3136

3237
describe('formatNoteForApi', () => {

packages/app-server/src/modules/notes/notes.models.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import type { StoredNote } from './notes.types';
1+
import type { Note } from './notes.types';
22
import { addSeconds, isBefore, isEqual } from 'date-fns';
3-
import { omit } from 'lodash-es';
3+
import { isNil, omit } from 'lodash-es';
44

55
export { formatNoteForApi, getNoteExpirationDate, isNoteExpired };
66

7-
function isNoteExpired({ note, now = new Date() }: { note: { expirationDate: Date }; now?: Date }) {
7+
function isNoteExpired({ note, now = new Date() }: { note: { expirationDate?: Date }; now?: Date }) {
8+
if (isNil(note.expirationDate)) {
9+
return false;
10+
}
11+
812
return isBefore(note.expirationDate, now) || isEqual(note.expirationDate, now);
913
}
1014

11-
function formatNoteForApi({ note }: { note: StoredNote }) {
15+
function formatNoteForApi({ note }: { note: Note }) {
1216
return {
1317
apiNote: omit(note, ['expirationDate', 'deleteAfterReading', 'isPublic']),
1418
};

packages/app-server/src/modules/notes/notes.repository.ts

+22-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Storage } from '../storage/storage.types';
2-
import type { StoredNote } from './notes.types';
2+
import type { DatabaseNote, Note } from './notes.types';
33
import { injectArguments } from '@corentinth/chisels';
44
import { generateId } from '../shared/utils/random';
55
import { createNoteNotFoundError } from './notes.errors';
@@ -42,28 +42,38 @@ async function saveNote(
4242
}:
4343
{
4444
payload: string;
45-
ttlInSeconds: number;
45+
ttlInSeconds?: number;
4646
deleteAfterReading: boolean;
47-
storage: Storage;
47+
storage: Storage<DatabaseNote>;
4848
generateNoteId?: () => string;
4949
now?: Date;
5050
encryptionAlgorithm: string;
5151
serializationFormat: string;
5252
isPublic: boolean;
5353
},
54-
) {
54+
): Promise<{ noteId: string }> {
5555
const noteId = generateNoteId();
56+
const baseNote = {
57+
payload,
58+
deleteAfterReading,
59+
encryptionAlgorithm,
60+
serializationFormat,
61+
isPublic,
62+
};
63+
64+
if (!ttlInSeconds) {
65+
await storage.setItem(noteId, baseNote);
66+
67+
return { noteId };
68+
}
69+
5670
const { expirationDate } = getNoteExpirationDate({ ttlInSeconds, now });
5771

5872
await storage.setItem(
5973
noteId,
6074
{
61-
payload,
75+
...baseNote,
6276
expirationDate: expirationDate.toISOString(),
63-
deleteAfterReading,
64-
encryptionAlgorithm,
65-
serializationFormat,
66-
isPublic,
6777
},
6878
{
6979
// Some storage drivers have a different API for setting TTLs
@@ -76,8 +86,8 @@ async function saveNote(
7686
return { noteId };
7787
}
7888

79-
async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage }) {
80-
const note = await storage.getItem<StoredNote>(noteId);
89+
async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage<DatabaseNote> }): Promise<{ note: Note }> {
90+
const note = await storage.getItem(noteId);
8191

8292
if (!note) {
8393
throw createNoteNotFoundError();
@@ -86,7 +96,7 @@ async function getNoteById({ noteId, storage }: { noteId: string; storage: Stora
8696
return {
8797
note: {
8898
...note,
89-
expirationDate: new Date(note.expirationDate),
99+
expirationDate: note.expirationDate ? new Date(note.expirationDate) : undefined,
90100
},
91101
};
92102
}

packages/app-server/src/modules/notes/notes.routes.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { ServerInstance } from '../app/server.types';
22
import { encryptionAlgorithms, serializationFormats } from '@enclosed/lib';
3+
import { isNil } from 'lodash-es';
34
import { z } from 'zod';
45
import { createUnauthorizedError } from '../app/auth/auth.errors';
56
import { protectedRouteMiddleware } from '../app/auth/auth.middleware';
67
import { validateJsonBody } from '../shared/validation/validation';
78
import { ONE_MONTH_IN_SECONDS, TEN_MINUTES_IN_SECONDS } from './notes.constants';
8-
import { createCannotCreatePrivateNoteOnPublicInstanceError, createNotePayloadTooLargeError } from './notes.errors';
9+
import { createCannotCreatePrivateNoteOnPublicInstanceError, createExpirationDelayRequiredError, createNotePayloadTooLargeError } from './notes.errors';
910
import { formatNoteForApi } from './notes.models';
1011
import { createNoteRepository } from './notes.repository';
1112
import { getRefreshedNote } from './notes.usecases';
@@ -92,7 +93,8 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) {
9293
deleteAfterReading: z.boolean(),
9394
ttlInSeconds: z.number()
9495
.min(TEN_MINUTES_IN_SECONDS)
95-
.max(ONE_MONTH_IN_SECONDS),
96+
.max(ONE_MONTH_IN_SECONDS)
97+
.optional(),
9698

9799
// @ts-expect-error zod wants strict non empty array
98100
encryptionAlgorithm: z.enum(encryptionAlgorithms),
@@ -105,7 +107,7 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) {
105107

106108
async (context, next) => {
107109
const config = context.get('config');
108-
const { payload, isPublic } = context.req.valid('json');
110+
const { payload, isPublic, ttlInSeconds } = context.req.valid('json');
109111

110112
if (payload.length > config.notes.maxEncryptedPayloadLength) {
111113
throw createNotePayloadTooLargeError();
@@ -115,6 +117,10 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) {
115117
throw createCannotCreatePrivateNoteOnPublicInstanceError();
116118
}
117119

120+
if (isNil(ttlInSeconds) && !config.public.isSettingNoExpirationAllowed) {
121+
throw createExpirationDelayRequiredError();
122+
}
123+
118124
await next();
119125
},
120126

Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import type { Expand } from '@corentinth/chisels';
12
import type { createNoteRepository } from './notes.repository';
23

34
export type NotesRepository = ReturnType<typeof createNoteRepository>;
45

5-
export type StoredNote = {
6+
export type DatabaseNote = {
67
payload: string;
78
encryptionAlgorithm: string;
89
serializationFormat: string;
9-
expirationDate: Date;
10+
expirationDate?: string;
1011
deleteAfterReading: boolean;
1112
isPublic: boolean;
1213

1314
// compressionAlgorithm: string
1415
// keyDerivationAlgorithm: string;
1516

1617
};
18+
19+
export type Note = Expand<Omit<DatabaseNote, 'expirationDate'> & { expirationDate?: Date }>;

packages/docs/src/data/configuration.data.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const mdTable = [
5353
].join('\n');
5454

5555
export default {
56+
watch: ['../../../app-server/src/modules/app/config/config.ts'],
5657
async load() {
5758
return md.render(mdTable);
5859
},

0 commit comments

Comments
 (0)