"use server"; import { createHash, randomBytes } from "crypto"; import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { getEntityType } from "./registry"; import { shareLinks } from "./schema"; import { ensureEntityShareAuthorized } from "./share-authorization"; export type ShareLinkCapabilities = { read: boolean; write: boolean }; export type CreateShareLinkResult = { url: string; token: string; expiresAt: Date | null; }; function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } function buildUrl(token: string): string { const base = process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000"; return `${base}/s/${token}`; } export async function createShareLink( entityType: string, entityId: string, opts: { expiresAt?: Date; capabilities?: Partial } = {}, ): Promise { const registration = getEntityType(entityType); if (!registration?.share?.canShare) { throw new Error(`Entity type "${entityType}" is not shareable`); } const { user, household } = await getCurrentSession(); await ensureEntityShareAuthorized(registration, entityId, { householdId: household.id, userId: user.id, }); const rawToken = randomBytes(32).toString("base64url"); const tokenHash = hashToken(rawToken); const capabilities: ShareLinkCapabilities = { read: opts.capabilities?.read ?? true, write: opts.capabilities?.write ?? false, }; const expiresAt = opts.expiresAt ?? null; await db.insert(shareLinks).values({ householdId: household.id, entityType, entityId, token: tokenHash, capabilities, createdBy: user.id, expiresAt, }); return { url: buildUrl(rawToken), token: rawToken, expiresAt }; } export async function resolveShareToken(rawToken: string): Promise<{ entityType: string; entityId: string; capabilities: ShareLinkCapabilities; householdId: string; expiresAt: Date | null; createdBy: string; } | null> { const tokenHash = hashToken(rawToken); const [link] = await db .select() .from(shareLinks) .where(and(eq(shareLinks.token, tokenHash), isNull(shareLinks.revokedAt))) .limit(1); if (!link) return null; if (link.expiresAt && link.expiresAt < new Date()) return null; return { entityType: link.entityType, entityId: link.entityId, capabilities: link.capabilities, householdId: link.householdId, expiresAt: link.expiresAt, createdBy: link.createdBy, }; } export async function revokeShareLink(id: string): Promise { const { household } = await getCurrentSession(); await db .update(shareLinks) .set({ revokedAt: new Date() }) .where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, household.id))); } export type ActiveShareLink = typeof shareLinks.$inferSelect; export type EntityShareLink = { id: string; createdAt: string; expiresAt: string | null; capabilities: ShareLinkCapabilities; }; export async function getShareLinksForEntity( entityType: string, entityId: string, ): Promise { const { household } = await getCurrentSession(); const now = new Date(); const rows = await db .select({ id: shareLinks.id, createdAt: shareLinks.createdAt, expiresAt: shareLinks.expiresAt, capabilities: shareLinks.capabilities, }) .from(shareLinks) .where( and( eq(shareLinks.householdId, household.id), eq(shareLinks.entityType, entityType), eq(shareLinks.entityId, entityId), isNull(shareLinks.revokedAt), ), ) .orderBy(shareLinks.createdAt); return rows .filter((r) => !r.expiresAt || r.expiresAt > now) .map((r) => ({ id: r.id, createdAt: r.createdAt.toISOString(), expiresAt: r.expiresAt?.toISOString() ?? null, capabilities: r.capabilities, })); } export async function getActiveShareLinks(): Promise { const { household } = await getCurrentSession(); const now = new Date(); const rows = await db .select() .from(shareLinks) .where( and( eq(shareLinks.householdId, household.id), isNull(shareLinks.revokedAt), // exclude already-expired links ), ) .orderBy(shareLinks.createdAt); // Filter expired in JS since OR NULL handling is cleaner here return rows.filter((r) => !r.expiresAt || r.expiresAt > now); }