diff --git a/app/package-lock.json b/app/package-lock.json index 5279e19..918ae22 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -183,6 +183,51 @@ "@electric-sql/pglite": "0.4.3" } }, + "node_modules/@hocuspocus/common": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.7.0.tgz", + "integrity": "sha512-KyS6dXoIcWsd+v3gwd6nnZW04M77df5XstPNkOwqoDxwuncygobEN7xsf0x2PmdvlQdQyRaLm2SF4r1GZZGRnA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.117" + } + }, + "node_modules/@hocuspocus/provider": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.7.0.tgz", + "integrity": "sha512-BOAqoj6g4EZQ9pTwIiR7bNl1QkMAnrr8EY69jMcdhSLFtTrsKaE8hsDIEncPFnGq+khWRFCAPEJSM7F9HC7fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "@lifeomic/attempt": "^3.1.0", + "lib0": "^0.2.117" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, + "node_modules/@hocuspocus/server": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-4.7.0.tgz", + "integrity": "sha512-rlHSxGXEkYvHgQa9Q8prjMHChIl7daZlXL1pezifS8j+svLHQPKLfevNBxFKc+ZW5pLpUiUJGU++O8eJlSt3Ew==", + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "async-mutex": "^0.5.0", + "crossws": "^0.4.4", + "kleur": "^4.1.5", + "lib0": "^0.2.117" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", @@ -577,6 +622,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lifeomic/attempt": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", + "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", + "dev": true, + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2922,6 +2974,15 @@ "node": ">=12" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -3365,6 +3426,20 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.12.tgz", + "integrity": "sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng==", + "license": "MIT", + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4389,6 +4464,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -4476,6 +4561,36 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/libphonenumber-js": { "version": "1.13.14", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz", @@ -7153,7 +7268,6 @@ "version": "8.22.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7180,6 +7294,43 @@ "node": ">=0.4" } }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yoctocolors": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", @@ -7207,6 +7358,7 @@ "name": "strata-server", "version": "0.1.0", "dependencies": { + "@hocuspocus/server": "4.7.0", "@nestjs/common": "12.1.0", "@nestjs/config": "12.0.1", "@nestjs/core": "12.1.0", @@ -7224,14 +7376,19 @@ "prisma": "7.10.0", "redis": "6.2.1", "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "ws": "8.22.0", + "y-protocols": "1.0.7", + "yjs": "13.6.33" }, "devDependencies": { + "@hocuspocus/provider": "4.7.0", "@nestjs/cli": "12.0.5", "@nestjs/testing": "12.1.0", "@types/cookie-parser": "1.4.10", "@types/node": "26.6.2", "@types/passport-jwt": "4.0.1", + "@types/ws": "8.18.2", "typescript": "6.0.2", "vitest": "5.0.1" } diff --git a/app/server/package.json b/app/server/package.json index 26be3ac..97af1a9 100644 --- a/app/server/package.json +++ b/app/server/package.json @@ -17,6 +17,7 @@ "system-manager:grant": "node dist/admin/grant-system-manager.command.js" }, "dependencies": { + "@hocuspocus/server": "4.7.0", "@nestjs/common": "12.1.0", "@nestjs/config": "12.0.1", "@nestjs/core": "12.1.0", @@ -34,14 +35,19 @@ "prisma": "7.10.0", "redis": "6.2.1", "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "ws": "8.22.0", + "y-protocols": "1.0.7", + "yjs": "13.6.33" }, "devDependencies": { + "@hocuspocus/provider": "4.7.0", "@nestjs/cli": "12.0.5", "@nestjs/testing": "12.1.0", "@types/cookie-parser": "1.4.10", "@types/node": "26.6.2", "@types/passport-jwt": "4.0.1", + "@types/ws": "8.18.2", "typescript": "6.0.2", "vitest": "5.0.1" } diff --git a/app/server/prisma/migrations/20261005020000_notes/migration.sql b/app/server/prisma/migrations/20261005020000_notes/migration.sql new file mode 100644 index 0000000..d7adb3a --- /dev/null +++ b/app/server/prisma/migrations/20261005020000_notes/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "notes" ( + "item_id" UUID NOT NULL, + "parent_id" UUID, + "position" DOUBLE PRECISION NOT NULL DEFAULT 0, + "icon" TEXT, + "pinned_at" TIMESTAMP(3), + CONSTRAINT "notes_pkey" PRIMARY KEY ("item_id") +); + +CREATE TABLE "note_documents" ( + "item_id" UUID NOT NULL, + "state" BYTEA NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "note_documents_pkey" PRIMARY KEY ("item_id") +); + +CREATE INDEX "notes_parent_id_idx" ON "notes"("parent_id"); + +ALTER TABLE "notes" ADD CONSTRAINT "notes_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "notes" ADD CONSTRAINT "notes_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "note_documents" ADD CONSTRAINT "note_documents_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "notes" ("item_id") SELECT "id" FROM "items" WHERE "kind" = 'note'; diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma index 5e71f9a..84b51f8 100644 --- a/app/server/prisma/schema.prisma +++ b/app/server/prisma/schema.prisma @@ -247,6 +247,9 @@ model Item { prices SubscriptionPrice[] event Event? guests EventGuest[] + note Note? @relation("NoteItem") + childNotes Note[] @relation("NoteParent") + noteDocument NoteDocument? tidyChanges TidyChange[] @@index([spaceId, updatedAt]) @@ -644,3 +647,25 @@ model ExchangeRate { @@map("exchange_rates") } + +model Note { + itemId String @id @map("item_id") @db.Uuid + parentId String? @map("parent_id") @db.Uuid + position Float @default(0) + icon String? + pinnedAt DateTime? @map("pinned_at") + item Item @relation("NoteItem", fields: [itemId], references: [id], onDelete: Cascade) + parent Item? @relation("NoteParent", fields: [parentId], references: [id], onDelete: SetNull) + + @@index([parentId]) + @@map("notes") +} + +model NoteDocument { + itemId String @id @map("item_id") @db.Uuid + state Bytes + updatedAt DateTime @updatedAt @map("updated_at") + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@map("note_documents") +} diff --git a/app/server/src/app.module.ts b/app/server/src/app.module.ts index addb5ed..42e2d87 100644 --- a/app/server/src/app.module.ts +++ b/app/server/src/app.module.ts @@ -17,7 +17,9 @@ import { InboxModule } from './inbox/inbox.module'; import { ItemsModule } from './items/items.module'; import { JobsModule } from './jobs/jobs.module'; import { MailModule } from './mail/mail.module'; +import { NotesModule } from './notes/notes.module'; import { PrismaModule } from './prisma/prisma.module'; +import { RealtimeModule } from './realtime/realtime.module'; import { RedisModule } from './redis/redis.module'; import { SearchModule } from './search/search.module'; import { ShareLinksModule } from './share-links/share-links.module'; @@ -59,6 +61,8 @@ import { WidgetsModule } from './widgets/widgets.module'; EventsModule, ExchangeRatesModule, TidyModule, + NotesModule, + RealtimeModule, WidgetsModule, HealthModule, ], diff --git a/app/server/src/auth/auth.module.ts b/app/server/src/auth/auth.module.ts index b6fc7dd..1d9608d 100644 --- a/app/server/src/auth/auth.module.ts +++ b/app/server/src/auth/auth.module.ts @@ -18,6 +18,6 @@ import { TwoStepService } from './two-step.service'; ], controllers: [AuthController], providers: [AuthService, JwtStrategy, JwtAuthGuard, PasswordService, SessionsService, TwoStepService], - exports: [PassportModule, JwtAuthGuard, AuthService, SessionsService, TwoStepService], + exports: [PassportModule, JwtModule, JwtStrategy, JwtAuthGuard, AuthService, SessionsService, TwoStepService], }) export class AuthModule {} diff --git a/app/server/src/notes/document-text.ts b/app/server/src/notes/document-text.ts new file mode 100644 index 0000000..e15665b --- /dev/null +++ b/app/server/src/notes/document-text.ts @@ -0,0 +1,21 @@ +import * as Y from 'yjs'; + +const blockSeparator = '\n'; + +function textOf(node: Y.XmlElement | Y.XmlText | Y.XmlFragment): string { + if (node instanceof Y.XmlText) { + return node + .toDelta() + .map((part: { insert: unknown }) => (typeof part.insert === 'string' ? part.insert : '')) + .join(''); + } + return node + .toArray() + .map((child) => (child instanceof Y.XmlHook ? '' : textOf(child))) + .filter(Boolean) + .join(blockSeparator); +} + +export function documentText(document: Y.Doc, maxLength = 100_000) { + return textOf(document.getXmlFragment('default')).replace(/\n{3,}/g, '\n\n').trim().slice(0, maxLength); +} diff --git a/app/server/src/notes/dto/notes.dto.ts b/app/server/src/notes/dto/notes.dto.ts new file mode 100644 index 0000000..70d738f --- /dev/null +++ b/app/server/src/notes/dto/notes.dto.ts @@ -0,0 +1,42 @@ +import { IsBoolean, IsNumber, IsOptional, IsString, IsUUID, MaxLength, ValidateIf } from 'class-validator'; +import { IsOptionalNotNull } from '../../validation/is-optional-not-null.decorator'; +import { Trimmed } from '../../validation/trimmed.decorator'; + +export class CreateNoteDto { + @IsOptional() + @Trimmed() + @IsString() + @MaxLength(200, { message: 'Titles are at most 200 characters long.' }) + title?: string; + + @IsOptional() + @IsUUID('all', { message: 'Choose a page from this space.' }) + parentId?: string; +} + +export class UpdateNoteDto { + @IsOptionalNotNull() + @Trimmed() + @IsString() + @MaxLength(200, { message: 'Titles are at most 200 characters long.' }) + title?: string; + + @IsOptional() + @ValidateIf((_input, value) => value !== null) + @IsUUID('all', { message: 'Choose a page from this space.' }) + parentId?: string | null; + + @IsOptionalNotNull() + @IsNumber() + position?: number; + + @IsOptional() + @ValidateIf((_input, value) => value !== null) + @IsString() + @MaxLength(8, { message: 'An icon is a single emoji.' }) + icon?: string | null; + + @IsOptionalNotNull() + @IsBoolean() + pinned?: boolean; +} diff --git a/app/server/src/notes/notes.controller.ts b/app/server/src/notes/notes.controller.ts new file mode 100644 index 0000000..72fb0f3 --- /dev/null +++ b/app/server/src/notes/notes.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Scope } from '../access-tokens/scopes'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { UuidPipe } from '../validation/uuid.pipe'; +import { CreateNoteDto, UpdateNoteDto } from './dto/notes.dto'; +import { NotesService } from './notes.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class NotesController { + constructor(private readonly notes: NotesService) {} + + @Get('spaces/:spaceId/notes') + @Scope('items:read') + list(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string) { + return this.notes.list(user.id, spaceId); + } + + @Post('spaces/:spaceId/notes') + @Scope('items:write') + create(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string, @Body() input: CreateNoteDto) { + return this.notes.create(user.id, spaceId, input); + } + + @Get('notes/:id') + @Scope('items:read') + get(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + return this.notes.get(user.id, id); + } + + @Patch('notes/:id') + @Scope('items:write') + update(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: UpdateNoteDto) { + return this.notes.update(user.id, id, input); + } +} diff --git a/app/server/src/notes/notes.module.ts b/app/server/src/notes/notes.module.ts new file mode 100644 index 0000000..c9f90ae --- /dev/null +++ b/app/server/src/notes/notes.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { NotesController } from './notes.controller'; +import { NotesService } from './notes.service'; + +@Module({ + imports: [AuthModule], + controllers: [NotesController], + providers: [NotesService], + exports: [NotesService], +}) +export class NotesModule {} diff --git a/app/server/src/notes/notes.service.ts b/app/server/src/notes/notes.service.ts new file mode 100644 index 0000000..809378f --- /dev/null +++ b/app/server/src/notes/notes.service.ts @@ -0,0 +1,164 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { ItemKind, Prisma, SpaceRole } from '@prisma/client'; +import { AccessService } from '../access/access.service'; +import { ActivityService } from '../activity/activity.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateNoteDto, UpdateNoteDto } from './dto/notes.dto'; + +const noteFields = { + id: true, + spaceId: true, + title: true, + createdAt: true, + updatedAt: true, + note: { select: { parentId: true, position: true, icon: true, pinnedAt: true } }, +} satisfies Prisma.ItemSelect; + +type NoteRow = Prisma.ItemGetPayload<{ select: typeof noteFields }>; + +function present(row: NoteRow) { + return { + id: row.id, + spaceId: row.spaceId, + title: row.title, + parentId: row.note?.parentId ?? null, + position: row.note?.position ?? 0, + icon: row.note?.icon ?? null, + pinnedAt: row.note?.pinnedAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +@Injectable() +export class NotesService { + constructor( + private readonly prisma: PrismaService, + private readonly access: AccessService, + private readonly activity: ActivityService, + ) {} + + async list(userId: string, spaceId: string) { + await this.access.assertSpace(userId, spaceId, 'read'); + const rows = await this.prisma.item.findMany({ + where: { spaceId, kind: ItemKind.NOTE, trashedAt: null, archivedAt: null }, + orderBy: [{ note: { position: 'asc' } }, { createdAt: 'asc' }, { id: 'asc' }], + select: noteFields, + }); + return rows.map(present); + } + + async create(userId: string, spaceId: string, input: CreateNoteDto) { + await this.access.assertSpace(userId, spaceId, 'edit'); + if (input.parentId) await this.parentIn(spaceId, input.parentId); + const last = await this.prisma.note.aggregate({ + where: { parentId: input.parentId ?? null, item: { spaceId, trashedAt: null } }, + _max: { position: true }, + }); + const created = await this.prisma.$transaction(async (transaction) => { + const item = await transaction.item.create({ + data: { + spaceId, + kind: ItemKind.NOTE, + title: input.title ?? '', + createdById: userId, + updatedById: userId, + note: { create: { parentId: input.parentId ?? null, position: (last._max.position ?? 0) + 1 } }, + }, + select: noteFields, + }); + await this.activity.record(transaction, { spaceId, actorId: userId, itemId: item.id, verb: 'item.created', data: { title: item.title } }); + return item; + }); + return present(created); + } + + async get(userId: string, noteId: string) { + const found = await this.note(userId, noteId, 'read'); + const row = await this.prisma.item.findUniqueOrThrow({ where: { id: noteId }, select: noteFields }); + return { ...present(row), editable: found.role !== SpaceRole.VIEWER, path: await this.ancestors(row.note?.parentId ?? null) }; + } + + async update(userId: string, noteId: string, input: UpdateNoteDto) { + const found = await this.note(userId, noteId, 'edit'); + if (found.trashedAt) throw new BadRequestException('Restore this page from the trash before changing it'); + if (input.parentId) { + await this.parentIn(found.spaceId, input.parentId); + if (input.parentId === noteId || (await this.ancestors(input.parentId)).some((ancestor) => ancestor.id === noteId)) { + throw new BadRequestException('A page cannot move inside itself'); + } + } + const moved = input.parentId !== undefined && input.position === undefined; + const last = moved + ? await this.prisma.note.aggregate({ + where: { parentId: input.parentId, itemId: { not: noteId }, item: { spaceId: found.spaceId, trashedAt: null } }, + _max: { position: true }, + }) + : null; + const position = last ? (last._max.position ?? 0) + 1 : input.position; + const updated = await this.prisma.$transaction(async (transaction) => { + await transaction.note.upsert({ + where: { itemId: noteId }, + create: { + itemId: noteId, + parentId: input.parentId ?? null, + position: position ?? 0, + icon: input.icon ?? null, + pinnedAt: input.pinned ? new Date() : null, + }, + update: { + parentId: input.parentId, + position, + icon: input.icon, + pinnedAt: input.pinned === undefined ? undefined : input.pinned ? new Date() : null, + }, + }); + const item = await transaction.item.update({ + where: { id: noteId }, + data: { title: input.title, updatedById: userId }, + select: noteFields, + }); + if (input.title !== undefined && input.title !== found.title) { + await this.activity.record(transaction, { + spaceId: found.spaceId, + actorId: userId, + itemId: noteId, + verb: 'item.updated', + data: { title: input.title, renamedFrom: found.title }, + }); + } + return item; + }); + return present(updated); + } + + async note(userId: string, noteId: string, access: 'read' | 'edit') { + const found = await this.access.assertItem(userId, noteId, access); + const item = await this.prisma.item.findUniqueOrThrow({ where: { id: noteId }, select: { kind: true } }); + if (item.kind !== ItemKind.NOTE) throw new NotFoundException('Note not found'); + return found; + } + + private async parentIn(spaceId: string, parentId: string) { + const parent = await this.prisma.item.findFirst({ + where: { id: parentId, spaceId, kind: ItemKind.NOTE, trashedAt: null }, + select: { id: true }, + }); + if (!parent) throw new BadRequestException('Choose a page from this space'); + } + + private async ancestors(parentId: string | null) { + const path: { id: string; title: string }[] = []; + let current = parentId; + while (current && path.length < 50) { + const row: { id: string; title: string; note: { parentId: string | null } | null } | null = await this.prisma.item.findUnique({ + where: { id: current }, + select: { id: true, title: true, note: { select: { parentId: true } } }, + }); + if (!row || path.some((step) => step.id === row.id)) break; + path.unshift({ id: row.id, title: row.title }); + current = row.note?.parentId ?? null; + } + return path; + } +} diff --git a/app/server/src/realtime/realtime.module.ts b/app/server/src/realtime/realtime.module.ts new file mode 100644 index 0000000..b93edb4 --- /dev/null +++ b/app/server/src/realtime/realtime.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { RealtimeService } from './realtime.service'; + +@Module({ + imports: [AuthModule], + providers: [RealtimeService], + exports: [RealtimeService], +}) +export class RealtimeModule {} diff --git a/app/server/src/realtime/realtime.service.ts b/app/server/src/realtime/realtime.service.ts new file mode 100644 index 0000000..0aefce9 --- /dev/null +++ b/app/server/src/realtime/realtime.service.ts @@ -0,0 +1,148 @@ +import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { ItemKind, SpaceRole } from '@prisma/client'; +import { Hocuspocus } from '@hocuspocus/server'; +import type { IncomingMessage, Server } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { WebSocketServer } from 'ws'; +import * as Y from 'yjs'; +import { AccessService } from '../access/access.service'; +import { JwtStrategy } from '../auth/jwt.strategy'; +import { documentText } from '../notes/document-text'; +import { PrismaService } from '../prisma/prisma.service'; + +export const realtimePath = '/api/v1/realtime'; +const maxUpdateBytes = 2 * 1024 * 1024; +const maxDocumentBytes = 5 * 1024 * 1024; +const maxDocumentsPerSocket = 20; +const recheckMs = 60_000; + +export interface RealtimeContext { + userId: string; + role: SpaceRole; +} + +function rejectUpgrade(socket: Duplex, status: number, reason: string) { + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} + +@Injectable() +export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy { + private readonly logger = new Logger(RealtimeService.name); + private readonly sockets = new WebSocketServer({ noServer: true, maxPayload: maxUpdateBytes }); + private readonly documentsPerSocket = new Map>(); + private readonly trustedOrigins: string[]; + private recheck?: NodeJS.Timeout; + readonly hocuspocus: Hocuspocus; + + constructor( + private readonly adapterHost: HttpAdapterHost, + private readonly prisma: PrismaService, + private readonly access: AccessService, + private readonly jwt: JwtService, + private readonly sessions: JwtStrategy, + config: ConfigService, + ) { + this.trustedOrigins = config.get('AUTH_TRUSTED_ORIGINS')?.split(',') ?? []; + const secret = config.getOrThrow('AUTH_ACCESS_TOKEN_SECRET'); + this.hocuspocus = new Hocuspocus({ + quiet: true, + debounce: 2_000, + maxDebounce: 10_000, + onAuthenticate: async ({ connectionConfig, documentName, socketId, token }) => { + const user = await this.sessions.validate(await this.jwt.verifyAsync(token, { secret })); + const found = await this.access.assertItem(user.id, documentName, 'read'); + const item = await this.prisma.item.findUniqueOrThrow({ where: { id: documentName }, select: { kind: true } }); + if (item.kind !== ItemKind.NOTE || found.trashedAt) throw new Error('Only notes can be opened here'); + const open = this.documentsPerSocket.get(socketId) ?? new Set(); + if (!open.has(documentName) && open.size >= maxDocumentsPerSocket) throw new Error('Too many open documents'); + open.add(documentName); + this.documentsPerSocket.set(socketId, open); + connectionConfig.readOnly = found.role === SpaceRole.VIEWER; + return { userId: user.id, role: found.role }; + }, + onLoadDocument: async ({ document, documentName }) => { + const stored = await this.prisma.noteDocument.findUnique({ where: { itemId: documentName }, select: { state: true } }); + if (stored) Y.applyUpdate(document, new Uint8Array(stored.state)); + return document; + }, + onStoreDocument: async ({ document, documentName, lastContext }) => { + const state = Y.encodeStateAsUpdate(document); + if (state.byteLength > maxDocumentBytes) { + this.logger.warn(`Note ${documentName} is over the size limit and was not saved`); + document.broadcastStateless(JSON.stringify({ type: 'too-large' })); + return; + } + await this.prisma.$transaction([ + this.prisma.noteDocument.upsert({ + where: { itemId: documentName }, + create: { itemId: documentName, state: Buffer.from(state) }, + update: { state: Buffer.from(state) }, + }), + this.prisma.searchDocument.updateMany({ where: { itemId: documentName }, data: { bodyText: documentText(document), updatedAt: new Date() } }), + this.prisma.item.update({ + where: { id: documentName }, + data: { updatedById: lastContext?.userId ?? undefined, updatedAt: new Date() }, + }), + ]); + }, + onDisconnect: async ({ documentName, socketId }) => { + const open = this.documentsPerSocket.get(socketId); + open?.delete(documentName); + if (open?.size === 0) this.documentsPerSocket.delete(socketId); + }, + }); + } + + onApplicationBootstrap() { + const server = this.adapterHost.httpAdapter.getHttpServer() as Server; + server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => this.upgrade(request, socket, head)); + this.recheck = setInterval(() => void this.recheckAccess(), recheckMs); + this.recheck.unref(); + } + + async onModuleDestroy() { + clearInterval(this.recheck); + this.hocuspocus.closeConnections(); + this.hocuspocus.flushPendingStores(); + this.sockets.close(); + } + + async recheckAccess() { + for (const [name, document] of this.hocuspocus.documents) { + for (const connection of document.connections.keys()) { + const context = connection.context as RealtimeContext | undefined; + if (!context) continue; + const role = await this.access + .assertItem(context.userId, name, 'read') + .then((found) => (found.trashedAt ? null : found.role)) + .catch(() => null); + if (role !== context.role) connection.close({ code: 4403, reason: 'Access changed' }); + } + } + } + + private upgrade(request: IncomingMessage, socket: Duplex, head: Buffer) { + const url = new URL(request.url ?? '/', 'http://localhost'); + if (url.pathname !== realtimePath) { + rejectUpgrade(socket, 404, 'Not Found'); + return; + } + if (!this.trustedOrigins.includes(request.headers.origin ?? '')) { + rejectUpgrade(socket, 403, 'Forbidden'); + return; + } + this.sockets.handleUpgrade(request, socket, head, (websocket) => { + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === 'string') headers.set(key, value); + } + const connection = this.hocuspocus.handleConnection(websocket, new Request(new URL(request.url ?? '/', 'http://localhost'), { headers })); + websocket.on('message', (data: Buffer) => connection.handleMessage(new Uint8Array(data))); + websocket.on('close', (code: number, reason: Buffer) => connection.handleClose({ code, reason: reason.toString() })); + websocket.on('error', (error) => this.logger.warn(`Realtime socket error: ${error.message}`)); + }); + } +} diff --git a/app/server/test/document-text.spec.ts b/app/server/test/document-text.spec.ts new file mode 100644 index 0000000..f232e5e --- /dev/null +++ b/app/server/test/document-text.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { documentText } from '../src/notes/document-text'; + +function block(name: string, ...children: (Y.XmlElement | Y.XmlText)[]) { + const element = new Y.XmlElement(name); + element.insert(0, children); + return element; +} + +describe('documentText', () => { + it('turns a note into plain text with one line per block and no formatting', () => { + const document = new Y.Doc(); + const bold = new Y.XmlText(); + bold.insert(0, 'Pack '); + bold.insert(5, 'light', { bold: true }); + document.getXmlFragment('default').push([ + block('heading', new Y.XmlText('Trip')), + block('paragraph', bold), + block('bulletList', block('listItem', block('paragraph', new Y.XmlText('Passport'))), block('listItem', block('paragraph', new Y.XmlText('Charger')))), + ]); + + expect(documentText(document)).toBe('Trip\nPack light\nPassport\nCharger'); + expect(documentText(document, 6)).toBe('Trip\nP'); + expect(documentText(new Y.Doc())).toBe(''); + }); +}); diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts new file mode 100644 index 0000000..3673c0e --- /dev/null +++ b/app/server/test/integration/notes.spec.ts @@ -0,0 +1,153 @@ +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; +import { SpaceRole } from '@prisma/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import WebSocket from 'ws'; +import * as Y from 'yjs'; +import { RealtimeService } from '../../src/realtime/realtime.service'; +import { integrationApp, type Member } from './harness'; + +class TrustedSocket extends WebSocket { + constructor(url: string, protocols?: string | string[]) { + super(url, protocols, { origin: 'http://localhost:4104' }); + } +} + +const settle = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function until(read: () => Promise, done: (value: T) => boolean) { + for (let attempt = 0; attempt < 40; attempt += 1) { + const value = await read(); + if (done(value)) return value; + await settle(100); + } + return read(); +} + +function write(document: Y.Doc, text: string) { + const paragraph = new Y.XmlElement('paragraph'); + paragraph.insert(0, [new Y.XmlText(text)]); + document.getXmlFragment('default').push([paragraph]); +} + +const textOf = (document: Y.Doc) => document.getXmlFragment('default').toString(); + +describe('Notes and real-time editing against Postgres', () => { + const strata = integrationApp(); + const { member } = strata; + const providers: HocuspocusProvider[] = []; + + afterEach(() => { + for (const provider of providers.splice(0)) provider.destroy(); + }); + + function open(as: Member, name: string) { + const document = new Y.Doc(); + const websocketProvider = new HocuspocusProviderWebsocket({ + url: `${strata.base.replace(/^http/, 'ws')}/api/v1/realtime`, + WebSocketPolyfill: TrustedSocket, + }); + return new Promise<{ provider: HocuspocusProvider; document: Y.Doc }>((resolve, reject) => { + const provider: HocuspocusProvider = new HocuspocusProvider({ + name, + token: as.token, + document, + websocketProvider, + onSynced: (): void => resolve({ provider, document }), + onAuthenticationFailed: ({ reason }) => reject(new Error(reason)), + }); + provider.attach(); + providers.push(provider); + }); + } + + it('keeps a page tree inside one space and refuses loops and viewers', async () => { + const owner = await member('writer'); + const viewer = await member('glancer'); + const space = (await owner.call('POST', '/spaces', { name: 'Journal' })).body; + await strata.join(space.id, viewer, SpaceRole.VIEWER); + + const parent = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Trips' })).body; + const child = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Lisbon', parentId: parent.id })).body; + const grandchild = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Food', parentId: child.id })).body; + expect(child).toMatchObject({ parentId: parent.id, position: 1 }); + + expect((await owner.call('GET', `/notes/${grandchild.id}`)).body).toMatchObject({ + editable: true, + path: [ + { id: parent.id, title: 'Trips' }, + { id: child.id, title: 'Lisbon' }, + ], + }); + expect((await owner.call('PATCH', `/notes/${parent.id}`, { parentId: grandchild.id })).status).toBe(400); + expect((await owner.call('PATCH', `/notes/${parent.id}`, { parentId: parent.id })).status).toBe(400); + expect((await owner.call('POST', `/spaces/${space.id}/notes`, { parentId: (await owner.call('POST', `/spaces/${owner.personalSpaceId}/notes`, {})).body.id })).status).toBe(400); + expect((await owner.call('PATCH', `/notes/${child.id}`, { title: 'Lisbon 2027', parentId: null, icon: '🌍', pinned: true })).body).toMatchObject({ + title: 'Lisbon 2027', + parentId: null, + icon: '🌍', + }); + + expect((await viewer.call('GET', `/spaces/${space.id}/notes`)).body.map((note: { title: string }) => note.title)).toEqual(['Trips', 'Food', 'Lisbon 2027']); + expect((await viewer.call('GET', `/notes/${parent.id}`)).body.editable).toBe(false); + expect((await viewer.call('POST', `/spaces/${space.id}/notes`, { title: 'Mine' })).status).toBe(403); + expect((await viewer.call('PATCH', `/notes/${parent.id}`, { title: 'Mine' })).status).toBe(403); + }); + + it('syncs edits between people, saves them, feeds search and drops a viewer’s changes', async () => { + const owner = await member('cowriter'); + const friend = await member('friend'); + const viewer = await member('onlooker'); + const outsider = await member('stranger'); + const space = (await owner.call('POST', '/spaces', { name: 'Plans' })).body; + await strata.join(space.id, friend, SpaceRole.EDITOR); + await strata.join(space.id, viewer, SpaceRole.VIEWER); + const note = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Packing' })).body; + + const mine = await open(owner, note.id); + const theirs = await open(friend, note.id); + const watching = await open(viewer, note.id); + write(mine.document, 'Passport and charger'); + await until(async () => textOf(theirs.document), (text) => text.includes('Passport and charger')); + expect(textOf(theirs.document)).toContain('Passport and charger'); + + write(watching.document, 'Viewer scribble'); + await settle(500); + expect(textOf(mine.document)).not.toContain('Viewer scribble'); + expect(textOf(strata.service(RealtimeService).hocuspocus.documents.get(note.id)!)).not.toContain('Viewer scribble'); + + strata.service(RealtimeService).hocuspocus.flushPendingStores(); + const stored = await until( + () => strata.prisma.searchDocument.findUnique({ where: { itemId: note.id } }), + (row) => Boolean(row?.bodyText.includes('Passport and charger')), + ); + expect(stored?.bodyText).toBe('Passport and charger'); + expect(await strata.prisma.noteDocument.count({ where: { itemId: note.id } })).toBe(1); + const found = (await owner.call('GET', '/search?q=charger')).body; + expect(found.items.map((result: { id: string }) => result.id)).toContain(note.id); + + await expect(open(outsider, note.id)).rejects.toThrow(); + }); + + it('refuses other origins and disconnects someone who lost access', async () => { + const owner = await member('host'); + const guest = await member('guest'); + const space = (await owner.call('POST', '/spaces', { name: 'Shared' })).body; + await strata.join(space.id, guest, SpaceRole.EDITOR); + const note = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Ideas' })).body; + + const refused = await new Promise((resolve) => { + const socket = new WebSocket(`${strata.base.replace(/^http/, 'ws')}/api/v1/realtime`, { origin: 'https://evil.example' }); + socket.on('unexpected-response', (_request, response) => resolve(response.statusCode ?? 0)); + socket.on('open', () => resolve(101)); + }); + expect(refused).toBe(403); + + await open(guest, note.id); + const realtime = strata.service(RealtimeService); + const connections = () => [...(realtime.hocuspocus.documents.get(note.id)?.connections.keys() ?? [])].length; + expect(connections()).toBe(1); + await strata.prisma.spaceMember.delete({ where: { spaceId_userId: { spaceId: space.id, userId: guest.id } } }); + await realtime.recheckAccess(); + expect(await until(async () => connections(), (count) => count === 0)).toBe(0); + }); +});