Merge branch 'main' into fix/name-input-invalid-characters

This commit is contained in:
Catalin Pit
2026-06-29 10:11:07 +03:00
committed by GitHub
125 changed files with 2560 additions and 854 deletions
@@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -22,9 +23,18 @@ export type CancelDocumentOptions = {
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
// Resolve the envelope through the visibility-aware helper so the caller must
// have read access (ownership OR team membership with sufficient visibility OR
// team-email). This prevents cancelling a document the caller cannot see.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
documentMeta: true,
@@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada
.then((roles) => roles.teamRole)
.catch(() => null);
const isUserTeamMember = teamRole !== null;
// Callers with no relationship to the document must not be able to determine
// whether it exists, so respond as if it was not found.
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
@@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
@@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
// Note: This is an unsafe request, we validate the ownership later in the function.
// Note: This is an unsafe request. It is used purely to resolve the recipient
// self-hide path below. The authoritative delete authorization is performed
// via the visibility-aware `getEnvelopeWhereInput` helper.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
@@ -51,27 +53,36 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
const isUserTeamMember = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
// Determine whether the user has authorized delete access using the
// visibility-aware helper. This enforces ownership OR (team membership AND
// the document's visibility is permitted for the member's role) OR team-email
// access. A bare team member without sufficient visibility will resolve to
// null here and therefore must not be able to delete the document.
const hasDeleteAccess = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
})
.then(() => true)
.then(({ envelopeWhereInput }) =>
prisma.envelope.findFirst({
where: envelopeWhereInput,
select: { id: true },
}),
)
.then((result) => Boolean(result))
.catch(() => false);
const isUserOwner = envelope.userId === userId;
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (!hasDeleteAccess && !userRecipient) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
if (hasDeleteAccess) {
await handleDocumentOwnerDelete({
envelope,
user,
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateAttachmentOptions = {
envelopeId: string;
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
};
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type DeleteAttachmentOptions = {
id: string;
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,7 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type UpdateAttachmentOptions = {
id: string;
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,44 +0,0 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
apiTokens: {
some: {
token: hashedToken,
},
},
},
include: {
apiTokens: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetRecipientByIdOptions = {
recipientId: number;
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type,
userId,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const legacyId = {
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateSharingIdOptions =
| {
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
),
select: {
id: true,
teamId: true,
},
});
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
.then((recipient) => recipient?.email);
})
.with({ userId: P.number }, async ({ userId }) => {
// Ensure the authenticated user actually has visibility-aware access to the
// envelope before allowing them to create a share link. The share route does
// not carry a teamId, so we derive it from the envelope and reuse the canonical
// visibility check (owner OR team member with sufficient visibility).
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId: envelope.teamId,
type: EnvelopeType.DOCUMENT,
});
const accessibleEnvelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
},
});
if (!accessibleEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.user
.findFirst({
where: {
@@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
organisationId: team.organisationId,
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
@@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { updateTeamPublicProfile } from './update-team-public-profile';
export type GetTeamPublicProfileOptions = {
userId: number;
@@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({
});
}
// Create and return the public profile.
// Lazily initialize a disabled public profile on first access. Membership is
// already verified by the query above, so this system initialization does not
// impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes.
if (!team.profile) {
const { url, profile } = await updateTeamPublicProfile({
userId: userId,
teamId,
data: {
const profile = await prisma.teamProfile.upsert({
where: {
teamId,
},
create: {
teamId,
enabled: false,
},
update: {},
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Failed to create public profile',
});
}
return {
profile,
url,
url: team.url,
};
}
@@ -1,3 +1,4 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = {
export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => {
return await prisma.team.update({
where: buildTeamWhereQuery({ teamId, userId }),
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
data: {
profile: {
upsert: {
@@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => {
return;
}
// Find a token that was created in the last hour and hasn't expired
// const existingToken = await prisma.passwordResetToken.findFirst({
// where: {
// userId: user.id,
// expiry: {
// gt: new Date(),
// },
// createdAt: {
// gt: new Date(Date.now() - ONE_HOUR),
// },
// },
// });
// if (existingToken) {
// return;
// }
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
// Invalidate any prior reset tokens for this user before issuing a new one, so
// only a single token is ever live at a time. We still always issue a fresh
// token (and email) so the user can request a new link if a prior email never
// arrived, while bounding the number of usable tokens to one.
await prisma.$transaction(async (tx) => {
await tx.passwordResetToken.deleteMany({
where: {
userId: user.id,
},
});
await tx.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
});
});
await sendForgotPassword({
@@ -71,10 +71,17 @@ const isBypassedHost = (url: string): boolean => {
};
/**
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
* Assert that a webhook URL does not point at a private/loopback address,
* checking both the literal host and its resolved DNS records. Throws an
* AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in
* NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
* This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation.
* It does not cover DNS rebinding (the resolved address can change between this
* check and the actual request), obscure IP encodings, or every IPv6 form, and
* it fails open on lookup errors/timeouts (see the catch below). Network-level
* SSRF protection (firewall/egress rules, blocking internal services and cloud
* metadata endpoints) remains the responsibility of the deployment.
*/
export const assertNotPrivateUrl = async (
url: string,
@@ -3,10 +3,11 @@ import { z } from 'zod';
const ZIpSchema = z.string().ip();
/**
* Check whether a URL points to a known private/loopback address.
* Synchronously check whether a URL's host is a known private/loopback address
* (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol.
*
* Performs a synchronous check against known private hostnames and IP ranges.
* Works regardless of the URL protocol.
* Best-effort and non-exhaustive: unrecognised or unparseable hosts return
* `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats.
*/
export const isPrivateUrl = (url: string): boolean => {
try {
@@ -1,4 +1,6 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { assertNotPrivateUrl } from '../assert-webhook-url';
@@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookUrl, eventTrigger } = await req.json();
await assertNotPrivateUrl(webhookUrl);
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege
// use of a token minted while the holder was privileged.
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to manage webhooks for this team',
});
}
const createdWebhook = await prisma.webhook.create({
data: {
@@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => {
eventTriggers: [eventTrigger],
secret: null,
enabled: true,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
userId,
teamId,
},
});
return Response.json(createdWebhook);
} catch (err) {
if (err instanceof AppError) {
return Response.json({ message: err.message }, { status: 400 });
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract (e.g. invalid webhook URL).
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
@@ -1,3 +1,6 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
@@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookId } = await req.json();
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a
// token minted while the holder was privileged.
const deletedWebhook = await prisma.webhook.delete({
where: {
id: webhookId,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
},
});
return Response.json(deletedWebhook);
} catch (err) {
if (err instanceof AppError) {
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract.
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
return Response.json(
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { isHttpUrl, toSafeHref } from './is-http-url';
describe('isHttpUrl', () => {
it('accepts http and https URLs', () => {
expect(isHttpUrl('http://example.com')).toBe(true);
expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true);
expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true);
});
it('rejects non-http(s) schemes', () => {
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
expect(isHttpUrl('JavaScript:alert(1)')).toBe(false);
expect(isHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false);
expect(isHttpUrl('file:///etc/passwd')).toBe(false);
});
it('rejects non-absolute or unparseable values', () => {
expect(isHttpUrl('not a url')).toBe(false);
expect(isHttpUrl('/relative/path')).toBe(false);
expect(isHttpUrl('')).toBe(false);
});
it('does not treat leading whitespace tricks as safe', () => {
// `new URL` trims leading control chars; ensure a smuggled scheme is rejected.
expect(isHttpUrl(' javascript:alert(1)')).toBe(false);
expect(isHttpUrl('java\tscript:alert(1)')).toBe(false);
});
});
describe('toSafeHref', () => {
it('returns the URL when it is http(s)', () => {
expect(toSafeHref('https://example.com')).toBe('https://example.com');
});
it('returns undefined for dangerous or empty values', () => {
expect(toSafeHref('javascript:alert(1)')).toBeUndefined();
expect(toSafeHref('data:text/html,x')).toBeUndefined();
expect(toSafeHref('')).toBeUndefined();
expect(toSafeHref(null)).toBeUndefined();
expect(toSafeHref(undefined)).toBeUndefined();
});
});
+32
View File
@@ -0,0 +1,32 @@
const ALLOWED_PROTOCOLS = ['http', 'https'];
/**
* Returns true only when `value` parses as an absolute URL using the http or
* https protocol.
*
* Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this
* to restrict user-supplied URLs to http(s) before they are stored or rendered
* as a link.
*/
export const isHttpUrl = (value: string) => {
try {
const url = new URL(value);
return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase());
} catch {
return false;
}
};
/**
* Returns the value to use for a link `href` only when it is an http(s) URL,
* otherwise `undefined`. Use this when rendering user-supplied URLs as anchors,
* including for older rows stored before URL validation was in place.
*/
export const toSafeHref = (value: string | null | undefined): string | undefined => {
if (!value || !isHttpUrl(value)) {
return undefined;
}
return value;
};