[READ-ONLY] Mirror of https://github.com/thoda-dev/shhh. Self-hostable zero-knowledge pastebin for secrets that expire on their own
docker end-to-end-encryption nuxt nuxtjs pastebin secrets selft-hosted zero-knowledge
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import { z } from 'zod'
const createInvitationSchema = z.object({ email: z.string().email()})
export default defineEventHandler(async (event) => { const session = await requireAdminSession(event) const body = await readValidatedBody(event, createInvitationSchema.parse) const email = body.email.trim().toLowerCase()
if (!isMailEnabled()) { throw createError({ statusCode: 503, statusMessage: 'This instance has no mail provider configured' }) }
// A partial unique index also covers the pending case; checking here turns it into a readable 409. const [existingUser] = await db .select({ id: schema.users.id }) .from(schema.users) .where(eq(schema.users.email, email)) .limit(1)
if (existingUser) { throw createError({ statusCode: 409, statusMessage: 'An account already exists for this email' }) }
const [pending] = await db .select() .from(schema.invitations) .where(and(eq(schema.invitations.email, email), eq(schema.invitations.status, 'pending'))) .limit(1)
// A lapsed pending row shouldn't block a fresh invitation; flipping it to 'expired' frees the partial unique index. if (pending) { if (invitationState(pending) === 'pending') { throw createError({ statusCode: 409, statusMessage: 'An invitation is already pending for this email' }) } await db.update(schema.invitations).set({ status: 'expired' }).where(eq(schema.invitations.id, pending.id)) }
const expiryDays = await getSetting('invitation_expiry_days') // `null` means no expiry configured; the column is NOT NULL, so a far-future date stands in rather than making every read handle a nullable deadline. const expiresAt = expiryDays === null ? new Date('9999-12-31T23:59:59Z') : new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000)
const token = generateInvitationToken()
const [invitation] = await db .insert(schema.invitations) .values({ email, token, invitedBy: session.user.id, expiresAt }) .returning({ id: schema.invitations.id, email: schema.invitations.email, status: schema.invitations.status, expiresAt: schema.invitations.expiresAt, createdAt: schema.invitations.createdAt })
// Same reasoning as paste sharing: from BETTER_AUTH_URL, not the attacker-controllable Host header, since it lands in an email. const origin = process.env.BETTER_AUTH_URL!.replace(/\/+$/, '') // The invitee has no account yet, so the inviting admin's UI language is the only signal available. const mail = invitationTemplate({ url: `${origin}/register?token=${token}`, expiresInDays: expiryDays, locale: mailLocaleFromEvent(event) }) const sent = await sendMail({ to: email, ...mail })
await db.insert(schema.adminAuditLog).values({ actorId: session.user.id, action: 'invitation.create', targetId: invitation!.id, details: { email, sent } })
setResponseStatus(event, 201) // The row is kept even when delivery fails: an admin can revoke and reissue, which beats having no trace of the attempt. return { ...invitation, sent }})