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(cli): added view command #71

Merged
merged 1 commit into from
Sep 1, 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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ cat file.txt | enclosed create
enclosed create --deleteAfterReading --password "password" --ttl 3600 "Hello, World!"
```

### View a note

```bash
# The password will be prompted if the note is password-protected
enclosed view <note-url>

# Or you can provide the password directly
enclosed view --password "password" <note-url>
```

### Configure the enclosed instance to use

```bash
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ cat file.txt | enclosed create
enclosed create --deleteAfterReading --password "password" --ttl 3600 "Hello, World!"
```

### View a note

```bash
# The password will be prompted if the note is password-protected
enclosed view <note-url>

# Or you can provide the password directly
enclosed view --password "password" <note-url>
```

### Configure the enclosed instance to use

```bash
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@enclosed/cli",
"type": "module",
"version": "0.0.3",
"version": "0.0.4",
"packageManager": "[email protected]",
"description": "Enclosed cli to create secure notes.",
"author": "Corentin Thomasset <[email protected]> (https://corentin.tech)",
Expand Down Expand Up @@ -34,6 +34,7 @@
},
"dependencies": {
"@enclosed/lib": "workspace:*",
"@inquirer/prompts": "^5.3.8",
"citty": "^0.1.6",
"conf": "^13.0.1",
"lodash-es": "^4.17.21",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineCommand, runMain } from 'citty';
import { createNoteCommand } from './create-note/create-note.command';
import { configCommand } from './config/config.command';
import { viewNoteCommand } from './view-note/view-note.command';

const main = defineCommand({
meta: {
Expand All @@ -9,6 +10,7 @@ const main = defineCommand({
},
subCommands: {
create: createNoteCommand,
view: viewNoteCommand,
config: configCommand,
},
});
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/view-note/view-note.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { defineCommand } from 'citty';
import { decryptNote, fetchNote, isApiClientErrorWithStatusCode, parseNoteUrl } from '@enclosed/lib';
import picocolors from 'picocolors';
import { getInstanceUrl } from '../config/config.usecases';
import { promptForPassword } from './view-note.models';

export const viewNoteCommand = defineCommand({
meta: {
name: 'view',
description: 'View a note',
},
args: {
noteUrl: {
description: 'Note URL',
type: 'positional',
required: true,
},
password: {
description: 'Password to decrypt the note (will be prompted if needed and not provided)',
valueHint: 'password',
alias: 'p',
type: 'string',
required: false,
},
},
run: async ({ args }) => {
const { noteUrl, password } = args;

try {
const { noteId, encryptionKey } = parseNoteUrl({ noteUrl });

const { content: encryptedContent, isPasswordProtected } = await fetchNote({
noteId,
apiBaseUrl: getInstanceUrl(),
});

const { decryptedContent } = await decryptNote({
encryptedContent,
encryptionKey,
password: isPasswordProtected ? password ?? await promptForPassword() : undefined,
});

console.log(decryptedContent);
} catch (error) {
if (isApiClientErrorWithStatusCode({ error, statusCode: 404 })) {
console.error(picocolors.red('Note not found'));
return;
}

if (isApiClientErrorWithStatusCode({ error, statusCode: 429 })) {
console.error(picocolors.red('Api rate limit reached, please try again later'));
return;
}

console.error(picocolors.red('Failed to fetch or decrypt note'));
}
},
});
13 changes: 13 additions & 0 deletions packages/cli/src/view-note/view-note.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { password as prompt } from '@inquirer/prompts';

export { promptForPassword };

async function promptForPassword(): Promise<string> {
const password = await prompt({
message: 'Enter the password',
});

console.log(''); // Add a new line after the password prompt

return password;
}
8 changes: 6 additions & 2 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@enclosed/lib",
"type": "module",
"version": "0.0.2",
"version": "0.0.3",
"packageManager": "[email protected]",
"description": "Enclosed lib to create secure notes.",
"author": "Corentin Thomasset <[email protected]> (https://corentin.tech)",
Expand Down Expand Up @@ -58,7 +58,9 @@
"main": "./dist/index.node.cjs",
"module": "./dist/index.web.mjs",
"types": "./dist/index.web.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"engines": {
"node": ">=22.0.0"
},
Expand All @@ -75,10 +77,12 @@
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"lodash-es": "^4.17.21",
"ofetch": "^1.3.4"
},
"devDependencies": {
"@antfu/eslint-config": "^2.27.0",
"@types/lodash-es": "^4.17.12",
"@vitest/coverage-v8": "^2.0.5",
"dotenv": "^16.4.5",
"eslint": "^9.9.0",
Expand Down
52 changes: 52 additions & 0 deletions packages/lib/src/api/api.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ofetch } from 'ofetch';
import { DEFAULT_API_BASE_URL } from './api.constants';

export { apiClient };

async function tryToGetBody({ response }: { response: Response }): Promise<unknown> {
try {
const contentType = response.headers.get('content-type');

if (contentType?.includes('application/json')) {
return await response.json();
}

return await response.text();
} catch (_error) {
return {};
}
}

async function apiClient<T>({
path,
method,
body,
baseUrl = DEFAULT_API_BASE_URL,
}: {
path: string;
method: string;
body?: Record<string, unknown>;
baseUrl?: string;
}): Promise<T> {
const data = await ofetch<T>(
path,
{
method,
body,
baseURL: baseUrl,
onResponseError: async ({ response }) => {
throw Object.assign(
new Error('Failed to fetch note'),
{
response: {
status: response.status,
body: tryToGetBody({ response }),
},
},
);
},
},
);

return data;
}
1 change: 1 addition & 0 deletions packages/lib/src/api/api.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_API_BASE_URL = 'https://enclosed.cc';
46 changes: 46 additions & 0 deletions packages/lib/src/api/api.models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest';
import { isApiClientErrorWithCode, isApiClientErrorWithStatusCode } from './api.models';

describe('api models', () => {
describe('isApiClientErrorWithStatusCode', () => {
test('permit to check if an error raised by the api client has a specific status code as response', () => {
const error = Object.assign(new Error('Failed to fetch note'), {
response: {
status: 404,
},
});

expect(isApiClientErrorWithStatusCode({ error, statusCode: 404 })).to.eql(true);
expect(isApiClientErrorWithStatusCode({ error, statusCode: 500 })).to.eql(false);
});

test('the error must be an instance of Error and have a response object', () => {
expect(isApiClientErrorWithStatusCode({ error: {}, statusCode: 404 })).to.eql(false);
expect(isApiClientErrorWithStatusCode({ error: new Error('Failed to fetch note'), statusCode: 404 })).to.eql(false);
});
});

describe('isApiClientErrorWithCode', () => {
test('permit to check if an error raised by the api client has a specific code in the error body', () => {
const error = Object.assign(new Error('Failed to fetch note'), {
response: {
body: {
error: {
code: 'NOT_FOUND',
},
},
},
});

expect(isApiClientErrorWithCode({ error, code: 'NOT_FOUND' })).to.eql(true);
expect(isApiClientErrorWithCode({ error, code: 'INTERNAL_ERROR' })).to.eql(false);
});

test('the error must be an instance of Error and have a response object with a body object', () => {
expect(isApiClientErrorWithCode({ error: {}, code: 'NOT_FOUND' })).to.eql(false);
expect(isApiClientErrorWithCode({ error: new Error('Failed to fetch note'), code: 'NOT_FOUND' })).to.eql(false);
expect(isApiClientErrorWithCode({ error: { response: {} }, code: 'NOT_FOUND' })).to.eql(false);
expect(isApiClientErrorWithCode({ error: { response: { body: {} } }, code: 'NOT_FOUND' })).to.eql(false);
});
});
});
19 changes: 19 additions & 0 deletions packages/lib/src/api/api.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { get, isError } from 'lodash-es';

export { isApiClientErrorWithStatusCode, isApiClientErrorWithCode };

function isApiClientErrorWithStatusCode({ error, statusCode }: { error: unknown; statusCode: number }): boolean {
if (!isError(error)) {
return false;
}

return get(error, 'response.status') === statusCode;
}

function isApiClientErrorWithCode({ error, code }: { error: unknown; code: string }): boolean {
if (!isError(error)) {
return false;
}

return get(error, 'response.body.error.code') === code;
}
7 changes: 5 additions & 2 deletions packages/lib/src/index.node.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createEnclosedLib } from './notes/notes.usecases';
import { decryptNoteContent, deriveMasterKey, encryptNoteContent, generateBaseKey } from './crypto/node/crypto.node.usecases';
import { storeNote } from './notes/notes.services';
import { fetchNote, storeNote } from './notes/notes.services';
import { createDecryptUsecase, createEncryptUsecase } from './crypto/crypto.usecases';
import { isApiClientErrorWithCode, isApiClientErrorWithStatusCode } from './api/api.models';

export const { encryptNote } = createEncryptUsecase({ generateBaseKey, deriveMasterKey, encryptNoteContent });
export const { decryptNote } = createDecryptUsecase({ deriveMasterKey, decryptNoteContent });

export const { createNote } = createEnclosedLib({ encryptNote, storeNote });
export const { createNote, createNoteUrl, parseNoteUrl } = createEnclosedLib({ encryptNote, storeNote });

export { fetchNote, storeNote, isApiClientErrorWithStatusCode, isApiClientErrorWithCode };
9 changes: 9 additions & 0 deletions packages/lib/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, test } from 'vitest';
import * as nodeLib from './index.node';
import * as webLib from './index.web';

describe('lib api', () => {
test('the web lib exports the same functions as the node lib', () => {
expect(Object.keys(nodeLib)).to.eql(Object.keys(webLib));
});
});
7 changes: 5 additions & 2 deletions packages/lib/src/index.web.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createEnclosedLib } from './notes/notes.usecases';
import { decryptNoteContent, deriveMasterKey, encryptNoteContent, generateBaseKey } from './crypto/web/crypto.web.usecases';
import { storeNote } from './notes/notes.services';
import { fetchNote, storeNote } from './notes/notes.services';
import { createDecryptUsecase, createEncryptUsecase } from './crypto/crypto.usecases';
import { isApiClientErrorWithCode, isApiClientErrorWithStatusCode } from './api/api.models';

export const { encryptNote } = createEncryptUsecase({ generateBaseKey, deriveMasterKey, encryptNoteContent });
export const { decryptNote } = createDecryptUsecase({ deriveMasterKey, decryptNoteContent });

export const { createNote } = createEnclosedLib({ encryptNote, storeNote });
export const { createNote, createNoteUrl, parseNoteUrl } = createEnclosedLib({ encryptNote, storeNote });

export { fetchNote, storeNote, isApiClientErrorWithStatusCode, isApiClientErrorWithCode };
49 changes: 48 additions & 1 deletion packages/lib/src/notes/notes.models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { createNoteUrl } from './notes.models';
import { createNoteUrl, parseNoteUrl } from './notes.models';

describe('note models', () => {
describe('createNoteUrl', () => {
Expand All @@ -19,4 +19,51 @@ describe('note models', () => {
});
});
});

describe('parseNoteUrl', () => {
test('retreives the note id and encryption key from a sharable note url', () => {
expect(
parseNoteUrl({ noteUrl: 'https://example.com/123#abc' }),
).to.eql({
noteId: '123',
encryptionKey: 'abc',
});
});

test('trailing slash in the base url is handled', () => {
expect(
parseNoteUrl({ noteUrl: 'https://example.com/123/#abc' }),
).to.eql({
noteId: '123',
encryptionKey: 'abc',
});
});

test('in case of nested paths, the last path segment is considered the note id', () => {
expect(
parseNoteUrl({ noteUrl: 'https://example.com/123/456#abc' }),
).to.eql({
noteId: '456',
encryptionKey: 'abc',
});
});

test('throws an error if their is no note id or encryption key', () => {
expect(() => {
parseNoteUrl({ noteUrl: 'https://example.com/#abc' });
}).to.throw('Invalid note url');

expect(() => {
parseNoteUrl({ noteUrl: 'https://example.com/123#' });
}).to.throw('Invalid note url');

expect(() => {
parseNoteUrl({ noteUrl: 'https://example.com/123' });
}).to.throw('Invalid note url');

expect(() => {
parseNoteUrl({ noteUrl: 'https://example.com/' });
}).to.throw('Invalid note url');
});
});
});
Loading
Loading