Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(config): added the option to create note without expiration #341

Merged
merged 1 commit into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -68,6 +68,7 @@
"placeholder": "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
Loading