import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { randomToken } from "../../lib/crypto.js"; import { assertCan } from "../../lib/authorize.js"; import { loadEnv } from "../../config/env.js"; import { EVENTS } from "../../constants.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import { workspaceOrgPolicy } from "../org/org-policy.service.js"; /** * Sharing: share links for pages/databases (scopes PRIVATE/WORKSPACE/ * TEAM/USERS/PUBLIC). Public shares are the only ones readable without * auth — and they 404 (not 403) for revoked/expired/missing so the * endpoint cannot be used to enumerate existing shares. */ @Injectable() export class ShareService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} /** * Create a share link. * kind: "link" (scoped link, token auth), "public" (web-public), * "invite" (workspace invite link — creates an invitation). */ async create( userId: string, input: { workspaceId: string; targetType: "page" | "database"; targetId: string; kind: "link" | "public" | "invite"; level: "VIEW" | "COMMENT" | "EDIT"; email?: string; expiresAt?: string; }, req: any ) { const { workspaceId, targetType, targetId, kind } = input; if (targetType === "database") { const db = await this.prisma.database.findFirst({ where: { id: targetId, workspaceId } }); if (!db) throw ApiError.notFound("Database not found."); await assertCan(this.prisma, { workspaceId, userId, resource: { databaseId: targetId } }, "EDIT"); } else { const page = await this.prisma.page.findFirst({ where: { id: targetId, workspaceId } }); if (!page || page.deletedAt) throw ApiError.notFound("Page not found."); await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: targetId } }, "EDIT"); } // TASK-0133 enforcement #2: an org policy `disableGuestPublicShare` // blocks creating GUEST (anyone-with-link) public shares — server // authoritative (the UI's notice is UX only). if (kind === "public") { const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId); if (policy?.disableGuestPublicShare === true) { throw ApiError.forbidden("Public guest sharing is disabled by your organization.", [ { reason: "guest_public_share_disabled" } ]); } } const scope = kind === "public" ? "PUBLIC" : kind === "invite" ? "USERS" : "PRIVATE"; const token = `sh_${randomToken(24)}`; const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { slug: true, name: true } }); const share = await this.prisma.share.create({ data: { workspaceId, pageId: targetType === "page" ? targetId : null, recordId: targetType === "database" ? targetId : null, token, scope: scope as never, allowComments: input.level !== "VIEW", expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, createdBy: userId } }); // record a Permission row so the level is enforced for scoped links await this.prisma.permission.create({ data: { workspaceId, granteeType: "share", granteeId: share.id, resourceType: targetType, resourceId: targetId, level: input.level as never } }).catch(() => undefined); // granteeType "share" is app-level, not a user/team // TASK-0133 audit catalog: the share action is `page.shared` (the // catalog's name for "someone shared a page"), with the CATALOG-required // metadata `targetType` + `targetId` + `scope` (plus the legacy fields). await this.audit.audit(req, req?.auth, { action: "page.shared", resourceType: "share", resourceId: share.id, workspaceId, pageId: targetType === "page" ? targetId : undefined, metadata: { targetType, targetId, scope, kind, level: input.level, email: input.email ?? null } }); await this.events.publish(EVENTS.PAGE_SHARED, { workspaceId, shareId: share.id, targetType, targetId, scope }); // TASK-0094 R4 — the PAGE_SHARED Notification row. The recipient is the // resolved email grantee (a share can carry an `email`); a share with no // email, or for an email with no account, has no in-app recipient // (the invite link is their path — recorded). Self-shares are skipped // (grantee == caller → no notification). if (input.email) { const grantee = await this.prisma.user.findUnique({ where: { email: input.email }, select: { id: true } }); if (grantee && grantee.id !== userId) { await this.notifications .notify({ workspaceId, userId: grantee.id, type: "PAGE_SHARED", title: `A ${targetType} was shared with you`, body: `${ws?.name ?? "A workspace"}: ${targetType} shared via link`, link: { type: targetType, id: targetId }, actorId: userId }) .catch(() => undefined); // a notification failure never fails the share } } const baseUrl = loadEnv().WEB_ORIGIN; const url = kind === "public" ? `${baseUrl}/share/${token}` : `${baseUrl}/share/${token}?t=${encodeURIComponent(token)}`; return { share: { id: share.id, token, scope: share.scope, allowComments: share.allowComments, expiresAt: share.expiresAt, createdBy: share.createdBy, createdAt: share.createdAt }, url, workspace: { id: workspaceId, name: ws?.name, slug: ws?.slug } }; } /** List the current user's shares in a workspace. */ async list(workspaceId: string, userId: string) { const shares = await this.prisma.share.findMany({ where: { workspaceId, createdBy: userId }, orderBy: { createdAt: "desc" }, take: 200 }); return { data: shares.map((s) => ({ id: s.id, token: s.token, scope: s.scope, pageId: s.pageId, recordId: s.recordId, allowComments: s.allowComments, expiresAt: s.expiresAt, createdAt: s.createdAt })) }; } async remove(shareId: string, userId: string, req: any) { const share = await this.prisma.share.findUnique({ where: { id: shareId } }); if (!share) throw ApiError.notFound("Share not found."); if (share.createdBy !== userId) { const level = await assertCan(this.prisma, { workspaceId: share.workspaceId, userId }, "FULL_ACCESS"); void level; } await this.prisma.$transaction([ this.prisma.share.delete({ where: { id: shareId } }), this.prisma.permission.deleteMany({ where: { granteeType: "share", granteeId: shareId } }) ]); await this.audit.audit(req, req?.auth, { action: "share.revoked", resourceType: "share", resourceId: shareId, workspaceId: share.workspaceId, metadata: { token: share.token } }); return { deleted: true }; } /** * PUBLIC read (no auth). Returns the shared content or 404 — never 403 — * so revoked/expired/unknown slugs are indistinguishable. */ async publicGet(token: string) { const share = await this.prisma.share.findUnique({ where: { token } }); if (!share || share.scope !== "PUBLIC") return null; if (share.expiresAt && share.expiresAt.getTime() < Date.now()) return null; const workspace = await this.prisma.workspace.findUnique({ where: { id: share.workspaceId }, select: { id: true, name: true, slug: true, settings: true, deletedAt: true } }); if (!workspace || workspace.deletedAt) return null; // workspace policy can disable public sharing entirely const settings = workspace.settings as Record; if (settings.guestPublicShare === false) return null; if (share.pageId) { const page = await this.prisma.page.findUnique({ where: { id: share.pageId }, select: { id: true, title: true, icon: true, description: true, coverUrl: true, trashedAt: true, deletedAt: true } }); if (!page || page.deletedAt || page.trashedAt) return null; const blocks = await this.prisma.block.findMany({ where: { pageId: page.id, deletedAt: null }, select: { id: true, type: true, content: true, properties: true, position: true }, orderBy: { position: "asc" }, take: 2000 }); return { share: { id: share.id, scope: share.scope, allowComments: share.allowComments, expiresAt: share.expiresAt }, workspace: { id: workspace.id, name: workspace.name, slug: workspace.slug }, target: { type: "page" as const, page, blocks }, canComment: share.allowComments }; } // database public share: metadata + read-only table view of records const db = await this.prisma.database.findUnique({ where: { id: share.recordId ?? "" }, select: { id: true, title: true, icon: true, description: true, isArchived: true } }); if (!db || db.isArchived) return null; const properties = await this.prisma.databaseProperty.findMany({ where: { databaseId: db.id, deletedAt: null, isVisible: true }, select: { id: true, name: true, type: true, isTitle: true }, orderBy: { position: "asc" } }); const records = await this.prisma.databaseRecord.findMany({ where: { databaseId: db.id, archivedAt: null }, select: { id: true, properties: { select: { propertyId: true, value: true } } }, take: 500 }); const propById = new Map(properties.map((p) => [p.id, p])); const rows = records.map((r) => { const values: Record = {}; for (const rp of r.properties) { const prop = propById.get(rp.propertyId); if (prop) values[prop.name] = rp.value; } return { id: r.id, properties: values }; }); return { share: { id: share.id, scope: share.scope, allowComments: share.allowComments, expiresAt: share.expiresAt }, workspace: { id: workspace.id, name: workspace.name, slug: workspace.slug }, target: { type: "database" as const, database: db, properties, records: rows }, canComment: share.allowComments }; } }