Skip to content

Commit

Permalink
feat(config): added the option to create note without expiration (#341)
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh authored Nov 16, 2024
1 parent 0f60883 commit 3e7431c
Show file tree
Hide file tree
Showing 18 changed files with 187 additions and 28 deletions.
1 change: 1 addition & 0 deletions packages/app-client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"generate-random-password": "Generate random password"
},
"expiration": "Expiration delay",
"no-expiration": "The note never expires",
"delays": {
"1h": "1 hour",
"1d": "1 day",
Expand Down
1 change: 1 addition & 0 deletions packages/app-client/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/app-client/src/modules/config/config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
2 changes: 2 additions & 0 deletions packages/app-client/src/modules/config/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export type Config = {
enclosedVersion: string;
defaultDeleteNoteAfterReading: boolean;
defaultNoteTtlSeconds: number;
isSettingNoExpirationAllowed: boolean;
defaultNoteNoExpiration: boolean;
};
2 changes: 1 addition & 1 deletion packages/app-client/src/modules/notes/notes.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function storeNote({
isPublic,
}: {
payload: string;
ttlInSeconds: number;
ttlInSeconds?: number;
deleteAfterReading: boolean;
encryptionAlgorithm: string;
serializationFormat: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/app-client/src/modules/notes/notes.usecases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { encryptAndCreateNote };
async function encryptAndCreateNote(args: {
content: string;
password?: string;
ttlInSeconds: number;
ttlInSeconds?: number;
deleteAfterReading: boolean;
fileAssets: File[];
isPublic?: boolean;
Expand Down
16 changes: 15 additions & 1 deletion packages/app-client/src/modules/notes/pages/create-note.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const CreateNotePage: Component = () => {
const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(config.defaultDeleteNoteAfterReading);
const [getUploadedFiles, setUploadedFiles] = createSignal<File[]>([]);
const [getIsNoteCreating, setIsNoteCreating] = createSignal(false);
const [getHasNoExpiration, setHasNoExpiration] = createSignal(config.defaultNoteNoExpiration);

function resetNoteForm() {
setContent('');
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -254,9 +255,22 @@ export const CreateNotePage: Component = () => {
<TextFieldLabel>
{t('create.settings.expiration')}
</TextFieldLabel>

{config.isSettingNoExpirationAllowed && (
<SwitchUiComponent class="flex items-center space-x-2 pb-1" checked={getHasNoExpiration()} onChange={setHasNoExpiration}>
<SwitchControl data-test-id="no-expiration">
<SwitchThumb />
</SwitchControl>
<SwitchLabel class="text-sm text-muted-foreground">
{t('create.settings.no-expiration')}
</SwitchLabel>
</SwitchUiComponent>
)}

<Tabs
value={getTtlInSeconds().toString()}
onChange={(value: string) => setTtlInSeconds(Number(value))}
disabled={getHasNoExpiration()}
>
<TabsList>
<TabsIndicator />
Expand Down
22 changes: 22 additions & 0 deletions packages/app-server/src/modules/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>();

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<any>();

expect(createNoteResponse.status).to.eql(400);
expect(reply).to.eql({
error: {
code: 'note.expiration_delay_required',
message: 'Expiration delay is required',
},
});
});
});
});
6 changes: 6 additions & 0 deletions packages/app-server/src/modules/notes/notes.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
5 changes: 5 additions & 0 deletions packages/app-server/src/modules/notes/notes.models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 8 additions & 4 deletions packages/app-server/src/modules/notes/notes.models.ts
Original file line number Diff line number Diff line change
@@ -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']),
};
Expand Down
34 changes: 22 additions & 12 deletions packages/app-server/src/modules/notes/notes.repository.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,28 +42,38 @@ async function saveNote(
}:
{
payload: string;
ttlInSeconds: number;
ttlInSeconds?: number;
deleteAfterReading: boolean;
storage: Storage;
storage: Storage<DatabaseNote>;
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
Expand All @@ -76,8 +86,8 @@ async function saveNote(
return { noteId };
}

async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage }) {
const note = await storage.getItem<StoredNote>(noteId);
async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage<DatabaseNote> }): Promise<{ note: Note }> {
const note = await storage.getItem(noteId);

if (!note) {
throw createNoteNotFoundError();
Expand All @@ -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,
},
};
}
Expand Down
12 changes: 9 additions & 3 deletions packages/app-server/src/modules/notes/notes.routes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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),
Expand All @@ -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();
Expand All @@ -115,6 +117,10 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) {
throw createCannotCreatePrivateNoteOnPublicInstanceError();
}

if (isNil(ttlInSeconds) && !config.public.isSettingNoExpirationAllowed) {
throw createExpirationDelayRequiredError();
}

await next();
},

Expand Down
7 changes: 5 additions & 2 deletions packages/app-server/src/modules/notes/notes.types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { Expand } from '@corentinth/chisels';
import type { createNoteRepository } from './notes.repository';

export type NotesRepository = ReturnType<typeof createNoteRepository>;

export type StoredNote = {
export type DatabaseNote = {
payload: string;
encryptionAlgorithm: string;
serializationFormat: string;
expirationDate: Date;
expirationDate?: string;
deleteAfterReading: boolean;
isPublic: boolean;

// compressionAlgorithm: string
// keyDerivationAlgorithm: string;

};

export type Note = Expand<Omit<DatabaseNote, 'expirationDate'> & { expirationDate?: Date }>;
1 change: 1 addition & 0 deletions packages/docs/src/data/configuration.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
Loading

0 comments on commit 3e7431c

Please sign in to comment.