From de4938e75ca7888dd8151ff10b1f72898610103f Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sun, 19 Apr 2026 23:23:48 -0400 Subject: [PATCH] feat: add shares schema, service layer, and lifecycle integration (#308) - Add `shares` and `share_recipients` tables to Drizzle schema with indices - Add migration 0010_shares.sql for shares/share_recipients tables - Add `server/lib/password.ts` extracting scrypt hash/verify from auth.ts to eliminate duplicate crypto params across services - Add `server/services/share.ts` implementing full CRUD + atomic counters: createShare, getShareByToken, incrementViews, incrementDownloadsAtomic (atomic SQL UPDATE), listSharesByCreator, revokeShare, cascadeDeleteByMatter - Add `shared/schemas/share.ts` Zod validation schemas - Export Share, ShareKind, ShareRecipient from shared/types - Extend `purge.ts` to cascade-delete shares on matter hard-delete - Add 38 integration tests and CF Workers atomic counter race tests Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob --- migrations/0010_shares.sql | 31 ++ migrations/meta/_journal.json | 9 +- server/auth.ts | 27 +- server/db/schema.ts | 37 +- server/lib/password.ts | 18 + server/services/purge.ts | 5 + server/services/share.cf-test.ts | 116 +++++ server/services/share.integration.test.ts | 555 ++++++++++++++++++++++ server/services/share.ts | 180 +++++++ server/test/setup.ts | 25 + shared/schemas/index.ts | 2 + shared/schemas/share.ts | 29 ++ shared/types/index.ts | 28 ++ 13 files changed, 1043 insertions(+), 19 deletions(-) create mode 100644 migrations/0010_shares.sql create mode 100644 server/lib/password.ts create mode 100644 server/services/share.cf-test.ts create mode 100644 server/services/share.integration.test.ts create mode 100644 server/services/share.ts create mode 100644 shared/schemas/share.ts diff --git a/migrations/0010_shares.sql b/migrations/0010_shares.sql new file mode 100644 index 00000000..88bf2c7a --- /dev/null +++ b/migrations/0010_shares.sql @@ -0,0 +1,31 @@ +CREATE TABLE `shares` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `kind` text NOT NULL, + `matter_id` text NOT NULL, + `org_id` text NOT NULL, + `creator_id` text NOT NULL, + `password_hash` text, + `expires_at` integer, + `download_limit` integer, + `views` integer DEFAULT 0 NOT NULL, + `downloads` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `shares_token_unique` ON `shares` (`token`); +--> statement-breakpoint +CREATE INDEX `shares_creator_status_created_idx` ON `shares` (`creator_id`,`status`,`created_at`); +--> statement-breakpoint +CREATE TABLE `share_recipients` ( + `id` text PRIMARY KEY NOT NULL, + `share_id` text NOT NULL, + `recipient_user_id` text, + `recipient_email` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `share_recipients_share_id_idx` ON `share_recipients` (`share_id`); +--> statement-breakpoint +CREATE INDEX `share_recipients_user_id_idx` ON `share_recipients` (`recipient_user_id`); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index d9052bcc..703f40ee 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1776200000000, "tag": "0009_matters_active_name_uniq", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1745000000000, + "tag": "0010_shares", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts index 17bacd2c..3290031f 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -1,4 +1,3 @@ -import crypto from 'node:crypto' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { admin, organization, username } from 'better-auth/plugins' @@ -15,6 +14,7 @@ import { } from '../shared/oauth-providers' import * as authSchema from './db/auth-schema' import { orgQuotas, systemOptions } from './db/schema' +import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password' import type { Database } from './platform/interface' import { sendEmail } from './services/email' import { redeemInviteCode, validateInviteCode } from './services/invite' @@ -22,23 +22,16 @@ import { findPersonalOrg } from './services/org' // better-auth's default password hasher is pure-JS scrypt from @noble/hashes, // which blows past Cloudflare Workers' CPU budget and triggers error 1102. -// node:crypto.scryptSync is native (OpenSSL) on both Node and Workers -// (via nodejs_compat) and is counted as I/O rather than JS CPU time on CF. -const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 } +// We use node:crypto.scryptSync via server/lib/password.ts (native OpenSSL, +// counted as I/O rather than JS CPU time on CF Workers). -async function hashPassword(password: string): Promise { - const salt = crypto.randomBytes(16) - const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS) - return `${salt.toString('hex')}:${key.toString('hex')}` +async function authHashPassword(password: string): Promise { + return hashPassword(password) } -async function verifyPassword({ hash, password }: { hash: string; password: string }): Promise { - const [saltHex, keyHex] = hash.split(':') - if (!saltHex || !keyHex) { - throw new Error('stored password hash is malformed: expected ":"') - } - const key = crypto.scryptSync(password.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS) - return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex')) +async function authVerifyPassword({ hash, password }: { hash: string; password: string }): Promise { + if (!hash.includes(':')) throw new Error('stored password hash is malformed: expected ":"') + return verifyPasswordHash(hash, password) } async function loadProviderConfig(db: Database, providerId: string) { @@ -145,8 +138,8 @@ export async function createAuth(db: Database, secret: string, baseURL?: string, emailAndPassword: { enabled: true, password: { - hash: hashPassword, - verify: verifyPassword, + hash: authHashPassword, + verify: authVerifyPassword, }, }, emailVerification: { diff --git a/server/db/schema.ts b/server/db/schema.ts index 61b2e541..c3463a2e 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' export const matters = sqliteTable('matters', { id: text('id').primaryKey(), @@ -79,3 +79,38 @@ export const activityEvents = sqliteTable('activity_events', { metadata: text('metadata'), // JSON createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), }) + +export const shares = sqliteTable( + 'shares', + { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + kind: text('kind').notNull(), // 'landing' | 'direct' + matterId: text('matter_id').notNull(), + orgId: text('org_id').notNull(), + creatorId: text('creator_id').notNull(), + passwordHash: text('password_hash'), + expiresAt: integer('expires_at', { mode: 'timestamp' }), + downloadLimit: integer('download_limit'), + views: integer('views').notNull().default(0), + downloads: integer('downloads').notNull().default(0), + status: text('status').notNull().default('active'), // 'active' | 'revoked' + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('shares_creator_status_created_idx').on(t.creatorId, t.status, t.createdAt)], +) + +export const shareRecipients = sqliteTable( + 'share_recipients', + { + id: text('id').primaryKey(), + shareId: text('share_id').notNull(), + recipientUserId: text('recipient_user_id'), + recipientEmail: text('recipient_email'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('share_recipients_share_id_idx').on(t.shareId), + index('share_recipients_user_id_idx').on(t.recipientUserId), + ], +) diff --git a/server/lib/password.ts b/server/lib/password.ts new file mode 100644 index 00000000..d0260ccb --- /dev/null +++ b/server/lib/password.ts @@ -0,0 +1,18 @@ +import crypto from 'node:crypto' + +// node:crypto.scryptSync is native OpenSSL — safe on Cloudflare Workers (nodejs_compat) +// and avoids the JS-CPU budget issue that the pure-JS @noble/hashes scrypt triggers. +const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 } + +export function hashPassword(password: string): string { + const salt = crypto.randomBytes(16) + const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS) + return `${salt.toString('hex')}:${key.toString('hex')}` +} + +export function verifyPassword(hash: string, plaintext: string): boolean { + const [saltHex, keyHex] = hash.split(':') + if (!saltHex || !keyHex) return false + const key = crypto.scryptSync(plaintext.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS) + return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex')) +} diff --git a/server/services/purge.ts b/server/services/purge.ts index 25c64caa..b088d103 100644 --- a/server/services/purge.ts +++ b/server/services/purge.ts @@ -3,6 +3,7 @@ import type { Storage as S3Storage } from '../../shared/types' import type { Database } from '../platform/interface' import { decrementUsage, type Matter, purgeMatters } from './matter' import { S3Service } from './s3' +import { cascadeDeleteByMatter } from './share' import { getStorage } from './storage' const s3 = new S3Service() @@ -32,6 +33,10 @@ export async function purgeRecursively(db: Database, orgId: string, matters: Mat if (storage && keys.length > 0) await s3.deleteObjects(storage, keys) } + for (const m of matters) { + await cascadeDeleteByMatter(db, m.id) + } + await purgeMatters( db, orgId, diff --git a/server/services/share.cf-test.ts b/server/services/share.cf-test.ts new file mode 100644 index 00000000..63c2da45 --- /dev/null +++ b/server/services/share.cf-test.ts @@ -0,0 +1,116 @@ +import { env } from 'cloudflare:workers' +import { nanoid } from 'nanoid' +import { describe, expect, it } from 'vitest' +import { DirType } from '../../shared/constants' +import { matters } from '../db/schema' +import { createCloudflarePlatform } from '../platform/cloudflare' +import { cascadeDeleteByMatter, createShare, incrementDownloadsAtomic, revokeShare } from './share' + +function buildDb() { + return createCloudflarePlatform(env).db +} + +async function seedMatter(db: ReturnType, orgId: string, dirtype = DirType.FILE) { + const now = new Date() + const matter = { + id: nanoid(), + orgId, + alias: nanoid(10), + name: `cf-test-${nanoid(6)}`, + type: dirtype !== DirType.FILE ? 'folder' : 'application/pdf', + size: 0, + dirtype, + parent: '', + object: dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`, + storageId: 'storage-1', + status: 'active', + trashedAt: null, + createdAt: now, + updatedAt: now, + } + await db.insert(matters).values(matter) + return matter +} + +// ─── Atomic counter race tests on D1 ───────────────────────────────────────── + +describe('[CF] incrementDownloadsAtomic — race conditions on D1', () => { + it('enforces download limit under 50 concurrent calls (limit=10)', async () => { + const db = buildDb() + const orgId = `org-${nanoid(6)}` + const matter = await seedMatter(db, orgId) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'cf-user-1', + kind: 'landing', + downloadLimit: 10, + }) + + const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id))) + + const successCount = results.filter((r) => r.ok).length + expect(successCount).toBe(10) + }) + + it('returns ok=false for all calls when share is revoked', async () => { + const db = buildDb() + const orgId = `org-${nanoid(6)}` + const matter = await seedMatter(db, orgId) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'cf-user-2', + kind: 'landing', + }) + await revokeShare(db, share.id, 'cf-user-2') + + const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id))) + expect(results.every((r) => !r.ok)).toBe(true) + }) + + it('returns ok=false for all calls when share is expired', async () => { + const db = buildDb() + const orgId = `org-${nanoid(6)}` + const matter = await seedMatter(db, orgId) + + const pastDate = new Date(Date.now() - 5000) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'cf-user-3', + kind: 'landing', + expiresAt: pastDate, + }) + + const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id))) + expect(results.every((r) => !r.ok)).toBe(true) + }) +}) + +// ─── cascadeDeleteByMatter on D1 ───────────────────────────────────────────── + +describe('[CF] cascadeDeleteByMatter on D1', () => { + it('removes shares and recipients atomically', async () => { + const db = buildDb() + const orgId = `org-${nanoid(6)}` + const matter = await seedMatter(db, orgId) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'cf-cascade-user', + kind: 'landing', + recipients: [{ recipientEmail: 'cascade@example.com' }], + }) + + await cascadeDeleteByMatter(db, matter.id) + + // Share should be gone (token lookup returns null due to deletion) + // We verify by checking the share lookup returns null + const { getShareByToken } = await import('./share') + expect(await getShareByToken(db, share.token)).toBeNull() + }) +}) diff --git a/server/services/share.integration.test.ts b/server/services/share.integration.test.ts new file mode 100644 index 00000000..65d47d65 --- /dev/null +++ b/server/services/share.integration.test.ts @@ -0,0 +1,555 @@ +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { describe, expect, it } from 'vitest' +import { DirType } from '../../shared/constants' +import { matters } from '../db/schema' +import { createTestApp } from '../test/setup.js' +import { + cascadeDeleteByMatter, + createShare, + getShareByToken, + incrementDownloadsAtomic, + incrementViews, + isAccessibleByUser, + listShareRecipientUserIds, + listSharesByCreator, + revokeShare, + verifyPassword, +} from './share.js' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function seedMatter( + db: Awaited>['db'], + opts: { orgId: string; storageId?: string; dirtype?: number; status?: string }, +) { + const now = new Date() + const matter = { + id: nanoid(), + orgId: opts.orgId, + alias: nanoid(10), + name: `test-${nanoid(6)}`, + type: opts.dirtype !== DirType.FILE ? 'folder' : 'application/pdf', + size: 0, + dirtype: opts.dirtype ?? DirType.FILE, + parent: '', + object: opts.dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`, + storageId: opts.storageId ?? 'storage-1', + status: opts.status ?? 'active', + trashedAt: null, + createdAt: now, + updatedAt: now, + } + await db.insert(matters).values(matter) + return matter +} + +// ─── createShare ───────────────────────────────────────────────────────────── + +describe('createShare', () => { + it('creates a landing share for a file with password and recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'user-1', + kind: 'landing', + password: 'secret123', + recipients: [{ recipientEmail: 'alice@example.com' }], + }) + + expect(share.id).toBeTruthy() + expect(share.token).toHaveLength(10) + expect(share.kind).toBe('landing') + expect(share.status).toBe('active') + expect(share.passwordHash).toBeTruthy() + expect(share.passwordHash).not.toBe('secret123') + }) + + it('creates a direct share for a file with no password and no recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'user-1', + kind: 'direct', + }) + + expect(share.kind).toBe('direct') + expect(share.passwordHash).toBeNull() + }) + + it('throws DIRECT_NO_PASSWORD when direct share has a password', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + await expect( + createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'direct', password: 'oops' }), + ).rejects.toThrow('DIRECT_NO_PASSWORD') + }) + + it('throws DIRECT_NO_RECIPIENTS when direct share has recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + await expect( + createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'direct', + recipients: [{ recipientEmail: 'bob@example.com' }], + }), + ).rejects.toThrow('DIRECT_NO_RECIPIENTS') + }) + + it('throws DIRECT_NO_FOLDER when direct share targets a folder', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const folder = await seedMatter(db, { orgId, dirtype: DirType.USER_FOLDER }) + + await expect(createShare(db, { matterId: folder.id, orgId, creatorId: 'u1', kind: 'direct' })).rejects.toThrow( + 'DIRECT_NO_FOLDER', + ) + }) + + it('throws MATTER_NOT_FOUND when matter does not exist', async () => { + const { db } = await createTestApp() + + await expect( + createShare(db, { matterId: 'nonexistent', orgId: 'org-1', creatorId: 'u1', kind: 'landing' }), + ).rejects.toThrow('MATTER_NOT_FOUND') + }) + + it('throws MATTER_NOT_FOUND when matter belongs to a different org', async () => { + const { db } = await createTestApp() + const matter = await seedMatter(db, { orgId: 'org-a' }) + + await expect( + createShare(db, { matterId: matter.id, orgId: 'org-b', creatorId: 'u1', kind: 'landing' }), + ).rejects.toThrow('MATTER_NOT_FOUND') + }) + + it('sets downloadLimit and expiresAt when provided', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const expiresAt = new Date(Date.now() + 86400000) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + downloadLimit: 5, + expiresAt, + }) + + expect(share.downloadLimit).toBe(5) + expect(share.expiresAt).toEqual(expiresAt) + }) +}) + +// ─── getShareByToken ────────────────────────────────────────────────────────── + +describe('getShareByToken', () => { + it('returns full data including matter and recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + recipients: [{ recipientUserId: 'user-42' }], + }) + + const result = await getShareByToken(db, share.token) + expect(result).not.toBeNull() + expect(result!.share.id).toBe(share.id) + expect(result!.matter.id).toBe(matter.id) + expect(result!.recipients).toHaveLength(1) + expect(result!.recipients[0].recipientUserId).toBe('user-42') + }) + + it('returns null when token does not exist', async () => { + const { db } = await createTestApp() + expect(await getShareByToken(db, 'nonexistent')).toBeNull() + }) + + it('returns null when share status is revoked', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + await revokeShare(db, share.id, 'u1') + expect(await getShareByToken(db, share.token)).toBeNull() + }) + + it('returns null when underlying matter is trashed', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId, status: 'trashed' }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + expect(await getShareByToken(db, share.token)).toBeNull() + }) + + it('returns data again after matter is restored from trash', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId, status: 'active' }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + // Trash it + await db.update(matters).set({ status: 'trashed' }).where(eq(matters.id, matter.id)) + expect(await getShareByToken(db, share.token)).toBeNull() + + // Restore it + await db.update(matters).set({ status: 'active' }).where(eq(matters.id, matter.id)) + const result = await getShareByToken(db, share.token) + expect(result).not.toBeNull() + expect(result!.share.id).toBe(share.id) + }) +}) + +// ─── verifyPassword ─────────────────────────────────────────────────────────── + +describe('verifyPassword', () => { + it('returns true for the correct password', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + password: 'correct-password', + }) + + expect(verifyPassword(share, 'correct-password')).toBe(true) + }) + + it('returns false for the wrong password', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + password: 'correct-password', + }) + + expect(verifyPassword(share, 'wrong-password')).toBe(false) + }) + + it('returns false when share has no password hash', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + expect(verifyPassword(share, 'any-password')).toBe(false) + }) +}) + +// ─── isAccessibleByUser ─────────────────────────────────────────────────────── + +describe('isAccessibleByUser', () => { + it('returns true when userId is in recipients', () => { + const now = new Date() + const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-42', recipientEmail: null, createdAt: now }] + expect(isAccessibleByUser(recipients, 'user-42')).toBe(true) + }) + + it('returns false when userId is not in recipients', () => { + const now = new Date() + const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-99', recipientEmail: null, createdAt: now }] + expect(isAccessibleByUser(recipients, 'user-42')).toBe(false) + }) + + it('returns false for empty recipients list', () => { + expect(isAccessibleByUser([], 'user-42')).toBe(false) + }) +}) + +// ─── incrementViews ─────────────────────────────────────────────────────────── + +describe('incrementViews', () => { + it('increments view count correctly', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + await incrementViews(db, share.id) + await incrementViews(db, share.id) + + const result = await getShareByToken(db, share.token) + expect(result!.share.views).toBe(2) + }) +}) + +// ─── incrementDownloadsAtomic ───────────────────────────────────────────────── + +describe('incrementDownloadsAtomic', () => { + it('returns ok=true and incremented count on success', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + downloadLimit: 10, + }) + + const result = await incrementDownloadsAtomic(db, share.id) + expect(result.ok).toBe(true) + expect(result.downloads).toBe(1) + }) + + it('enforces download limit — exactly limit calls succeed with concurrent calls', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + downloadLimit: 10, + }) + + const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id))) + const successCount = results.filter((r) => r.ok).length + expect(successCount).toBe(10) + }) + + it('returns ok=false when share is revoked', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + await revokeShare(db, share.id, 'u1') + + const result = await incrementDownloadsAtomic(db, share.id) + expect(result.ok).toBe(false) + }) + + it('returns ok=false when share is expired', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const pastDate = new Date(Date.now() - 1000) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + expiresAt: pastDate, + }) + + const result = await incrementDownloadsAtomic(db, share.id) + expect(result.ok).toBe(false) + }) + + it('allows unlimited downloads when downloadLimit is null', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id))) + expect(results.every((r) => r.ok)).toBe(true) + expect(results[4].downloads).toBe(5) + }) +}) + +// ─── listSharesByCreator ────────────────────────────────────────────────────── + +describe('listSharesByCreator', () => { + it('returns empty result when no shares exist for creator', async () => { + const { db } = await createTestApp() + const result = await listSharesByCreator(db, 'unknown-creator', { page: 1, pageSize: 20 }) + expect(result).toEqual({ items: [], total: 0 }) + }) + + it('returns shares with matterName and matterType joined', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + const result = await listSharesByCreator(db, 'u1', { page: 1, pageSize: 20 }) + expect(result.total).toBe(1) + expect(result.items[0].matterName).toBe(matter.name) + expect(result.items[0].matterType).toBe(matter.type) + }) + + it('paginates correctly', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + for (let i = 0; i < 5; i++) { + const m = await seedMatter(db, { orgId }) + await createShare(db, { matterId: m.id, orgId, creatorId: 'paginator', kind: 'landing' }) + } + + const page1 = await listSharesByCreator(db, 'paginator', { page: 1, pageSize: 3 }) + expect(page1.total).toBe(5) + expect(page1.items).toHaveLength(3) + + const page2 = await listSharesByCreator(db, 'paginator', { page: 2, pageSize: 3 }) + expect(page2.items).toHaveLength(2) + }) + + it('returns shares across multiple orgs for the same creator', async () => { + const { db } = await createTestApp() + const m1 = await seedMatter(db, { orgId: 'org-x' }) + const m2 = await seedMatter(db, { orgId: 'org-y' }) + await createShare(db, { matterId: m1.id, orgId: 'org-x', creatorId: 'cross-org-user', kind: 'landing' }) + await createShare(db, { matterId: m2.id, orgId: 'org-y', creatorId: 'cross-org-user', kind: 'landing' }) + + const result = await listSharesByCreator(db, 'cross-org-user', { page: 1, pageSize: 20 }) + expect(result.total).toBe(2) + }) + + it('filters by status when provided', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const m1 = await seedMatter(db, { orgId }) + const m2 = await seedMatter(db, { orgId }) + await createShare(db, { matterId: m1.id, orgId, creatorId: 'u-filter', kind: 'landing' }) + const s2 = await createShare(db, { matterId: m2.id, orgId, creatorId: 'u-filter', kind: 'landing' }) + await revokeShare(db, s2.id, 'u-filter') + + const active = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'active' }) + expect(active.total).toBe(1) + + const revoked = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'revoked' }) + expect(revoked.total).toBe(1) + }) +}) + +// ─── revokeShare ───────────────────────────────────────────────────────────── + +describe('revokeShare', () => { + it('flips share status to revoked', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + await revokeShare(db, share.id, 'u1') + expect(await getShareByToken(db, share.token)).toBeNull() + }) + + it('throws when non-creator tries to revoke', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + + await expect(revokeShare(db, share.id, 'other-user')).rejects.toThrow() + }) + + it('throws when share does not exist', async () => { + const { db } = await createTestApp() + await expect(revokeShare(db, 'nonexistent', 'u1')).rejects.toThrow() + }) +}) + +// ─── listShareRecipientUserIds ──────────────────────────────────────────────── + +describe('listShareRecipientUserIds', () => { + it('returns user IDs from recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + recipients: [{ recipientUserId: 'user-a' }, { recipientUserId: 'user-b' }, { recipientEmail: 'c@example.com' }], + }) + + const ids = await listShareRecipientUserIds(db, share.id) + expect(ids.sort()).toEqual(['user-a', 'user-b'].sort()) + }) + + it('returns empty array when no user recipients', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + recipients: [{ recipientEmail: 'email@example.com' }], + }) + + const ids = await listShareRecipientUserIds(db, share.id) + expect(ids).toEqual([]) + }) +}) + +// ─── cascadeDeleteByMatter ──────────────────────────────────────────────────── + +describe('cascadeDeleteByMatter', () => { + it('removes all shares and recipients for a matter', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const share = await createShare(db, { + matterId: matter.id, + orgId, + creatorId: 'u1', + kind: 'landing', + recipients: [{ recipientUserId: 'user-x' }], + }) + + await cascadeDeleteByMatter(db, matter.id) + + expect(await getShareByToken(db, share.token)).toBeNull() + const userIds = await listShareRecipientUserIds(db, share.id) + expect(userIds).toEqual([]) + }) + + it('is a no-op when matter has no shares', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + + await expect(cascadeDeleteByMatter(db, matter.id)).resolves.toBeUndefined() + }) + + it('removes multiple shares for the same matter', async () => { + const { db } = await createTestApp() + const orgId = nanoid() + const matter = await seedMatter(db, { orgId }) + const s1 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) + const s2 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u2', kind: 'landing' }) + + await cascadeDeleteByMatter(db, matter.id) + + expect(await getShareByToken(db, s1.token)).toBeNull() + expect(await getShareByToken(db, s2.token)).toBeNull() + }) +}) diff --git a/server/services/share.ts b/server/services/share.ts new file mode 100644 index 00000000..c97a4400 --- /dev/null +++ b/server/services/share.ts @@ -0,0 +1,180 @@ +import { and, count, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { DirType } from '../../shared/constants' +import type { CreateShareInput } from '../../shared/schemas/share' +import { matters, shareRecipients, shares } from '../db/schema' +import { hashPassword, verifyPassword as verifyPasswordHash } from '../lib/password' +import type { Database } from '../platform/interface' + +export type Share = typeof shares.$inferSelect +export type ShareRecipient = typeof shareRecipients.$inferSelect +export type ShareWithMatter = Share & { matterName: string; matterType: string } + +export function verifyPassword(share: Share, plaintext: string): boolean { + if (!share.passwordHash) return false + return verifyPasswordHash(share.passwordHash, plaintext) +} + +export function isAccessibleByUser(recipients: ShareRecipient[], userId: string): boolean { + return recipients.some((r) => r.recipientUserId === userId) +} + +export async function createShare(db: Database, input: CreateShareInput): Promise { + if (input.kind === 'direct' && input.password) throw new Error('DIRECT_NO_PASSWORD') + if (input.kind === 'direct' && input.recipients && input.recipients.length > 0) + throw new Error('DIRECT_NO_RECIPIENTS') + + const matter = await db + .select() + .from(matters) + .where(and(eq(matters.id, input.matterId), eq(matters.orgId, input.orgId))) + .then((rows) => rows[0] ?? null) + + if (!matter) throw new Error('MATTER_NOT_FOUND') + if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new Error('DIRECT_NO_FOLDER') + + const now = new Date() + const share: Share = { + id: nanoid(), + token: nanoid(10), + kind: input.kind, + matterId: input.matterId, + orgId: input.orgId, + creatorId: input.creatorId, + passwordHash: input.password ? hashPassword(input.password) : null, + expiresAt: input.expiresAt ?? null, + downloadLimit: input.downloadLimit ?? null, + views: 0, + downloads: 0, + status: 'active', + createdAt: now, + } + + await db.insert(shares).values(share) + + if (input.recipients && input.recipients.length > 0) { + const recipientRows: ShareRecipient[] = input.recipients.map((r) => ({ + id: nanoid(), + shareId: share.id, + recipientUserId: r.recipientUserId ?? null, + recipientEmail: r.recipientEmail ?? null, + createdAt: now, + })) + await db.insert(shareRecipients).values(recipientRows) + } + + return share +} + +export async function getShareByToken( + db: Database, + token: string, +): Promise<{ share: Share; matter: typeof matters.$inferSelect; recipients: ShareRecipient[] } | null> { + const rows = await db + .select({ share: shares, matter: matters }) + .from(shares) + .innerJoin(matters, eq(shares.matterId, matters.id)) + .where(eq(shares.token, token)) + + const row = rows[0] + if (!row) return null + if (row.share.status === 'revoked') return null + if (row.matter.status === 'trashed') return null + + const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id)) + + return { share: row.share, matter: row.matter, recipients } +} + +export async function incrementViews(db: Database, shareId: string): Promise { + await db + .update(shares) + .set({ views: sql`${shares.views} + 1` }) + .where(eq(shares.id, shareId)) +} + +export async function incrementDownloadsAtomic( + db: Database, + shareId: string, +): Promise<{ ok: boolean; downloads: number }> { + const nowSecs = Math.floor(Date.now() / 1000) + const result = await db + .update(shares) + .set({ downloads: sql`${shares.downloads} + 1` }) + .where( + and( + eq(shares.id, shareId), + eq(shares.status, 'active'), + or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`), + or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`), + ), + ) + .returning({ downloads: shares.downloads }) + + if (result.length === 1) { + return { ok: true, downloads: result[0].downloads } + } + + const current = await db.select({ downloads: shares.downloads }).from(shares).where(eq(shares.id, shareId)) + if (!current[0]) throw new Error('SHARE_NOT_FOUND') + return { ok: false, downloads: current[0].downloads } +} + +export async function listSharesByCreator( + db: Database, + creatorId: string, + opts: { page: number; pageSize: number; status?: string }, +): Promise<{ items: ShareWithMatter[]; total: number }> { + const conditions = [eq(shares.creatorId, creatorId)] + if (opts.status) conditions.push(eq(shares.status, opts.status)) + const where = and(...conditions) + + const [countRow] = await db.select({ count: count() }).from(shares).where(where) + const total = countRow?.count ?? 0 + + const offset = (opts.page - 1) * opts.pageSize + const rows = await db + .select({ share: shares, matterName: matters.name, matterType: matters.type }) + .from(shares) + .leftJoin(matters, eq(shares.matterId, matters.id)) + .where(where) + .orderBy(desc(shares.createdAt)) + .limit(opts.pageSize) + .offset(offset) + + const items = rows.map(({ share, matterName, matterType }) => ({ + ...share, + matterName: matterName ?? '', + matterType: matterType ?? '', + })) + + return { items, total } +} + +export async function revokeShare(db: Database, shareId: string, creatorId: string): Promise { + const result = await db + .update(shares) + .set({ status: 'revoked' }) + .where(and(eq(shares.id, shareId), eq(shares.creatorId, creatorId))) + .returning({ id: shares.id }) + + if (result.length === 0) throw new Error('SHARE_NOT_FOUND_OR_FORBIDDEN') +} + +export async function listShareRecipientUserIds(db: Database, shareId: string): Promise { + const rows = await db + .select({ userId: shareRecipients.recipientUserId }) + .from(shareRecipients) + .where(and(eq(shareRecipients.shareId, shareId), isNotNull(shareRecipients.recipientUserId))) + + return rows.map((r) => r.userId as string) +} + +export async function cascadeDeleteByMatter(db: Database, matterId: string): Promise { + const shareRows = await db.select({ id: shares.id }).from(shares).where(eq(shares.matterId, matterId)) + if (shareRows.length === 0) return + + const shareIds = shareRows.map((r) => r.id) + await db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds)) + await db.delete(shares).where(inArray(shares.id, shareIds)) +} diff --git a/server/test/setup.ts b/server/test/setup.ts index a8fa3599..567703ae 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -168,6 +168,31 @@ const APP_SCHEMA_SQL = ` created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id); + CREATE TABLE IF NOT EXISTS shares ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + matter_id TEXT NOT NULL, + org_id TEXT NOT NULL, + creator_id TEXT NOT NULL, + password_hash TEXT, + expires_at INTEGER, + download_limit INTEGER, + views INTEGER NOT NULL DEFAULT 0, + downloads INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at); + CREATE TABLE IF NOT EXISTS share_recipients ( + id TEXT PRIMARY KEY, + share_id TEXT NOT NULL, + recipient_user_id TEXT, + recipient_email TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id); + CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id); ` export async function createTestApp() { diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index e72c5650..cc7c1ba5 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -1,5 +1,7 @@ import { z } from 'zod' +export type { CreateShareInput, ShareKind } from './share' +export { createShareSchema, listSharesQuerySchema, shareKindSchema, shareRecipientSchema } from './share' export type { CreateStorageInput, UpdateStorageInput } from './storage' export { createStorageSchema, updateStorageSchema } from './storage' diff --git a/shared/schemas/share.ts b/shared/schemas/share.ts new file mode 100644 index 00000000..f4cc3234 --- /dev/null +++ b/shared/schemas/share.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export const shareKindSchema = z.enum(['landing', 'direct']) + +export type ShareKind = z.infer + +export const shareRecipientSchema = z.object({ + recipientUserId: z.string().optional(), + recipientEmail: z.string().email().optional(), +}) + +export const createShareSchema = z.object({ + matterId: z.string().min(1), + orgId: z.string().min(1), + creatorId: z.string().min(1), + kind: shareKindSchema, + password: z.string().optional(), + expiresAt: z.date().optional(), + downloadLimit: z.number().int().positive().optional(), + recipients: z.array(shareRecipientSchema).optional(), +}) + +export type CreateShareInput = z.infer + +export const listSharesQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().default(20), + status: z.enum(['active', 'revoked']).optional(), +}) diff --git a/shared/types/index.ts b/shared/types/index.ts index 9db97fa6..83166ba6 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -90,6 +90,34 @@ export interface PaginatedResponse { pageSize: number } +import type { ShareKind as _ShareKind } from '../schemas/share' + +export type { ShareKind } from '../schemas/share' + +export interface Share { + id: string + token: string + kind: _ShareKind + matterId: string + orgId: string + creatorId: string + passwordHash: string | null + expiresAt: Date | null + downloadLimit: number | null + views: number + downloads: number + status: 'active' | 'revoked' + createdAt: Date +} + +export interface ShareRecipient { + id: string + shareId: string + recipientUserId: string | null + recipientEmail: string | null + createdAt: Date +} + export interface ActivityEvent { id: string orgId: string