import { createHash, randomBytes } from 'node:crypto'; import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; import { InvitationListView, InvitationPreview, InvitationStatus, InvitationView, } from '@dorfteich/shared'; import { Invitation, User } from '@prisma/client'; import { AuditService } from '../audit/audit.service'; import { AppConfig } from '../config/app-config.service'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { RateLimitService } from '../rate-limit/rate-limit.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60; // Anti-spam backstop besides the open-invitations quota: without it a // revoke-and-recreate loop would allow unlimited mail volume while never // exceeding the quota. const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 }; /** * Peer invitations (issue #332). A user invites an e-mail address; the * mailed single-use token lets exactly one signup through even while * registration is closed (auth.service). Open (pending, unexpired) * invitations count against the per-user quota * `invitations.maxOpenPerUser` — 0 turns the feature off. Only the * SHA-256 hash of the token is stored (auth-tokens pattern); revoked and * accepted rows are kept so the settings UI can show history. */ @Injectable() export class InvitationsService { constructor( private readonly prisma: PrismaService, private readonly mail: MailService, private readonly rateLimits: RateLimitService, private readonly settings: InstanceSettingsService, private readonly audit: AuditService, private readonly config: AppConfig, ) {} async create(user: User, email: string): Promise { const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number; if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' }); if ((await this.openCount(user.id)) >= maxOpen) { throw new BadRequestException({ code: 'invitation_quota_reached' }); } const limit = await this.rateLimits.hit( 'invitation-create', user.id, CREATE_LIMIT.limit, CREATE_LIMIT.windowSeconds, ); if (!limit.allowed) { throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS); } const raw = randomBytes(32).toString('base64url'); const row = await this.prisma.invitation.create({ data: { inviterId: user.id, email: email.toLowerCase(), tokenHash: hashToken(raw), expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000), }, }); // The invitee has no account and no locale yet — the instance default // decides the mail language. The greeting falls back to the address. await this.mail.enqueue( row.email, 'invitation', { displayName: row.email, inviterName: user.displayName, link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`, }, (await this.settings.get('instance.defaultLocale')) as 'de' | 'en', ); await this.audit.record({ action: 'invitation.created', actorId: user.id, targetType: 'invitation', targetId: row.id, }); return this.viewOf(row); } async list(user: User): Promise { const rows = await this.prisma.invitation.findMany({ where: { inviterId: user.id }, orderBy: { createdAt: 'desc' }, take: 100, }); return { invitations: rows.map((row) => this.viewOf(row)), open: await this.openCount(user.id), maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number, }; } async revoke(user: User, id: string): Promise { const row = await this.prisma.invitation.findFirst({ where: { id, inviterId: user.id }, }); if (!row) throw new NotFoundException(); if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' }); if (row.revokedAt) return; // idempotent await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } }); await this.audit.record({ action: 'invitation.revoked', actorId: user.id, targetType: 'invitation', targetId: id, }); } /** What the signup screen may show for a link before it is used. */ async preview(raw: string): Promise { const row = await this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) }, include: { inviter: true }, }); if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) { throw new BadRequestException({ code: 'token_invalid' }); } return { email: row.email, inviterName: row.inviter.displayName }; } /** * Atomically claims the token (only one signup can flip acceptedAt from * null). Returns the row, or null for unknown/revoked/expired/used * tokens. The caller un-redeems if the signup fails afterwards. */ async redeem(raw: string): Promise { const result = await this.prisma.invitation.updateMany({ where: { tokenHash: hashToken(raw), revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() }, }, data: { acceptedAt: new Date() }, }); if (result.count === 0) return null; return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } }); } /** Ties the redeemed invitation to the account it created. */ async markAccepted(id: string, userId: string): Promise { await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } }); } /** Rolls a redeem back when the signup it gated failed (e.g. duplicate * username) — the invitee must be able to try again with the same link. */ async unredeem(id: string): Promise { await this.prisma.invitation.updateMany({ where: { id, acceptedUserId: null }, data: { acceptedAt: null }, }); } private openCount(inviterId: string): Promise { return this.prisma.invitation.count({ where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } }, }); } private viewOf(row: Invitation): InvitationView { return { id: row.id, email: row.email, status: statusOf(row), createdAt: row.createdAt.toISOString(), expiresAt: row.expiresAt.toISOString(), }; } } function statusOf(row: Invitation): InvitationStatus { if (row.revokedAt) return 'revoked'; if (row.acceptedAt) return 'accepted'; if (row.expiresAt <= new Date()) return 'expired'; return 'pending'; } function hashToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); }