Merge remote-tracking branch 'origin/main' into pr-2559

This commit is contained in:
ephraimduncan
2026-07-02 15:36:15 +00:00
632 changed files with 39543 additions and 6691 deletions
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
getFieldCanvasStyleCacheKey,
getOpacityValue,
getPixelValue,
getRenderableColor,
TRANSPARENT_COLOR,
} from './field-canvas-style';
import type { FieldToRender } from './field-renderer';
const createField = (overrides: Partial<FieldToRender> = {}) =>
({
type: 'SIGNATURE',
inserted: false,
fieldMeta: null,
...overrides,
}) as FieldToRender;
describe('getPixelValue', () => {
it('parses pixel values', () => {
expect(getPixelValue('12px')).toBe(12);
expect(getPixelValue('0px')).toBe(0);
expect(getPixelValue('1.5px')).toBe(1.5);
});
it('parses unitless numeric strings', () => {
expect(getPixelValue('42')).toBe(42);
});
it('returns undefined for non-finite or unparseable values', () => {
expect(getPixelValue('')).toBeUndefined();
expect(getPixelValue('auto')).toBeUndefined();
expect(getPixelValue('inherit')).toBeUndefined();
expect(getPixelValue('NaN')).toBeUndefined();
});
});
describe('getOpacityValue', () => {
it('returns undefined for fully opaque (the default) so callers fall back', () => {
expect(getOpacityValue('1')).toBeUndefined();
});
it('keeps values inside the [0, 1) range as-is', () => {
expect(getOpacityValue('0')).toBe(0);
expect(getOpacityValue('0.5')).toBe(0.5);
expect(getOpacityValue('0.999')).toBe(0.999);
});
it('clamps out-of-range values into [0, 1]', () => {
expect(getOpacityValue('-0.5')).toBe(0);
expect(getOpacityValue('2')).toBe(1);
});
it('returns undefined for non-finite values', () => {
expect(getOpacityValue('')).toBeUndefined();
expect(getOpacityValue('inherit')).toBeUndefined();
expect(getOpacityValue('NaN')).toBeUndefined();
});
});
describe('getRenderableColor', () => {
it('passes non-transparent colors through unchanged', () => {
expect(getRenderableColor('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)');
expect(getRenderableColor('rgba(0, 128, 0, 0.5)')).toBe('rgba(0, 128, 0, 0.5)');
expect(getRenderableColor('rgba(255, 255, 255, 0.9)')).toBe('rgba(255, 255, 255, 0.9)');
expect(getRenderableColor('#abcdef')).toBe('#abcdef');
});
it('returns undefined for falsy input', () => {
expect(getRenderableColor(undefined)).toBeUndefined();
expect(getRenderableColor('')).toBeUndefined();
});
it('normalizes the `transparent` keyword to a renderable transparent color, regardless of case or whitespace', () => {
expect(getRenderableColor('transparent')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor('TRANSPARENT')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor(' Transparent ')).toBe(TRANSPARENT_COLOR);
});
it('normalizes fully transparent rgba() colors to a renderable transparent color', () => {
expect(getRenderableColor('rgba(0, 0, 0, 0)')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor('rgba(255, 0, 0, 0)')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor('rgba(255, 0, 0, 0.0)')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor('rgba(255, 0, 0, 0.00)')).toBe(TRANSPARENT_COLOR);
});
it('normalizes space-separated (CSS Color 4) fully transparent colors to a renderable transparent color', () => {
expect(getRenderableColor('rgb(0 128 0 / 0)')).toBe(TRANSPARENT_COLOR);
expect(getRenderableColor('rgba(255 0 0 / 0)')).toBe(TRANSPARENT_COLOR);
});
it('passes space-separated (CSS Color 4) colors through unchanged', () => {
expect(getRenderableColor('rgb(0 128 0 / 0.5)')).toBe('rgb(0 128 0 / 0.5)');
});
it('returns undefined for unparseable color values', () => {
expect(getRenderableColor('none')).toBeUndefined();
expect(getRenderableColor('not-a-color')).toBeUndefined();
});
it('does not strip non-zero alpha colors', () => {
expect(getRenderableColor('rgba(255, 0, 0, 0.5)')).toBe('rgba(255, 0, 0, 0.5)');
expect(getRenderableColor('rgba(255, 0, 0, 1)')).toBe('rgba(255, 0, 0, 1)');
expect(getRenderableColor('rgba(255, 0, 0, 0.01)')).toBe('rgba(255, 0, 0, 0.01)');
});
});
describe('getFieldCanvasStyleCacheKey', () => {
it('includes validation state', () => {
expect(getFieldCanvasStyleCacheKey(createField({ isValidating: false }))).toBe('SIGNATURE:false:false:false');
expect(getFieldCanvasStyleCacheKey(createField({ isValidating: true }))).toBe('SIGNATURE:false:false:true');
});
});
@@ -0,0 +1,168 @@
import {
FIELD_PROBE_ANCHOR_SELECTOR,
FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME,
} from '@documenso/ui/lib/field-root-container-classes';
import { colord } from 'colord';
import type { FieldCanvasStyle, FieldRenderMode, FieldToRender } from './field-renderer';
export type FieldCanvasStyleCache = Map<string, FieldCanvasStyle | undefined>;
export const createFieldCanvasStyleCache = (): FieldCanvasStyleCache => new Map();
export const getFieldCanvasStyleCacheKey = (field: FieldToRender) =>
`${field.type}:${field.inserted}:${field.fieldMeta?.readOnly ?? false}:${field.isValidating ?? false}`;
export const getPixelValue = (value: string) => {
const parsedValue = Number.parseFloat(value);
if (!Number.isFinite(parsedValue)) {
return undefined;
}
return parsedValue;
};
export const getOpacityValue = (value: string) => {
const parsedValue = Number.parseFloat(value);
if (!Number.isFinite(parsedValue) || parsedValue === 1) {
return undefined;
}
return Math.max(0, Math.min(parsedValue, 1));
};
// Canonical value Konva paints as fully transparent. We normalize transparent
// inputs to this so the renderer can tell "customer asked for transparent"
// (honored — paint nothing) apart from "no custom style" (undefined — fall back
// to the renderer default).
export const TRANSPARENT_COLOR = 'rgba(0, 0, 0, 0)';
export const getRenderableColor = (value: string | undefined) => {
if (!value) {
return undefined;
}
const color = colord(value);
// Unparseable input (e.g. `none`) has no canvas meaning, so fall back to the
// renderer defaults. Inputs come from `getComputedStyle`, which normalizes to
// `rgb()`/`rgba()`, so the base colord parser (no named-colors plugin) is
// sufficient here. The `transparent` keyword is the one exception colord
// reports as invalid; we treat it as an explicit transparent request.
if (!color.isValid()) {
return value.trim().toLowerCase() === 'transparent' ? TRANSPARENT_COLOR : undefined;
}
// A fully transparent color is a deliberate choice — honor it by painting
// nothing rather than falling back to the default background/border.
if (color.alpha() === 0) {
return TRANSPARENT_COLOR;
}
return value;
};
/**
* Build a throwaway field container that mirrors the real `FieldRootContainer`
* (same classes + data attributes) so we can read whatever the active embed CSS
* resolves for this field's state.
*
* `transition` is disabled because we read the computed style synchronously right
* after attaching — leaving transitions on would surface mid-animation values.
* `visibility: hidden` + zero size keep it invisible and out of layout flow
* without using `display: none`, which would prevent border/background resolution.
*/
const createFieldProbeElement = (field: FieldToRender): HTMLElement => {
const $probe = document.createElement('div');
$probe.className = FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME;
$probe.setAttribute('aria-hidden', 'true');
$probe.dataset.fieldType = field.type;
$probe.dataset.inserted = field.inserted ? 'true' : 'false';
$probe.dataset.validate = field.isValidating ? 'true' : 'false';
$probe.dataset.readonly = field.fieldMeta?.readOnly ? 'true' : 'false';
Object.assign($probe.style, {
position: 'absolute',
width: '0',
height: '0',
overflow: 'hidden',
pointerEvents: 'none',
visibility: 'hidden',
transition: 'none',
} satisfies Partial<CSSStyleDeclaration>);
return $probe;
};
const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyle | undefined => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return undefined;
}
// The probe must be appended inside the same subtree as the real fields so it
// inherits the identical CSS cascade. Custom embed CSS is typically scoped
// under `.embed--DocumentContainer`; appending to `document.body` would resolve
// a different (wrong) cascade. If the anchor is absent (non-embed contexts),
// there is no custom field CSS to read, so we skip the probe entirely.
const $anchor = document.querySelector(FIELD_PROBE_ANCHOR_SELECTOR);
if (!$anchor) {
return undefined;
}
const $probe = createFieldProbeElement(field);
$anchor.appendChild($probe);
try {
const computedStyle = window.getComputedStyle($probe);
const borderWidth = getPixelValue(computedStyle.borderTopWidth);
const borderColor = getRenderableColor(computedStyle.borderTopColor);
const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth);
const borderRadius = getPixelValue(computedStyle.borderTopLeftRadius);
return {
backgroundColor: getRenderableColor(computedStyle.backgroundColor),
borderColor: hasBorderStyle ? borderColor : undefined,
borderRadius,
borderWidth: hasBorderStyle ? borderWidth : undefined,
opacity: getOpacityValue(computedStyle.opacity),
};
} finally {
$probe.remove();
}
};
/**
* Resolve the canvas style for a field by reading a throwaway probe element's
* computed CSS.
*
* Sign-mode only — the editor and export views intentionally use the renderer
* defaults. Reads are cache-gated, so the probe is created/removed at most once
* per unique field state per render pass.
*/
export const resolveFieldCanvasStyle = (
field: FieldToRender,
mode: FieldRenderMode,
cache?: FieldCanvasStyleCache,
): FieldCanvasStyle | undefined => {
if (mode !== 'sign') {
return undefined;
}
const cacheKey = getFieldCanvasStyleCacheKey(field);
if (cache?.has(cacheKey)) {
return cache.get(cacheKey);
}
const style = computeFieldCanvasStyleFromProbe(field);
cache?.set(cacheKey, style);
return style;
};
@@ -29,6 +29,7 @@ export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldEleme
x: fieldX,
y: fieldY,
draggable: editable,
opacity: options.fieldCanvasStyle?.opacity ?? 1,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
@@ -42,6 +43,7 @@ export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldEleme
export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Rect => {
const { pageWidth, pageHeight, mode, pageLayer, color } = options;
const { fieldCanvasStyle } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@@ -55,10 +57,10 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen
fieldRect.setAttrs({
width: fieldWidth,
height: fieldHeight,
fill: DEFAULT_RECT_BACKGROUND,
stroke: color ? getRecipientColorStyles(color).baseRing : '#e5e7eb',
strokeWidth: 2,
cornerRadius: 2,
fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'),
strokeWidth: fieldCanvasStyle?.borderWidth ?? 2,
cornerRadius: fieldCanvasStyle?.borderRadius ?? 2,
strokeScaleEnabled: false,
visible: mode !== 'export',
} satisfies Partial<Konva.RectConfig>);
@@ -69,6 +71,7 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen
export const createSpinner = ({ fieldWidth, fieldHeight }: { fieldWidth: number; fieldHeight: number }) => {
const loadingGroup = new Konva.Group({
name: 'loading-spinner-group',
listening: false,
});
const rect = new Konva.Rect({
@@ -126,6 +129,10 @@ export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }:
return;
}
if (options.fieldCanvasStyle?.backgroundColor) {
return;
}
const hoverColor = getRecipientColorStyles(options.color).baseRingHover;
fieldGroup.on('mouseover', () => {
@@ -16,21 +16,42 @@ export type FieldToRender = Pick<
height: number;
positionX: number;
positionY: number;
isValidating?: boolean;
fieldMeta?: TFieldMetaSchema | null;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
/**
* The render type.
*
* @default 'edit'
*
* - `edit` - The field is rendered in editor page.
* - `sign` - The field is rendered for the signing page.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
*/
export type FieldRenderMode = 'edit' | 'sign' | 'export';
export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode: 'edit' | 'sign' | 'export';
mode: FieldRenderMode;
editable?: boolean;
scale: number;
color?: TRecipientColor;
fieldCanvasStyle?: FieldCanvasStyle;
translations: Record<FieldType, string> | null;
};
export type FieldCanvasStyle = {
backgroundColor?: string;
borderColor?: string;
borderRadius?: number;
borderWidth?: number;
opacity?: number;
};
/**
* Converts a fields percentage based values to pixel based values.
*/
@@ -107,6 +107,7 @@ export const renderDropdownFieldElement = (field: FieldToRender, options: Render
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
listening: false,
});
const arrow = new Konva.Line({
@@ -120,6 +121,7 @@ export const renderDropdownFieldElement = (field: FieldToRender, options: Render
lineJoin: 'round',
closed: false,
visible: mode !== 'export',
listening: false,
});
fieldGroup.add(selectedText);
@@ -1,10 +1,11 @@
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { FieldType } from '@prisma/client';
import type Konva from 'konva';
import { match } from 'ts-pattern';
import type { TFieldMetaSchema } from '../../types/field-meta';
import type { FieldCanvasStyleCache } from './field-canvas-style';
import { resolveFieldCanvasStyle } from './field-canvas-style';
import type { FieldRenderMode, FieldToRender } from './field-renderer';
import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderGenericTextFieldElement } from './render-generic-text-field';
@@ -14,30 +15,6 @@ import { renderSignatureFieldElement } from './render-signature-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
/**
* The render type.
*
* @default 'edit'
*
* - `edit` - The field is rendered in editor page.
* - `sign` - The field is rendered for the signing page.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
*/
export type FieldRenderMode = 'edit' | 'sign' | 'export';
export type FieldToRender = Pick<
Field,
'envelopeItemId' | 'recipientId' | 'type' | 'page' | 'customText' | 'inserted' | 'recipientId'
> & {
renderId: string; // A unique ID for the field in the render.
width: number;
height: number;
positionX: number;
positionY: number;
fieldMeta?: TFieldMetaSchema | null;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
type RenderFieldOptions = {
field: FieldToRender;
pageLayer: Konva.Layer;
@@ -52,6 +29,7 @@ type RenderFieldOptions = {
scale: number;
editable?: boolean;
fieldCanvasStyleCache?: FieldCanvasStyleCache;
};
export const renderField = ({
@@ -64,6 +42,7 @@ export const renderField = ({
scale,
editable,
color,
fieldCanvasStyleCache,
}: RenderFieldOptions) => {
const options = {
pageLayer,
@@ -74,6 +53,7 @@ export const renderField = ({
color,
editable,
scale,
fieldCanvasStyle: resolveFieldCanvasStyle(field, mode, fieldCanvasStyleCache),
};
// If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type
@@ -36,6 +36,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
listening: false,
});
// Calculate text positioning based on alignment
@@ -1,6 +1,6 @@
import Konva from 'konva';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE, getSignatureFontFamily } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import type { TSignatureFieldMeta } from '../../types/field-meta';
import { resolveFieldOverflowMode } from '../../types/field-meta';
@@ -77,6 +77,7 @@ const createSignatureImage = (signatureImageAsBase64: string, fieldWidth: number
y: 0,
width: fieldWidth,
height: fieldHeight,
listening: false,
});
img.onload = () => {
@@ -109,6 +110,7 @@ const createSignatureImage = (signatureImageAsBase64: string, fieldWidth: number
return new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
listening: false,
});
};
@@ -121,6 +123,7 @@ const createFieldSignature = (field: FieldToRender, options: RenderFieldElementO
const fieldText = new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
listening: false,
});
const fieldTypeName = translations?.[field.type] || field.type;
@@ -184,7 +187,7 @@ const createFieldSignature = (field: FieldToRender, options: RenderFieldElementO
isLabel,
textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
fontFamily: getSignatureFontFamily(textToRender),
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
@@ -206,7 +209,7 @@ const createFieldSignature = (field: FieldToRender, options: RenderFieldElementO
wrap: overflowLayout.wrap,
text: textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
fontFamily: getSignatureFontFamily(textToRender),
align: overflowLayout.textAlign,
width: overflowLayout.width,
height: overflowLayout.height,
@@ -277,7 +280,7 @@ export const renderSignatureFieldElement = (field: FieldToRender, options: Rende
isLabel,
textToRender: fieldSignature.text(),
fontSize: fieldSignature.fontSize(),
fontFamily: 'Caveat, sans-serif',
fontFamily: getSignatureFontFamily(fieldSignature.text()),
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
+2
View File
@@ -20,8 +20,10 @@ type DatabaseIdPrefix =
| 'envelope'
| 'envelope_item'
| 'email_domain'
| 'email_transport'
| 'org'
| 'org_email'
| 'org_monthly_stat'
| 'org_claim'
| 'org_group'
| 'org_sso'
+7
View File
@@ -0,0 +1,7 @@
/** Current UTC calendar month as `YYYY-MM`. */
export const currentMonthlyPeriod = (): string => {
const now = new Date();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
return `${now.getUTCFullYear()}-${month}`;
};
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
@@ -17,6 +17,14 @@ export class S3Provider implements StorageProvider {
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: env('NEXT_PRIVATE_UPLOAD_REGION') || 'us-east-1',
// Since v3.729 the AWS SDK adds a CRC32 checksum to every request by default
// (`requestChecksumCalculation: 'WHEN_SUPPORTED'`). Many S3-compatible providers
// (GarageHQ, MinIO, Backblaze B2, etc.) reject those requests with an
// `InvalidDigest` error, which breaks uploads against third-party storage. Only
// send/validate checksums when the operation actually requires them so the default
// configuration keeps working with non-AWS backends.
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: hasCredentials
? {
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
+23 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -13,7 +8,27 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putPdfFile = async (file: File) => {
/**
* Options for uploads that are not authorized by a logged-in session.
*
* Embedded authoring flows run cross-origin without a session cookie, so they
* must authorize uploads with their embedding presign token instead.
*/
export type PutFileOptions = {
presignToken?: string;
};
const buildUploadAuthHeaders = (options?: PutFileOptions): Record<string, string> => {
if (!options?.presignToken) {
return {};
}
return {
Authorization: `Bearer ${options.presignToken}`,
};
};
export const putPdfFile = async (file: File, options?: PutFileOptions) => {
const formData = new FormData();
// Create a proper File object from the data
@@ -25,6 +40,7 @@ export const putPdfFile = async (file: File) => {
const response = await fetch('/api/files/upload-pdf', {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
});
@@ -37,67 +53,3 @@ export const putPdfFile = async (file: File) => {
return result;
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};