fabricjs-document-engine 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/dist/assets/asset-pipeline.cjs +3 -1
- package/dist/assets/asset-pipeline.js +3 -1
- package/dist/engine/create-document-engine.cjs +6 -4
- package/dist/engine/create-document-engine.d.cts +2 -0
- package/dist/engine/create-document-engine.d.ts +2 -0
- package/dist/engine/create-document-engine.js +7 -5
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/export/render-export.cjs +3 -15
- package/dist/export/render-export.js +4 -15
- package/dist/history/apply-state.cjs +3 -1
- package/dist/history/apply-state.js +3 -1
- package/dist/history/create-history.cjs +4 -1
- package/dist/history/create-history.d.cts +1 -0
- package/dist/history/create-history.d.ts +1 -0
- package/dist/history/create-history.js +4 -1
- package/dist/history/history-stack.cjs +44 -8
- package/dist/history/history-stack.js +44 -9
- package/dist/index.cjs +4 -0
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/recovery/recovery-controller.cjs +29 -3
- package/dist/recovery/recovery-controller.js +29 -3
- package/dist/security/content-limits.cjs +86 -0
- package/dist/security/content-limits.d.cts +11 -0
- package/dist/security/content-limits.d.ts +11 -0
- package/dist/security/content-limits.js +84 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
|
|
|
16
16
|
- **Versions and migration.** Keep named versions, restore any of them as a new revision, and open plain Fabric JSON or documents saved by older versions of this package.
|
|
17
17
|
- **Reliable undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
|
|
18
18
|
- **React ready, framework free.** Hooks for React, and a small state store for any other framework. Your toolbar and UI stay yours.
|
|
19
|
+
- **Hardened.** Imported documents are cleaned and size-limited, undo history has a memory budget, and every feature is tested in Chromium, Firefox and WebKit. See the [compatibility and performance results](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility.md).
|
|
19
20
|
- **Fabric 6 and 7.** Every release is tested against both.
|
|
20
21
|
|
|
21
22
|
## Install
|
|
@@ -439,6 +440,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
|
|
|
439
440
|
| `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
|
|
440
441
|
| `engine.on(event, handler)` | Listens to `load:start`, `document:change`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `export:success`, `export:error`, `version:created`, `version:restored`, `version:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
|
|
441
442
|
| `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
|
|
443
|
+
| `secureDocument(value, limits?)` / `refuseUnsafeImageUrls(objects, isAllowed?)` / `isSafeImageUrl(url)` | The safety checks, exported for server-side validation. |
|
|
442
444
|
| `createDocumentStateStore(engine)` | Framework-free `{ getSnapshot, subscribe, destroy }` state for toolbars. |
|
|
443
445
|
| `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
|
|
444
446
|
|
|
@@ -448,6 +450,7 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
|
|
|
448
450
|
|
|
449
451
|
| Code | Meaning |
|
|
450
452
|
| --- | --- |
|
|
453
|
+
| `UNSAFE_DOCUMENT` | The document broke a safety rule: an unsafe image address, too many objects, or nesting too deep. `error.issues` lists each one. |
|
|
451
454
|
| `INVALID_DOCUMENT` | The document shape is wrong. `error.issues` lists each path, such as `objects[3].objects[1].type`. |
|
|
452
455
|
| `UNSUPPORTED_SCHEMA` | The document was written by a newer version of this package. |
|
|
453
456
|
| `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
|
|
@@ -490,9 +493,33 @@ A failed load never clears or half-fills your canvas.
|
|
|
490
493
|
| 6 | PNG, JPEG, SVG and JSON export with preflight checks | 0.5.0 |
|
|
491
494
|
| 7 | Named versions and schema migrations | 0.6.0 |
|
|
492
495
|
| 8 | React adapter and examples | 0.7.0 |
|
|
493
|
-
| 9 | Hardening and benchmarks | |
|
|
496
|
+
| 9 | Hardening and benchmarks | 0.8.0 |
|
|
494
497
|
| 10 | Stable API | 1.0.0 |
|
|
495
498
|
|
|
499
|
+
## Imported content and limits
|
|
500
|
+
|
|
501
|
+
Documents often come from users, so the engine treats them as untrusted:
|
|
502
|
+
|
|
503
|
+
- Keys named `__proto__`, `constructor` or `prototype` are removed before Fabric sees them. Fabric copies every key onto the object it creates, so these keys could otherwise change an object's prototype.
|
|
504
|
+
- Image addresses are checked after `assets.resolveUrl`, before anything is fetched. `http:`, `https:`, `blob:`, relative addresses and `data:image/...` are allowed. `javascript:`, `file:` and non-image `data:` addresses are refused with `UNSAFE_DOCUMENT`.
|
|
505
|
+
- A document with more than 50,000 objects, or nested more than 100 levels deep, is refused before loading, so a hostile file cannot freeze the tab.
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
createDocumentEngine({
|
|
509
|
+
canvas,
|
|
510
|
+
limits: {
|
|
511
|
+
maxObjects: 10_000,
|
|
512
|
+
maxDepth: 40,
|
|
513
|
+
isAllowedUrl: (url) => url.startsWith('https://cdn.example.com/'),
|
|
514
|
+
},
|
|
515
|
+
history: { limit: 100, maxBytes: 32 * 1024 * 1024 },
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
`history.maxBytes` caps the memory undo history uses. The default is 64 MB, and the oldest steps are dropped first. SVG export escapes text, so text such as `<script>` inside a text box stays text.
|
|
520
|
+
|
|
521
|
+
Accessibility guidance for your toolbar, status text and dialogs is in [docs/accessibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/accessibility.md).
|
|
522
|
+
|
|
496
523
|
## Troubleshooting
|
|
497
524
|
|
|
498
525
|
Common problems and fixes are in [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md).
|
|
@@ -2,6 +2,7 @@ const require_asset_references = require("./asset-references.cjs");
|
|
|
2
2
|
const require_asset_manifest = require("./asset-manifest.cjs");
|
|
3
3
|
const require_errors = require("../engine/errors.cjs");
|
|
4
4
|
const require_font_check = require("./font-check.cjs");
|
|
5
|
+
const require_content_limits = require("../security/content-limits.cjs");
|
|
5
6
|
const require_image_check = require("./image-check.cjs");
|
|
6
7
|
//#region src/assets/asset-pipeline.ts
|
|
7
8
|
function cloneDocument(document) {
|
|
@@ -105,10 +106,11 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
105
106
|
warnings
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
|
-
async function prepareAssetsForLoad(input, options, signal) {
|
|
109
|
+
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
|
|
109
110
|
const document = cloneDocument(input);
|
|
110
111
|
const { resolveUrl } = options;
|
|
111
112
|
if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
|
|
113
|
+
require_content_limits.refuseUnsafeImageUrls(document.objects, isAllowedUrl);
|
|
112
114
|
const report = await inspectAssets(document, options, signal);
|
|
113
115
|
if (options.requireFonts && report.unavailableFonts.length > 0) {
|
|
114
116
|
const families = report.unavailableFonts.map((font) => font.family).join(", ");
|
|
@@ -2,6 +2,7 @@ import { findImageReferences } from "./asset-references.js";
|
|
|
2
2
|
import { buildAssetManifest, isEmbeddedUrl } from "./asset-manifest.js";
|
|
3
3
|
import { DocumentEngineError, isDocumentEngineError } from "../engine/errors.js";
|
|
4
4
|
import { findUnavailableFonts } from "./font-check.js";
|
|
5
|
+
import { refuseUnsafeImageUrls } from "../security/content-limits.js";
|
|
5
6
|
import { findMissingImages, isCrossOriginUrl, isPortableUrl } from "./image-check.js";
|
|
6
7
|
//#region src/assets/asset-pipeline.ts
|
|
7
8
|
function cloneDocument(document) {
|
|
@@ -105,10 +106,11 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
105
106
|
warnings
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
|
-
async function prepareAssetsForLoad(input, options, signal) {
|
|
109
|
+
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
|
|
109
110
|
const document = cloneDocument(input);
|
|
110
111
|
const { resolveUrl } = options;
|
|
111
112
|
if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
|
|
113
|
+
refuseUnsafeImageUrls(document.objects, isAllowedUrl);
|
|
112
114
|
const report = await inspectAssets(document, options, signal);
|
|
113
115
|
if (options.requireFonts && report.unavailableFonts.length > 0) {
|
|
114
116
|
const families = report.unavailableFonts.map((font) => font.family).join(", ");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const require_asset_manifest = require("../assets/asset-manifest.cjs");
|
|
2
2
|
const require_errors = require("./errors.cjs");
|
|
3
|
+
const require_content_limits = require("../security/content-limits.cjs");
|
|
3
4
|
const require_image_check = require("../assets/image-check.cjs");
|
|
4
5
|
const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
|
|
5
6
|
const require_export_area = require("../export/export-area.cjs");
|
|
@@ -72,6 +73,7 @@ function createDocumentEngine(options) {
|
|
|
72
73
|
const history = require_create_history.createHistory({
|
|
73
74
|
canvas,
|
|
74
75
|
limit: options.history?.limit,
|
|
76
|
+
maxBytes: options.history?.maxBytes,
|
|
75
77
|
serializeObjects: () => {
|
|
76
78
|
rebuildIndex();
|
|
77
79
|
return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
|
|
@@ -171,13 +173,13 @@ function createDocumentEngine(options) {
|
|
|
171
173
|
try {
|
|
172
174
|
await recovery?.markLoadStarted(documentId);
|
|
173
175
|
if (controller.signal.aborted) throw new Error("aborted");
|
|
174
|
-
const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(input, {
|
|
176
|
+
const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(require_content_limits.secureDocument(input, options.limits), {
|
|
175
177
|
canvasWidth: canvas.getWidth(),
|
|
176
178
|
canvasHeight: canvas.getHeight(),
|
|
177
179
|
...importDetails
|
|
178
180
|
});
|
|
179
181
|
const checked = checkDocument(migrated);
|
|
180
|
-
const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
|
|
182
|
+
const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
|
|
181
183
|
if (controller.signal.aborted) throw new Error("aborted");
|
|
182
184
|
await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
|
|
183
185
|
version: document.fabricVersion,
|
|
@@ -412,10 +414,10 @@ function createDocumentEngine(options) {
|
|
|
412
414
|
const area = require_export_area.resolveExportArea(canvas, settings.area, settings.padding);
|
|
413
415
|
return {
|
|
414
416
|
area,
|
|
415
|
-
content: format === "svg" ? require_render_export.renderSvg(canvas, area, settings.scale) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
|
|
417
|
+
content: format === "svg" ? Promise.resolve(new Blob([require_render_export.renderSvg(canvas, area, settings.scale)], { type: require_render_export.mimeTypes.svg })) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
|
|
416
418
|
};
|
|
417
419
|
});
|
|
418
|
-
const blob =
|
|
420
|
+
const blob = await rendered.content;
|
|
419
421
|
return {
|
|
420
422
|
format,
|
|
421
423
|
mimeType: blob.type || require_render_export.mimeTypes[format],
|
|
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.cjs";
|
|
|
6
6
|
import { NewDocumentOptions } from "../document/create-document.cjs";
|
|
7
7
|
import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
|
|
8
8
|
import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
|
|
9
|
+
import { ContentLimits } from "../security/content-limits.cjs";
|
|
9
10
|
import { VersionOptions, VersionSummary } from "../versions/document-version.cjs";
|
|
10
11
|
import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.cjs";
|
|
11
12
|
import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
|
|
@@ -27,6 +28,7 @@ export interface DocumentEngineOptions {
|
|
|
27
28
|
assets?: AssetOptions;
|
|
28
29
|
recovery?: RecoveryOptions;
|
|
29
30
|
versions?: VersionOptions;
|
|
31
|
+
limits?: ContentLimits;
|
|
30
32
|
}
|
|
31
33
|
export interface LoadOptions {
|
|
32
34
|
restoreCanvasSize?: boolean;
|
|
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.js";
|
|
|
6
6
|
import { NewDocumentOptions } from "../document/create-document.js";
|
|
7
7
|
import { CustomObjectDefinition } from "../fabric/object-registry.js";
|
|
8
8
|
import { HistoryOptions, HistoryState } from "../history/create-history.js";
|
|
9
|
+
import { ContentLimits } from "../security/content-limits.js";
|
|
9
10
|
import { VersionOptions, VersionSummary } from "../versions/document-version.js";
|
|
10
11
|
import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.js";
|
|
11
12
|
import { AutosaveOptions } from "../save/autosave-scheduler.js";
|
|
@@ -27,6 +28,7 @@ export interface DocumentEngineOptions {
|
|
|
27
28
|
assets?: AssetOptions;
|
|
28
29
|
recovery?: RecoveryOptions;
|
|
29
30
|
versions?: VersionOptions;
|
|
31
|
+
limits?: ContentLimits;
|
|
30
32
|
}
|
|
31
33
|
export interface LoadOptions {
|
|
32
34
|
restoreCanvasSize?: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildAssetManifest } from "../assets/asset-manifest.js";
|
|
2
2
|
import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
|
|
3
|
+
import { secureDocument } from "../security/content-limits.js";
|
|
3
4
|
import { isSameUrl } from "../assets/image-check.js";
|
|
4
5
|
import { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave } from "../assets/asset-pipeline.js";
|
|
5
6
|
import { resolveExportArea } from "../export/export-area.js";
|
|
@@ -7,7 +8,7 @@ import { normalizeExportOptions } from "../export/export-options.js";
|
|
|
7
8
|
import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
|
|
8
9
|
import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
|
|
9
10
|
import { preflightExport } from "../export/preflight-export.js";
|
|
10
|
-
import {
|
|
11
|
+
import { mimeTypes, renderRaster, renderSvg, withExportView } from "../export/render-export.js";
|
|
11
12
|
import "../document/document-format.js";
|
|
12
13
|
import { createId } from "../document/ids.js";
|
|
13
14
|
import { createDocumentInfo } from "../document/create-document.js";
|
|
@@ -72,6 +73,7 @@ function createDocumentEngine(options) {
|
|
|
72
73
|
const history = createHistory({
|
|
73
74
|
canvas,
|
|
74
75
|
limit: options.history?.limit,
|
|
76
|
+
maxBytes: options.history?.maxBytes,
|
|
75
77
|
serializeObjects: () => {
|
|
76
78
|
rebuildIndex();
|
|
77
79
|
return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
|
|
@@ -171,13 +173,13 @@ function createDocumentEngine(options) {
|
|
|
171
173
|
try {
|
|
172
174
|
await recovery?.markLoadStarted(documentId);
|
|
173
175
|
if (controller.signal.aborted) throw new Error("aborted");
|
|
174
|
-
const { document: migrated, migratedFrom } = migrateDocument(input, {
|
|
176
|
+
const { document: migrated, migratedFrom } = migrateDocument(secureDocument(input, options.limits), {
|
|
175
177
|
canvasWidth: canvas.getWidth(),
|
|
176
178
|
canvasHeight: canvas.getHeight(),
|
|
177
179
|
...importDetails
|
|
178
180
|
});
|
|
179
181
|
const checked = checkDocument(migrated);
|
|
180
|
-
const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
|
|
182
|
+
const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
|
|
181
183
|
if (controller.signal.aborted) throw new Error("aborted");
|
|
182
184
|
await history.withoutRecording(() => loadIntoCanvas(canvas, {
|
|
183
185
|
version: document.fabricVersion,
|
|
@@ -412,10 +414,10 @@ function createDocumentEngine(options) {
|
|
|
412
414
|
const area = resolveExportArea(canvas, settings.area, settings.padding);
|
|
413
415
|
return {
|
|
414
416
|
area,
|
|
415
|
-
content: format === "svg" ? renderSvg(canvas, area, settings.scale) : renderRaster(canvas, area, format, settings.scale, settings.quality)
|
|
417
|
+
content: format === "svg" ? Promise.resolve(new Blob([renderSvg(canvas, area, settings.scale)], { type: mimeTypes.svg })) : renderRaster(canvas, area, format, settings.scale, settings.quality)
|
|
416
418
|
};
|
|
417
419
|
});
|
|
418
|
-
const blob =
|
|
420
|
+
const blob = await rendered.content;
|
|
419
421
|
return {
|
|
420
422
|
format,
|
|
421
423
|
mimeType: blob.type || mimeTypes[format],
|
package/dist/engine/errors.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
|
|
2
2
|
import { ExportProblem } from "../export/preflight-export.cjs";
|
|
3
3
|
//#region src/engine/errors.d.ts
|
|
4
|
-
export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
|
|
4
|
+
export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
|
|
5
5
|
export interface DocumentIssue {
|
|
6
6
|
code: DocumentErrorCode;
|
|
7
7
|
path: string;
|
package/dist/engine/errors.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
|
|
2
2
|
import { ExportProblem } from "../export/preflight-export.js";
|
|
3
3
|
//#region src/engine/errors.d.ts
|
|
4
|
-
export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
|
|
4
|
+
export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
|
|
5
5
|
export interface DocumentIssue {
|
|
6
6
|
code: DocumentErrorCode;
|
|
7
7
|
path: string;
|
|
@@ -33,12 +33,9 @@ function withExportView(canvas, background, format, work) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
function renderRaster(canvas, area, format, scale, quality) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
format,
|
|
39
|
-
quality,
|
|
40
|
-
multiplier: scale,
|
|
41
|
-
enableRetinaScaling: false
|
|
36
|
+
const rendered = canvas.toCanvasElement(scale, area);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
|
|
42
39
|
});
|
|
43
40
|
}
|
|
44
41
|
function renderSvg(canvas, area, scale) {
|
|
@@ -53,16 +50,7 @@ function renderSvg(canvas, area, scale) {
|
|
|
53
50
|
height: String(area.height * scale)
|
|
54
51
|
});
|
|
55
52
|
}
|
|
56
|
-
function dataUrlToBlob(dataUrl) {
|
|
57
|
-
const [header, payload = ""] = dataUrl.split(",");
|
|
58
|
-
const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
|
|
59
|
-
const binary = atob(payload);
|
|
60
|
-
const bytes = new Uint8Array(binary.length);
|
|
61
|
-
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
62
|
-
return new Blob([bytes], { type: mimeType });
|
|
63
|
-
}
|
|
64
53
|
//#endregion
|
|
65
|
-
exports.dataUrlToBlob = dataUrlToBlob;
|
|
66
54
|
exports.mimeTypes = mimeTypes;
|
|
67
55
|
exports.renderRaster = renderRaster;
|
|
68
56
|
exports.renderSvg = renderSvg;
|
|
@@ -33,12 +33,9 @@ function withExportView(canvas, background, format, work) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
function renderRaster(canvas, area, format, scale, quality) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
format,
|
|
39
|
-
quality,
|
|
40
|
-
multiplier: scale,
|
|
41
|
-
enableRetinaScaling: false
|
|
36
|
+
const rendered = canvas.toCanvasElement(scale, area);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
|
|
42
39
|
});
|
|
43
40
|
}
|
|
44
41
|
function renderSvg(canvas, area, scale) {
|
|
@@ -53,13 +50,5 @@ function renderSvg(canvas, area, scale) {
|
|
|
53
50
|
height: String(area.height * scale)
|
|
54
51
|
});
|
|
55
52
|
}
|
|
56
|
-
function dataUrlToBlob(dataUrl) {
|
|
57
|
-
const [header, payload = ""] = dataUrl.split(",");
|
|
58
|
-
const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
|
|
59
|
-
const binary = atob(payload);
|
|
60
|
-
const bytes = new Uint8Array(binary.length);
|
|
61
|
-
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
62
|
-
return new Blob([bytes], { type: mimeType });
|
|
63
|
-
}
|
|
64
53
|
//#endregion
|
|
65
|
-
export {
|
|
54
|
+
export { mimeTypes, renderRaster, renderSvg, withExportView };
|
|
@@ -34,10 +34,12 @@ async function applyStateChange(canvas, change) {
|
|
|
34
34
|
try {
|
|
35
35
|
const outgoing = current.filter((object) => !kept.has(object));
|
|
36
36
|
if (outgoing.length > 0) canvas.remove(...outgoing);
|
|
37
|
+
let onCanvas = canvas.getObjects();
|
|
37
38
|
arranged.forEach((object, index) => {
|
|
38
|
-
if (
|
|
39
|
+
if (onCanvas[index] === object) return;
|
|
39
40
|
if (object.canvas === canvas) canvas.moveObjectTo(object, index);
|
|
40
41
|
else canvas.insertAt(index, object);
|
|
42
|
+
onCanvas = canvas.getObjects();
|
|
41
43
|
});
|
|
42
44
|
} finally {
|
|
43
45
|
canvas.renderOnAddRemove = renderOnAddRemove;
|
|
@@ -34,10 +34,12 @@ async function applyStateChange(canvas, change) {
|
|
|
34
34
|
try {
|
|
35
35
|
const outgoing = current.filter((object) => !kept.has(object));
|
|
36
36
|
if (outgoing.length > 0) canvas.remove(...outgoing);
|
|
37
|
+
let onCanvas = canvas.getObjects();
|
|
37
38
|
arranged.forEach((object, index) => {
|
|
38
|
-
if (
|
|
39
|
+
if (onCanvas[index] === object) return;
|
|
39
40
|
if (object.canvas === canvas) canvas.moveObjectTo(object, index);
|
|
40
41
|
else canvas.insertAt(index, object);
|
|
42
|
+
onCanvas = canvas.getObjects();
|
|
41
43
|
});
|
|
42
44
|
} finally {
|
|
43
45
|
canvas.renderOnAddRemove = renderOnAddRemove;
|
|
@@ -25,7 +25,10 @@ function describePendingChanges(changes) {
|
|
|
25
25
|
}
|
|
26
26
|
function createHistory(options) {
|
|
27
27
|
const { canvas, serializeObjects, onChange, onContentChange } = options;
|
|
28
|
-
const stack = require_history_stack.createHistoryStack(
|
|
28
|
+
const stack = require_history_stack.createHistoryStack({
|
|
29
|
+
steps: Math.max(1, options.limit ?? 100),
|
|
30
|
+
bytes: Math.max(0, options.maxBytes ?? 67108864)
|
|
31
|
+
});
|
|
29
32
|
let snapshot = require_snapshot.createSnapshot(serializeObjects());
|
|
30
33
|
let pendingChanges = [];
|
|
31
34
|
let flushScheduled = false;
|
|
@@ -25,7 +25,10 @@ function describePendingChanges(changes) {
|
|
|
25
25
|
}
|
|
26
26
|
function createHistory(options) {
|
|
27
27
|
const { canvas, serializeObjects, onChange, onContentChange } = options;
|
|
28
|
-
const stack = createHistoryStack(
|
|
28
|
+
const stack = createHistoryStack({
|
|
29
|
+
steps: Math.max(1, options.limit ?? 100),
|
|
30
|
+
bytes: Math.max(0, options.maxBytes ?? 67108864)
|
|
31
|
+
});
|
|
29
32
|
let snapshot = createSnapshot(serializeObjects());
|
|
30
33
|
let pendingChanges = [];
|
|
31
34
|
let flushScheduled = false;
|
|
@@ -1,27 +1,63 @@
|
|
|
1
1
|
//#region src/history/history-stack.ts
|
|
2
|
-
function
|
|
2
|
+
function bytesOfChange(change) {
|
|
3
|
+
let bytes = (change.order?.length ?? 0) * 8;
|
|
4
|
+
for (const [id, json] of change.objects) bytes += (id.length + (json?.length ?? 0)) * 2;
|
|
5
|
+
return bytes;
|
|
6
|
+
}
|
|
7
|
+
function estimateStepBytes(step) {
|
|
8
|
+
return bytesOfChange(step.before) + bytesOfChange(step.after) + step.label.length * 2;
|
|
9
|
+
}
|
|
10
|
+
function createHistoryStack(limits) {
|
|
3
11
|
const undoSteps = [];
|
|
4
12
|
const redoSteps = [];
|
|
5
|
-
|
|
13
|
+
const sizes = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
let totalBytes = 0;
|
|
15
|
+
function sizeOf(step) {
|
|
16
|
+
let size = sizes.get(step);
|
|
17
|
+
if (size === void 0) {
|
|
18
|
+
size = estimateStepBytes(step);
|
|
19
|
+
sizes.set(step, size);
|
|
20
|
+
}
|
|
21
|
+
return size;
|
|
22
|
+
}
|
|
23
|
+
function forget(step) {
|
|
24
|
+
if (step) totalBytes -= sizeOf(step);
|
|
25
|
+
}
|
|
26
|
+
function stayWithinLimits() {
|
|
27
|
+
while (undoSteps.length > limits.steps) forget(undoSteps.shift());
|
|
28
|
+
while (redoSteps.length > limits.steps) forget(redoSteps.shift());
|
|
29
|
+
while (totalBytes > limits.bytes && undoSteps.length + redoSteps.length > 1) forget(undoSteps.length > 0 ? undoSteps.shift() : redoSteps.shift());
|
|
30
|
+
}
|
|
31
|
+
function add(steps, step) {
|
|
6
32
|
steps.push(step);
|
|
7
|
-
|
|
33
|
+
totalBytes += sizeOf(step);
|
|
34
|
+
stayWithinLimits();
|
|
35
|
+
}
|
|
36
|
+
function take(steps) {
|
|
37
|
+
const step = steps.pop();
|
|
38
|
+
forget(step);
|
|
39
|
+
return step;
|
|
8
40
|
}
|
|
9
41
|
return {
|
|
10
42
|
record(step) {
|
|
11
|
-
|
|
43
|
+
redoSteps.forEach(forget);
|
|
12
44
|
redoSteps.length = 0;
|
|
45
|
+
add(undoSteps, step);
|
|
13
46
|
},
|
|
14
|
-
takeUndo: () => undoSteps
|
|
15
|
-
takeRedo: () => redoSteps
|
|
16
|
-
returnUndo: (step) =>
|
|
17
|
-
returnRedo: (step) =>
|
|
47
|
+
takeUndo: () => take(undoSteps),
|
|
48
|
+
takeRedo: () => take(redoSteps),
|
|
49
|
+
returnUndo: (step) => add(undoSteps, step),
|
|
50
|
+
returnRedo: (step) => add(redoSteps, step),
|
|
18
51
|
undoLabels: () => undoSteps.map((step) => step.label).reverse(),
|
|
19
52
|
redoLabels: () => redoSteps.map((step) => step.label).reverse(),
|
|
53
|
+
usedBytes: () => totalBytes,
|
|
20
54
|
clear() {
|
|
21
55
|
undoSteps.length = 0;
|
|
22
56
|
redoSteps.length = 0;
|
|
57
|
+
totalBytes = 0;
|
|
23
58
|
}
|
|
24
59
|
};
|
|
25
60
|
}
|
|
26
61
|
//#endregion
|
|
27
62
|
exports.createHistoryStack = createHistoryStack;
|
|
63
|
+
exports.estimateStepBytes = estimateStepBytes;
|
|
@@ -1,27 +1,62 @@
|
|
|
1
1
|
//#region src/history/history-stack.ts
|
|
2
|
-
function
|
|
2
|
+
function bytesOfChange(change) {
|
|
3
|
+
let bytes = (change.order?.length ?? 0) * 8;
|
|
4
|
+
for (const [id, json] of change.objects) bytes += (id.length + (json?.length ?? 0)) * 2;
|
|
5
|
+
return bytes;
|
|
6
|
+
}
|
|
7
|
+
function estimateStepBytes(step) {
|
|
8
|
+
return bytesOfChange(step.before) + bytesOfChange(step.after) + step.label.length * 2;
|
|
9
|
+
}
|
|
10
|
+
function createHistoryStack(limits) {
|
|
3
11
|
const undoSteps = [];
|
|
4
12
|
const redoSteps = [];
|
|
5
|
-
|
|
13
|
+
const sizes = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
let totalBytes = 0;
|
|
15
|
+
function sizeOf(step) {
|
|
16
|
+
let size = sizes.get(step);
|
|
17
|
+
if (size === void 0) {
|
|
18
|
+
size = estimateStepBytes(step);
|
|
19
|
+
sizes.set(step, size);
|
|
20
|
+
}
|
|
21
|
+
return size;
|
|
22
|
+
}
|
|
23
|
+
function forget(step) {
|
|
24
|
+
if (step) totalBytes -= sizeOf(step);
|
|
25
|
+
}
|
|
26
|
+
function stayWithinLimits() {
|
|
27
|
+
while (undoSteps.length > limits.steps) forget(undoSteps.shift());
|
|
28
|
+
while (redoSteps.length > limits.steps) forget(redoSteps.shift());
|
|
29
|
+
while (totalBytes > limits.bytes && undoSteps.length + redoSteps.length > 1) forget(undoSteps.length > 0 ? undoSteps.shift() : redoSteps.shift());
|
|
30
|
+
}
|
|
31
|
+
function add(steps, step) {
|
|
6
32
|
steps.push(step);
|
|
7
|
-
|
|
33
|
+
totalBytes += sizeOf(step);
|
|
34
|
+
stayWithinLimits();
|
|
35
|
+
}
|
|
36
|
+
function take(steps) {
|
|
37
|
+
const step = steps.pop();
|
|
38
|
+
forget(step);
|
|
39
|
+
return step;
|
|
8
40
|
}
|
|
9
41
|
return {
|
|
10
42
|
record(step) {
|
|
11
|
-
|
|
43
|
+
redoSteps.forEach(forget);
|
|
12
44
|
redoSteps.length = 0;
|
|
45
|
+
add(undoSteps, step);
|
|
13
46
|
},
|
|
14
|
-
takeUndo: () => undoSteps
|
|
15
|
-
takeRedo: () => redoSteps
|
|
16
|
-
returnUndo: (step) =>
|
|
17
|
-
returnRedo: (step) =>
|
|
47
|
+
takeUndo: () => take(undoSteps),
|
|
48
|
+
takeRedo: () => take(redoSteps),
|
|
49
|
+
returnUndo: (step) => add(undoSteps, step),
|
|
50
|
+
returnRedo: (step) => add(redoSteps, step),
|
|
18
51
|
undoLabels: () => undoSteps.map((step) => step.label).reverse(),
|
|
19
52
|
redoLabels: () => redoSteps.map((step) => step.label).reverse(),
|
|
53
|
+
usedBytes: () => totalBytes,
|
|
20
54
|
clear() {
|
|
21
55
|
undoSteps.length = 0;
|
|
22
56
|
redoSteps.length = 0;
|
|
57
|
+
totalBytes = 0;
|
|
23
58
|
}
|
|
24
59
|
};
|
|
25
60
|
}
|
|
26
61
|
//#endregion
|
|
27
|
-
export { createHistoryStack };
|
|
62
|
+
export { createHistoryStack, estimateStepBytes };
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_errors = require("./engine/errors.cjs");
|
|
3
|
+
const require_content_limits = require("./security/content-limits.cjs");
|
|
3
4
|
const require_document_format = require("./document/document-format.cjs");
|
|
4
5
|
const require_validate_document = require("./document/validate-document.cjs");
|
|
5
6
|
const require_migrate_document = require("./migrations/migrate-document.cjs");
|
|
@@ -18,5 +19,8 @@ exports.createDocumentStateStore = require_document_state_store.createDocumentSt
|
|
|
18
19
|
exports.detectSchemaVersion = require_migrate_document.detectSchemaVersion;
|
|
19
20
|
exports.downloadExport = require_download_export.downloadExport;
|
|
20
21
|
exports.isDocumentEngineError = require_errors.isDocumentEngineError;
|
|
22
|
+
exports.isSafeImageUrl = require_content_limits.isSafeImageUrl;
|
|
21
23
|
exports.migrateDocument = require_migrate_document.migrateDocument;
|
|
24
|
+
exports.refuseUnsafeImageUrls = require_content_limits.refuseUnsafeImageUrls;
|
|
25
|
+
exports.secureDocument = require_content_limits.secureDocument;
|
|
22
26
|
exports.validateDocument = require_validate_document.validateDocument;
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,7 @@ import { ExportPreflight, ExportProblem, ExportProblemCode } from "./export/pref
|
|
|
7
7
|
import { NewDocumentOptions } from "./document/create-document.cjs";
|
|
8
8
|
import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
|
|
9
9
|
import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
|
|
10
|
+
import { ContentLimits, isSafeImageUrl, refuseUnsafeImageUrls, secureDocument } from "./security/content-limits.cjs";
|
|
10
11
|
import { DocumentVersion, VersionKind, VersionOptions, VersionStorage, VersionSummary } from "./versions/document-version.cjs";
|
|
11
12
|
import { RecoveryStore } from "./recovery/recovery-store.cjs";
|
|
12
13
|
import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.cjs";
|
|
@@ -23,4 +24,4 @@ import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-
|
|
|
23
24
|
import { DownloadableExport, downloadExport } from "./export/download-export.cjs";
|
|
24
25
|
import { Migration, MigrationContext, MigrationResult, detectSchemaVersion, migrateDocument } from "./migrations/migrate-document.cjs";
|
|
25
26
|
import { DocumentState, DocumentStateStore, createDocumentStateStore } from "./state/document-state-store.cjs";
|
|
26
|
-
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentState, type DocumentStateStore, type DocumentStorage, type DocumentVersion, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type ImportOptions, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type Migration, type MigrationContext, type MigrationResult, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, type VersionKind, type VersionOptions, type VersionStorage, type VersionSummary, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, migrateDocument, validateDocument };
|
|
27
|
+
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type ContentLimits, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentState, type DocumentStateStore, type DocumentStorage, type DocumentVersion, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type ImportOptions, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type Migration, type MigrationContext, type MigrationResult, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, type VersionKind, type VersionOptions, type VersionStorage, type VersionSummary, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, isSafeImageUrl, migrateDocument, refuseUnsafeImageUrls, secureDocument, validateDocument };
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ExportPreflight, ExportProblem, ExportProblemCode } from "./export/pref
|
|
|
7
7
|
import { NewDocumentOptions } from "./document/create-document.js";
|
|
8
8
|
import { CustomObjectDefinition } from "./fabric/object-registry.js";
|
|
9
9
|
import { HistoryOptions, HistoryState } from "./history/create-history.js";
|
|
10
|
+
import { ContentLimits, isSafeImageUrl, refuseUnsafeImageUrls, secureDocument } from "./security/content-limits.js";
|
|
10
11
|
import { DocumentVersion, VersionKind, VersionOptions, VersionStorage, VersionSummary } from "./versions/document-version.js";
|
|
11
12
|
import { RecoveryStore } from "./recovery/recovery-store.js";
|
|
12
13
|
import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.js";
|
|
@@ -23,4 +24,4 @@ import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-
|
|
|
23
24
|
import { DownloadableExport, downloadExport } from "./export/download-export.js";
|
|
24
25
|
import { Migration, MigrationContext, MigrationResult, detectSchemaVersion, migrateDocument } from "./migrations/migrate-document.js";
|
|
25
26
|
import { DocumentState, DocumentStateStore, createDocumentStateStore } from "./state/document-state-store.js";
|
|
26
|
-
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentState, type DocumentStateStore, type DocumentStorage, type DocumentVersion, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type ImportOptions, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type Migration, type MigrationContext, type MigrationResult, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, type VersionKind, type VersionOptions, type VersionStorage, type VersionSummary, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, migrateDocument, validateDocument };
|
|
27
|
+
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type ContentLimits, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentState, type DocumentStateStore, type DocumentStorage, type DocumentVersion, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type ImportOptions, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type Migration, type MigrationContext, type MigrationResult, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, type VersionKind, type VersionOptions, type VersionStorage, type VersionSummary, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, isSafeImageUrl, migrateDocument, refuseUnsafeImageUrls, secureDocument, validateDocument };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DocumentEngineError, createConflictError, isDocumentEngineError } from "./engine/errors.js";
|
|
2
|
+
import { isSafeImageUrl, refuseUnsafeImageUrls, secureDocument } from "./security/content-limits.js";
|
|
2
3
|
import { CURRENT_SCHEMA_VERSION } from "./document/document-format.js";
|
|
3
4
|
import { validateDocument } from "./document/validate-document.js";
|
|
4
5
|
import { detectSchemaVersion, migrateDocument } from "./migrations/migrate-document.js";
|
|
@@ -7,4 +8,4 @@ import { bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
|
|
|
7
8
|
import { bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
|
|
8
9
|
import { downloadExport } from "./export/download-export.js";
|
|
9
10
|
import { createDocumentStateStore } from "./state/document-state-store.js";
|
|
10
|
-
export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, migrateDocument, validateDocument };
|
|
11
|
+
export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, createDocumentStateStore, detectSchemaVersion, downloadExport, isDocumentEngineError, isSafeImageUrl, migrateDocument, refuseUnsafeImageUrls, secureDocument, validateDocument };
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
|
|
2
2
|
//#region src/recovery/recovery-controller.ts
|
|
3
|
+
async function toStoredFiles(files) {
|
|
4
|
+
const entries = await Promise.all(Object.entries(files).map(async ([url, blob]) => [url, {
|
|
5
|
+
type: blob.type,
|
|
6
|
+
bytes: await blob.arrayBuffer()
|
|
7
|
+
}]));
|
|
8
|
+
return Object.fromEntries(entries);
|
|
9
|
+
}
|
|
10
|
+
function fromStoredFiles(files) {
|
|
11
|
+
const restored = {};
|
|
12
|
+
for (const [url, file] of Object.entries(files ?? {})) restored[url] = file instanceof Blob ? file : new Blob([file.bytes], { type: file.type });
|
|
13
|
+
return restored;
|
|
14
|
+
}
|
|
15
|
+
function fromStoredRecord(value) {
|
|
16
|
+
if (!isRecoveryRecord(value)) return void 0;
|
|
17
|
+
const stored = value;
|
|
18
|
+
return {
|
|
19
|
+
...stored,
|
|
20
|
+
files: fromStoredFiles(stored.files)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
3
23
|
const documentKeyPrefix = "document:";
|
|
4
24
|
const unloadKeyPrefix = "unload:";
|
|
5
25
|
const loadingKey = "loading";
|
|
@@ -79,7 +99,10 @@ function createRecoveryController(options) {
|
|
|
79
99
|
if (!options.shouldWrite()) return;
|
|
80
100
|
const document = options.createDocument();
|
|
81
101
|
const record = createRecord(document, await captureTabOnlyFiles(document));
|
|
82
|
-
await store.set(documentKey(document.id),
|
|
102
|
+
await store.set(documentKey(document.id), {
|
|
103
|
+
...record,
|
|
104
|
+
files: await toStoredFiles(record.files)
|
|
105
|
+
});
|
|
83
106
|
options.onCheckpoint(record);
|
|
84
107
|
}
|
|
85
108
|
function writeCheckpointBeforeThePageCloses() {
|
|
@@ -91,12 +114,15 @@ function createRecoveryController(options) {
|
|
|
91
114
|
return;
|
|
92
115
|
}
|
|
93
116
|
const record = createRecord(document, Object.fromEntries(readyFiles));
|
|
94
|
-
|
|
117
|
+
toStoredFiles(record.files).then((files) => store.set(documentKey(document.id), {
|
|
118
|
+
...record,
|
|
119
|
+
files
|
|
120
|
+
})).then(() => options.onCheckpoint(record), options.onError);
|
|
95
121
|
}
|
|
96
122
|
async function read(documentId) {
|
|
97
123
|
await queue;
|
|
98
124
|
const [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
|
|
99
|
-
return newerRecord(
|
|
125
|
+
return newerRecord(fromStoredRecord(checkpoint), fromStoredRecord(unloadCopy));
|
|
100
126
|
}
|
|
101
127
|
function cancel() {
|
|
102
128
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { rewriteUrls } from "../assets/asset-pipeline.js";
|
|
2
2
|
//#region src/recovery/recovery-controller.ts
|
|
3
|
+
async function toStoredFiles(files) {
|
|
4
|
+
const entries = await Promise.all(Object.entries(files).map(async ([url, blob]) => [url, {
|
|
5
|
+
type: blob.type,
|
|
6
|
+
bytes: await blob.arrayBuffer()
|
|
7
|
+
}]));
|
|
8
|
+
return Object.fromEntries(entries);
|
|
9
|
+
}
|
|
10
|
+
function fromStoredFiles(files) {
|
|
11
|
+
const restored = {};
|
|
12
|
+
for (const [url, file] of Object.entries(files ?? {})) restored[url] = file instanceof Blob ? file : new Blob([file.bytes], { type: file.type });
|
|
13
|
+
return restored;
|
|
14
|
+
}
|
|
15
|
+
function fromStoredRecord(value) {
|
|
16
|
+
if (!isRecoveryRecord(value)) return void 0;
|
|
17
|
+
const stored = value;
|
|
18
|
+
return {
|
|
19
|
+
...stored,
|
|
20
|
+
files: fromStoredFiles(stored.files)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
3
23
|
const documentKeyPrefix = "document:";
|
|
4
24
|
const unloadKeyPrefix = "unload:";
|
|
5
25
|
const loadingKey = "loading";
|
|
@@ -79,7 +99,10 @@ function createRecoveryController(options) {
|
|
|
79
99
|
if (!options.shouldWrite()) return;
|
|
80
100
|
const document = options.createDocument();
|
|
81
101
|
const record = createRecord(document, await captureTabOnlyFiles(document));
|
|
82
|
-
await store.set(documentKey(document.id),
|
|
102
|
+
await store.set(documentKey(document.id), {
|
|
103
|
+
...record,
|
|
104
|
+
files: await toStoredFiles(record.files)
|
|
105
|
+
});
|
|
83
106
|
options.onCheckpoint(record);
|
|
84
107
|
}
|
|
85
108
|
function writeCheckpointBeforeThePageCloses() {
|
|
@@ -91,12 +114,15 @@ function createRecoveryController(options) {
|
|
|
91
114
|
return;
|
|
92
115
|
}
|
|
93
116
|
const record = createRecord(document, Object.fromEntries(readyFiles));
|
|
94
|
-
|
|
117
|
+
toStoredFiles(record.files).then((files) => store.set(documentKey(document.id), {
|
|
118
|
+
...record,
|
|
119
|
+
files
|
|
120
|
+
})).then(() => options.onCheckpoint(record), options.onError);
|
|
95
121
|
}
|
|
96
122
|
async function read(documentId) {
|
|
97
123
|
await queue;
|
|
98
124
|
const [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
|
|
99
|
-
return newerRecord(
|
|
125
|
+
return newerRecord(fromStoredRecord(checkpoint), fromStoredRecord(unloadCopy));
|
|
100
126
|
}
|
|
101
127
|
function cancel() {
|
|
102
128
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const require_asset_references = require("../assets/asset-references.cjs");
|
|
2
|
+
const require_errors = require("../engine/errors.cjs");
|
|
3
|
+
//#region src/security/content-limits.ts
|
|
4
|
+
const dangerousKeys = /* @__PURE__ */ new Set([
|
|
5
|
+
"__proto__",
|
|
6
|
+
"constructor",
|
|
7
|
+
"prototype"
|
|
8
|
+
]);
|
|
9
|
+
function isSafeImageUrl(url) {
|
|
10
|
+
const trimmed = url.trim().toLowerCase();
|
|
11
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(trimmed)?.[1];
|
|
12
|
+
if (scheme === void 0) return true;
|
|
13
|
+
if (scheme === "http" || scheme === "https" || scheme === "blob") return true;
|
|
14
|
+
if (scheme === "data") return trimmed.startsWith("data:image/");
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function withoutDangerousKeys(value) {
|
|
18
|
+
return JSON.parse(JSON.stringify(value), (key, nested) => dangerousKeys.has(key) ? void 0 : nested);
|
|
19
|
+
}
|
|
20
|
+
function deepestNesting(value) {
|
|
21
|
+
let deepest = 0;
|
|
22
|
+
const pending = [{
|
|
23
|
+
value,
|
|
24
|
+
depth: 1
|
|
25
|
+
}];
|
|
26
|
+
while (pending.length > 0) {
|
|
27
|
+
const { value: current, depth } = pending.pop();
|
|
28
|
+
if (typeof current !== "object" || current === null) continue;
|
|
29
|
+
deepest = Math.max(deepest, depth);
|
|
30
|
+
for (const child of Object.values(current)) pending.push({
|
|
31
|
+
value: child,
|
|
32
|
+
depth: depth + 1
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return deepest;
|
|
36
|
+
}
|
|
37
|
+
function countObjects(objects) {
|
|
38
|
+
let count = 0;
|
|
39
|
+
const pending = [...objects];
|
|
40
|
+
while (pending.length > 0) {
|
|
41
|
+
const object = pending.pop();
|
|
42
|
+
count += 1;
|
|
43
|
+
if (Array.isArray(object.objects)) pending.push(...object.objects);
|
|
44
|
+
if (object.clipPath && typeof object.clipPath === "object") pending.push(object.clipPath);
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
function unsafe(path, message) {
|
|
49
|
+
return {
|
|
50
|
+
code: "UNSAFE_DOCUMENT",
|
|
51
|
+
path,
|
|
52
|
+
message
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function secureDocument(input, limits = {}) {
|
|
56
|
+
if (typeof input !== "object" || input === null) return input;
|
|
57
|
+
const maxDepth = limits.maxDepth ?? 100;
|
|
58
|
+
const maxObjects = limits.maxObjects ?? 5e4;
|
|
59
|
+
const document = withoutDangerousKeys(input);
|
|
60
|
+
const issues = [];
|
|
61
|
+
const depth = deepestNesting(document);
|
|
62
|
+
if (depth > maxDepth) issues.push(unsafe("", `the document is nested ${depth} levels deep, the limit is ${maxDepth}`));
|
|
63
|
+
if (Array.isArray(document.objects) && issues.length === 0) {
|
|
64
|
+
const count = countObjects(document.objects.filter((object) => typeof object === "object" && object !== null));
|
|
65
|
+
if (count > maxObjects) issues.push(unsafe("objects", `the document has ${count} objects, the limit is ${maxObjects}`));
|
|
66
|
+
}
|
|
67
|
+
refuseWhenUnsafe(issues);
|
|
68
|
+
return document;
|
|
69
|
+
}
|
|
70
|
+
function refuseWhenUnsafe(issues) {
|
|
71
|
+
if (issues.length === 0) return;
|
|
72
|
+
throw new require_errors.DocumentEngineError("UNSAFE_DOCUMENT", `The document was refused: ${issues.map((issue) => issue.message).join("; ")}`, { issues });
|
|
73
|
+
}
|
|
74
|
+
function refuseUnsafeImageUrls(objects, isAllowedUrl = isSafeImageUrl) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
for (const reference of require_asset_references.findImageReferences(objects)) {
|
|
77
|
+
if (isAllowedUrl(reference.url)) continue;
|
|
78
|
+
const where = reference.objectId ? `object ${reference.objectId}` : "an object";
|
|
79
|
+
issues.push(unsafe("objects", `${where} uses the image address "${reference.url.slice(0, 60)}", which is not allowed`));
|
|
80
|
+
}
|
|
81
|
+
refuseWhenUnsafe(issues);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
exports.isSafeImageUrl = isSafeImageUrl;
|
|
85
|
+
exports.refuseUnsafeImageUrls = refuseUnsafeImageUrls;
|
|
86
|
+
exports.secureDocument = secureDocument;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SerializedFabricObject } from "../document/document-format.cjs";
|
|
2
|
+
//#region src/security/content-limits.d.ts
|
|
3
|
+
export interface ContentLimits {
|
|
4
|
+
maxObjects?: number;
|
|
5
|
+
maxDepth?: number;
|
|
6
|
+
isAllowedUrl?: (url: string) => boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function isSafeImageUrl(url: string): boolean;
|
|
9
|
+
export declare function secureDocument(input: unknown, limits?: ContentLimits): unknown;
|
|
10
|
+
export declare function refuseUnsafeImageUrls(objects: readonly SerializedFabricObject[], isAllowedUrl?: (url: string) => boolean): void;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SerializedFabricObject } from "../document/document-format.js";
|
|
2
|
+
//#region src/security/content-limits.d.ts
|
|
3
|
+
export interface ContentLimits {
|
|
4
|
+
maxObjects?: number;
|
|
5
|
+
maxDepth?: number;
|
|
6
|
+
isAllowedUrl?: (url: string) => boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function isSafeImageUrl(url: string): boolean;
|
|
9
|
+
export declare function secureDocument(input: unknown, limits?: ContentLimits): unknown;
|
|
10
|
+
export declare function refuseUnsafeImageUrls(objects: readonly SerializedFabricObject[], isAllowedUrl?: (url: string) => boolean): void;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { findImageReferences } from "../assets/asset-references.js";
|
|
2
|
+
import { DocumentEngineError } from "../engine/errors.js";
|
|
3
|
+
//#region src/security/content-limits.ts
|
|
4
|
+
const dangerousKeys = /* @__PURE__ */ new Set([
|
|
5
|
+
"__proto__",
|
|
6
|
+
"constructor",
|
|
7
|
+
"prototype"
|
|
8
|
+
]);
|
|
9
|
+
function isSafeImageUrl(url) {
|
|
10
|
+
const trimmed = url.trim().toLowerCase();
|
|
11
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(trimmed)?.[1];
|
|
12
|
+
if (scheme === void 0) return true;
|
|
13
|
+
if (scheme === "http" || scheme === "https" || scheme === "blob") return true;
|
|
14
|
+
if (scheme === "data") return trimmed.startsWith("data:image/");
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function withoutDangerousKeys(value) {
|
|
18
|
+
return JSON.parse(JSON.stringify(value), (key, nested) => dangerousKeys.has(key) ? void 0 : nested);
|
|
19
|
+
}
|
|
20
|
+
function deepestNesting(value) {
|
|
21
|
+
let deepest = 0;
|
|
22
|
+
const pending = [{
|
|
23
|
+
value,
|
|
24
|
+
depth: 1
|
|
25
|
+
}];
|
|
26
|
+
while (pending.length > 0) {
|
|
27
|
+
const { value: current, depth } = pending.pop();
|
|
28
|
+
if (typeof current !== "object" || current === null) continue;
|
|
29
|
+
deepest = Math.max(deepest, depth);
|
|
30
|
+
for (const child of Object.values(current)) pending.push({
|
|
31
|
+
value: child,
|
|
32
|
+
depth: depth + 1
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return deepest;
|
|
36
|
+
}
|
|
37
|
+
function countObjects(objects) {
|
|
38
|
+
let count = 0;
|
|
39
|
+
const pending = [...objects];
|
|
40
|
+
while (pending.length > 0) {
|
|
41
|
+
const object = pending.pop();
|
|
42
|
+
count += 1;
|
|
43
|
+
if (Array.isArray(object.objects)) pending.push(...object.objects);
|
|
44
|
+
if (object.clipPath && typeof object.clipPath === "object") pending.push(object.clipPath);
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
function unsafe(path, message) {
|
|
49
|
+
return {
|
|
50
|
+
code: "UNSAFE_DOCUMENT",
|
|
51
|
+
path,
|
|
52
|
+
message
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function secureDocument(input, limits = {}) {
|
|
56
|
+
if (typeof input !== "object" || input === null) return input;
|
|
57
|
+
const maxDepth = limits.maxDepth ?? 100;
|
|
58
|
+
const maxObjects = limits.maxObjects ?? 5e4;
|
|
59
|
+
const document = withoutDangerousKeys(input);
|
|
60
|
+
const issues = [];
|
|
61
|
+
const depth = deepestNesting(document);
|
|
62
|
+
if (depth > maxDepth) issues.push(unsafe("", `the document is nested ${depth} levels deep, the limit is ${maxDepth}`));
|
|
63
|
+
if (Array.isArray(document.objects) && issues.length === 0) {
|
|
64
|
+
const count = countObjects(document.objects.filter((object) => typeof object === "object" && object !== null));
|
|
65
|
+
if (count > maxObjects) issues.push(unsafe("objects", `the document has ${count} objects, the limit is ${maxObjects}`));
|
|
66
|
+
}
|
|
67
|
+
refuseWhenUnsafe(issues);
|
|
68
|
+
return document;
|
|
69
|
+
}
|
|
70
|
+
function refuseWhenUnsafe(issues) {
|
|
71
|
+
if (issues.length === 0) return;
|
|
72
|
+
throw new DocumentEngineError("UNSAFE_DOCUMENT", `The document was refused: ${issues.map((issue) => issue.message).join("; ")}`, { issues });
|
|
73
|
+
}
|
|
74
|
+
function refuseUnsafeImageUrls(objects, isAllowedUrl = isSafeImageUrl) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
for (const reference of findImageReferences(objects)) {
|
|
77
|
+
if (isAllowedUrl(reference.url)) continue;
|
|
78
|
+
const where = reference.objectId ? `object ${reference.objectId}` : "an object";
|
|
79
|
+
issues.push(unsafe("objects", `${where} uses the image address "${reference.url.slice(0, 60)}", which is not allowed`));
|
|
80
|
+
}
|
|
81
|
+
refuseWhenUnsafe(issues);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { isSafeImageUrl, refuseUnsafeImageUrls, secureDocument };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fabricjs-document-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Turn an existing Fabric.js canvas into a dependable editable document: stable object ids, versioned save and load, undo and redo, safe autosave with conflict protection, custom objects and clear errors.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fabric",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tools.json",
|
|
85
85
|
"test": "vitest run",
|
|
86
86
|
"test:unit": "vitest run --project unit",
|
|
87
|
-
"test:browser": "vitest run --project browser-fabric7 --project browser-fabric6",
|
|
87
|
+
"test:browser": "vitest run --project browser-fabric7 --project browser-fabric6 --project browser-firefox --project browser-webkit",
|
|
88
88
|
"test:watch": "vitest",
|
|
89
89
|
"lint:publint": "publint --strict",
|
|
90
90
|
"lint:attw": "attw --pack . --profile node16",
|