mirror of
https://github.com/docmost/docmost.git
synced 2026-08-20 19:14:10 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f5a7d76d9 | ||
|
|
ea59912c7e | ||
|
|
db3ff54da1 | ||
|
|
9414a38215 |
@@ -255,6 +255,7 @@ export default function ChatInput({
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={template.content}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function TemplateEditor() {
|
||||
const editor = useEditor({
|
||||
extensions: templateExtensions,
|
||||
content: "",
|
||||
textDirection: "auto",
|
||||
editorProps: {
|
||||
scrollThreshold: 80,
|
||||
scrollMargin: 80,
|
||||
|
||||
@@ -103,6 +103,7 @@ const CommentEditor = forwardRef(
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: (autofocus && "end") || false,
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content as any}
|
||||
/>
|
||||
|
||||
@@ -249,6 +249,7 @@ function CollabPageEditor({
|
||||
{
|
||||
extensions,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
@@ -484,6 +485,7 @@ function StaticPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
@@ -93,6 +94,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
|
||||
@@ -86,6 +86,7 @@ export function TitleEditor({
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
|
||||
@@ -31,6 +31,7 @@ export function HistoryEditor({
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
textDirection: "auto",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -53,6 +54,7 @@ try {
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
EncryptionModule,
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
|
||||
@@ -50,7 +50,12 @@ import {
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
extensions as coreExtensions,
|
||||
generateText,
|
||||
getSchema,
|
||||
JSONContent,
|
||||
} from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
@@ -61,6 +66,7 @@ import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
coreExtensions.TextDirection.configure({ direction: 'auto' }),
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
codeBlock: false,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: c0f9440de9...b38dc5ecd1
@@ -0,0 +1,13 @@
|
||||
export class UnableToInitialize extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Unable to initialize the encryption service: ${message}`);
|
||||
this.name = 'UnableToInitialize';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnableToDecrypt extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
||||
this.name = 'UnableToDecrypt';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
||||
|
||||
const buildService = (appSecret: string | undefined) => {
|
||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
||||
return new EncryptionService(env);
|
||||
};
|
||||
|
||||
const decodeEnvelope = (encrypted: string) =>
|
||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
};
|
||||
|
||||
const encodeEnvelope = (envelope: {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EncryptionService,
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { getAppSecret: () => APP_SECRET },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EncryptionService>(EncryptionService);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('compiles via Nest DI', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt + decrypt round-trip', () => {
|
||||
it('decrypts back to the original plaintext', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const encrypted = service.encrypt('');
|
||||
expect(service.decrypt(encrypted)).toBe('');
|
||||
});
|
||||
|
||||
it('handles unicode (multi-byte UTF-8)', () => {
|
||||
const plaintext = 'héllo 🔐 世界';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles long plaintext (>1 block)', () => {
|
||||
const plaintext = 'a'.repeat(10_000);
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
||||
const plaintext = 'same input';
|
||||
const a = service.encrypt(plaintext);
|
||||
const b = service.encrypt(plaintext);
|
||||
expect(a).not.toBe(b);
|
||||
expect(service.decrypt(a)).toBe(plaintext);
|
||||
expect(service.decrypt(b)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-key isolation', () => {
|
||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
||||
const other = buildService('totally-different-secret-value-9876543210');
|
||||
const encrypted = service.encrypt('secret');
|
||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tamper detection', () => {
|
||||
it('rejects modified ciphertext', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
||||
tamperedCipher[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
cipherText: tamperedCipher.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
||||
tamperedTag[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
authTag: tamperedTag.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
||||
tamperedIV[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
iv: tamperedIV.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payloads', () => {
|
||||
it('rejects non-base64 garbage', () => {
|
||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
||||
UnableToDecrypt,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects base64 of non-JSON', () => {
|
||||
const garbage = Buffer.from('not json at all').toString('base64');
|
||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects JSON missing required fields', () => {
|
||||
const partial = encodeEnvelope({
|
||||
iv: Buffer.alloc(12).toString('base64'),
|
||||
authTag: Buffer.alloc(16).toString('base64'),
|
||||
} as never);
|
||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
iv: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
authTag: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelope format', () => {
|
||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
type AEADPayload<TFormat = string | Buffer> = {
|
||||
iv: TFormat;
|
||||
authTag: TFormat;
|
||||
cipherText: TFormat;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
const appSecret = environmentService.getAppSecret();
|
||||
if (!appSecret) {
|
||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
||||
}
|
||||
this.key = createHash('sha256')
|
||||
.update(KEY_DOMAIN)
|
||||
.update(appSecret)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
||||
const cipherText = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const aead: AEADPayload<string> = {
|
||||
iv: iv.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
cipherText: cipherText.toString('base64'),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
||||
}
|
||||
|
||||
public decrypt(encrypted: string): string {
|
||||
try {
|
||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(cipherText),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (e: unknown) {
|
||||
throw new UnableToDecrypt((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
||||
const payload = Buffer.from(encodedPayload, 'base64');
|
||||
|
||||
let deserializedPkg: Record<string, unknown>;
|
||||
try {
|
||||
deserializedPkg = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
||||
}
|
||||
|
||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
||||
}
|
||||
}
|
||||
|
||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
||||
if (iv.length !== IV_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cipherText = Buffer.from(
|
||||
deserializedPkg.cipherText as string,
|
||||
'base64',
|
||||
);
|
||||
|
||||
return { iv, authTag, cipherText };
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
base() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
|
||||
Reference in New Issue
Block a user