Merge branch 'main' into feat/audit-logs-api

This commit is contained in:
Ephraim Duncan
2025-11-26 00:22:13 +00:00
committed by GitHub
153 changed files with 18936 additions and 73005 deletions

View File

@@ -75,6 +75,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
const renderContext: RenderParameters = {
canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,

View File

@@ -6,7 +6,7 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
@@ -15,3 +15,6 @@ export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const USE_INTERNAL_URL_BROWSERLESS = () =>
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';

View File

@@ -1,8 +1,8 @@
import type { Context as HonoContext } from 'hono';
import type { Context, Handler, InngestFunction } from 'inngest';
import { Inngest as InngestClient } from 'inngest';
import type { Logger } from 'inngest';
import { serve as createHonoPagesRoute } from 'inngest/hono';
import type { Logger } from 'inngest/middleware/logger';
import { env } from '../../utils/env';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';

View File

@@ -213,7 +213,7 @@ export class LocalJobProvider extends BaseJobProvider {
}) {
const { jobId, jobDefinitionId, data, isRetry } = options;
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/jobs/${jobDefinitionId}/${jobId}`;
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/jobs/${jobDefinitionId}/${jobId}`;
const signature = sign(data);
const headers: Record<string, string> = {

View File

@@ -49,7 +49,8 @@ export const run = async ({
throw new Error('Template not found');
}
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');

View File

@@ -22,53 +22,51 @@ export const run = async ({
const { webhookUrl: url, secret } = webhook;
await io.runTask('execute-webhook', async () => {
const payloadData = {
event,
payload: data,
createdAt: new Date().toISOString(),
webhookEndpoint: url,
};
const payloadData = {
event,
payload: data,
createdAt: new Date().toISOString(),
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payloadData),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
requestBody: payloadData as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
webhookId: webhook.id,
},
});
if (!response.ok) {
throw new Error(`Webhook execution failed with status ${response.status}`);
}
return {
success: response.ok,
status: response.status,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payloadData),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
requestBody: payloadData as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
webhookId: webhook.id,
},
});
if (!response.ok) {
throw new Error(`Webhook execution failed with status ${response.status}`);
}
return {
success: response.ok,
status: response.status,
};
};

View File

@@ -15,51 +15,52 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/client-sesv2": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
"@aws-sdk/client-s3": "^3.936.0",
"@aws-sdk/client-sesv2": "^3.936.0",
"@aws-sdk/cloudfront-signer": "^3.935.0",
"@aws-sdk/s3-request-presigner": "^3.936.0",
"@aws-sdk/signature-v4-crt": "^3.936.0",
"@documenso/assets": "*",
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@lingui/core": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
"@noble/ciphers": "0.6.0",
"@noble/hashes": "1.8.0",
"@node-rs/bcrypt": "^1.10.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"jose": "^6.0.0",
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^5.1.5",
"@scure/base": "^1.2.6",
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"csv-parse": "^6.1.0",
"inngest": "^3.45.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.8",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"pg": "^8.11.3",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"playwright": "1.52.0",
"posthog-js": "^1.245.0",
"posthog-node": "^4.17.0",
"pg": "^8.16.3",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"remeda": "^2.17.3",
"sharp": "0.32.6",
"react-pdf": "^10.2.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
"@playwright/browser-chromium": "1.56.1",
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}

View File

@@ -71,7 +71,7 @@ export const getMonthlyActiveUsers = async () => {
)
.as('cume_count'),
])
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
.where(() => sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
.orderBy('month', 'desc')
.limit(12);

View File

@@ -1,16 +0,0 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@@ -1,7 +1,11 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@@ -48,14 +52,19 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.goto(
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`,
{
waitUntil: 'networkidle',
timeout: 10_000,
},
);
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would

View File

@@ -1,7 +1,11 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@@ -48,14 +52,19 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
{
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.goto(
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`,
{
waitUntil: 'networkidle',
timeout: 10_000,
},
);
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would

View File

@@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
import { ZSiteSettingSchema } from './schema';
export const getSiteSetting = async <
T extends TSiteSettingSchema['id'],
U = Extract<TSiteSettingSchema, { id: T }>,
>(options: {
id: T;
}): Promise<U> => {
const { id } = options;
const setting = await prisma.siteSettings.findFirstOrThrow({
where: {
id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ZSiteSettingSchema.parse(setting) as U;
};

View File

@@ -1,9 +1,12 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
// TODO: Use `z.union([...])` once we have more than one setting
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_TELEMETRY_ID = 'telemetry.installation';
export const ZSiteSettingsTelemetrySchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_TELEMETRY_ID),
data: z.object({
installationId: z.string(),
}),
});
export type TSiteSettingsTelemetrySchema = z.infer<typeof ZSiteSettingsTelemetrySchema>;

View File

@@ -1,9 +1,9 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
import { type TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId: number;
userId?: number | null;
};
export const upsertSiteSetting = async ({

View File

@@ -0,0 +1,190 @@
/* eslint-disable require-atomic-updates */
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PostHog } from 'posthog-node';
import { version } from '../../../../package.json';
import { prefixedId } from '../../universal/id';
import { getSiteSetting } from '../site-settings/get-site-setting';
import { SITE_SETTINGS_TELEMETRY_ID } from '../site-settings/schemas/telemetry';
import { upsertSiteSetting } from '../site-settings/upsert-site-setting';
const TELEMETRY_KEY = process.env.NEXT_PRIVATE_TELEMETRY_KEY;
const TELEMETRY_HOST = process.env.NEXT_PRIVATE_TELEMETRY_HOST;
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY;
const NODE_ID_FILENAME = '.documenso-node-id';
const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
// Version is hardcoded to avoid rollup JSON import issues
const APP_VERSION = version;
export class TelemetryClient {
private static instance: TelemetryClient | null = null;
private client: PostHog | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private installationId: string | null = null;
private nodeId: string | null = null;
private constructor() {}
/**
* Start the telemetry client.
*
* This will initialize the PostHog client, load or create the installation ID and node ID,
* capture a startup event, and start a heartbeat interval.
*
* If telemetry is disabled via `DOCUMENSO_DISABLE_TELEMETRY=true` or credentials are not
* provided, this will be a no-op.
*/
public static async start(): Promise<void> {
if (TELEMETRY_DISABLED) {
console.log(
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
);
return;
}
if (!TELEMETRY_KEY || !TELEMETRY_HOST) {
console.log('[Telemetry] Telemetry credentials not configured. Telemetry will not be sent.');
return;
}
if (TelemetryClient.instance) {
return;
}
const instance = new TelemetryClient();
TelemetryClient.instance = instance;
await instance.initialize();
}
/**
* Stop the telemetry client.
*
* This will clear the heartbeat interval and shutdown the PostHog client.
*/
public static async stop(): Promise<void> {
const instance = TelemetryClient.instance;
if (!instance) {
return;
}
if (instance.heartbeatInterval) {
clearInterval(instance.heartbeatInterval);
}
if (instance.client) {
await instance.client.shutdown();
}
TelemetryClient.instance = null;
}
private async initialize(): Promise<void> {
this.client = new PostHog(TELEMETRY_KEY!, {
host: TELEMETRY_HOST,
disableGeoip: false,
});
// Load or create IDs
this.installationId = await this.getOrCreateInstallationId();
this.nodeId = await this.getOrCreateNodeId();
console.log(
'[Telemetry] Telemetry is enabled. Documenso collects anonymous usage data to help improve the product.',
);
console.log(
'[Telemetry] We collect: app version, installation ID, and node ID. No personal data, document contents, or user information is collected.',
);
console.log(
'[Telemetry] To disable telemetry, set DOCUMENSO_DISABLE_TELEMETRY=true in your environment variables.',
);
console.log(
'[Telemetry] Learn more: https://documenso.com/docs/developers/self-hosting/telemetry',
);
// Capture startup event
this.captureEvent('telemetry_selfhoster_startup');
// Start heartbeat
this.heartbeatInterval = setInterval(() => {
this.captureEvent('telemetry_selfhoster_heartbeat');
}, HEARTBEAT_INTERVAL_MS);
}
private captureEvent(event: string): void {
if (!this.client || !this.installationId) {
return;
}
this.client.capture({
distinctId: this.installationId,
event,
properties: {
appVersion: APP_VERSION,
installationId: this.installationId,
nodeId: this.nodeId,
},
});
}
private async getOrCreateInstallationId(): Promise<string> {
try {
// Try to get from site settings
const existing = await getSiteSetting({ id: SITE_SETTINGS_TELEMETRY_ID }).catch(() => null);
if (existing) {
if (existing.data.installationId) {
return existing.data.installationId;
}
}
// Create new installation ID
const installationId = prefixedId('installation');
await upsertSiteSetting({
id: SITE_SETTINGS_TELEMETRY_ID,
data: { installationId },
enabled: true,
});
return installationId;
} catch {
// If database is not available, generate a temporary ID
return prefixedId('installation');
}
}
private async getOrCreateNodeId(): Promise<string | null> {
const nodeIdPath = path.join(os.tmpdir(), NODE_ID_FILENAME);
try {
const existingId = await fs.readFile(nodeIdPath, 'utf-8');
if (existingId.trim()) {
return existingId.trim();
}
} catch {
// File doesn't exist or can't be read, continue to create
}
// Generate new node ID
const nodeId = prefixedId('node');
try {
await fs.writeFile(nodeIdPath, nodeId, 'utf-8');
} catch {
// Read-only filesystem, use memory for nodeId
}
return nodeId;
}
}

View File

@@ -13,6 +13,7 @@ export type HandlerTriggerWebhooksResponse =
error: string;
};
// Todo: [Webhooks] delete after deployment.
export const handlerTriggerWebhooks = async (req: Request) => {
const signature = req.headers.get('x-webhook-signature');

View File

@@ -1,7 +1,6 @@
import type { WebhookTriggerEvents } from '@prisma/client';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
import { jobs } from '../../../jobs/client';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
export type TriggerWebhookOptions = {
@@ -13,35 +12,26 @@ export type TriggerWebhookOptions = {
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
try {
const body = {
event,
data,
userId,
teamId,
};
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
if (registeredWebhooks.length === 0) {
return;
}
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webhook-signature': signature,
},
body: JSON.stringify(body),
await Promise.allSettled(
registeredWebhooks.map(async (webhook) => {
await jobs.triggerJob({
name: 'internal.execute-webhook',
payload: {
event,
webhookId: webhook.id,
data,
},
});
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 500);
}),
]).catch(() => null);
);
} catch (err) {
console.error(err);
throw new Error(`Failed to trigger webhook`);
}
};

View File

@@ -1,7 +1,7 @@
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
import { sha256 } from '@noble/hashes/sha256';
import { managedNonce } from '@noble/ciphers/webcrypto';
import { sha256 } from '@noble/hashes/sha2';
export type SymmetricEncryptOptions = {
key: string;

View File

@@ -96,6 +96,7 @@ const createFieldSignature = (
img.onload = () => {
image.setAttrs({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};

View File

@@ -1,7 +1,5 @@
import { I18nProvider } from '@lingui/react';
import type { RenderOptions } from '@documenso/email/render';
import { render } from '@documenso/email/render';
import { renderWithI18N } from '@documenso/email/render';
import { getI18nInstance } from '../client-only/providers/i18n-server';
import {
@@ -26,7 +24,7 @@ export const renderEmailWithI18N = async (
i18n.activate(lang);
return render(<I18nProvider i18n={i18n}>{component}</I18nProvider>, otherOptions);
return renderWithI18N(component, { i18n, ...otherOptions });
} catch (err) {
console.error(err);
throw new Error('Failed to render email');