Compare commits

..
12 Commits
Author SHA1 Message Date
alam00000 9aae81e73c Release v2.8.10 2026-10-19 15:33:11 +05:30
Alam 2f868d4d0e Merge pull request #932 from alam00000/chore/update-pdf-engine
build(deps): bump PDF engine and viewer to 051abe5b72a9
2026-10-19 15:25:18 +05:30
alam00000 2be3d6858c build(deps): bump PDF engine and viewer to 051abe5b72a9 2026-10-19 09:46:51 +00:00
alam00000 edd959f345 fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI 2026-10-19 14:45:00 +05:30
alam00000 b0a186839a fix: change CLA signature storage branch to 'cla-signatures' 2026-10-19 13:20:31 +05:30
alam00000 70dc47d25c feat: update GitHub Actions workflow for PDF engine and viewer 2026-10-19 13:17:07 +05:30
alam00000 231c012e99 fix(ci): avoid rate limits 2026-10-19 02:07:51 +05:30
Alam 6a75673cbe Merge pull request #927 from alam00000/dependabot/npm_and_yarn/source-map-js-1.2.2
build(deps): bump source-map-js from 1.2.1 to 1.2.2
2026-10-19 02:03:10 +05:30
Alam 66f3628811 Merge pull request #926 from alam00000/dependabot/npm_and_yarn/handlebars-4.7.10
build(deps-dev): bump handlebars from 4.7.9 to 4.7.10
2026-10-19 02:02:59 +05:30
alam00000 4c314b8a13 Update service worker cache version 2026-10-19 01:07:55 +05:30
dependabot[bot] 733c4f0bf9 build(deps): bump source-map-js from 1.2.1 to 1.2.2
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-17 21:02:32 +00:00
dependabot[bot] dde07a28bd build(deps-dev): bump handlebars from 4.7.9 to 4.7.10
Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.9 to 4.7.10.
- [Release notes](https://github.com/handlebars-lang/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.10/release-notes.md)
- [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.9...v4.7.10)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-version: 4.7.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-17 12:24:15 +00:00
83 changed files with 5672 additions and 1032 deletions
+14 -16
View File
@@ -45,7 +45,7 @@ jobs:
run: npm run package
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
files: |
dist-${{ steps.version.outputs.version }}.zip
@@ -77,10 +77,10 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -103,7 +103,7 @@ jobs:
- name: Build and push amd64 ${{ matrix.mode.name }} (release)
if: steps.version.outputs.is_release == 'true'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
build-args: |
@@ -111,12 +111,11 @@ jobs:
tags: |
ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64
platforms: linux/amd64
cache-from: type=gha,scope=amd64-${{ matrix.mode.name }}
cache-to: type=gha,mode=max,scope=amd64-${{ matrix.mode.name }}
no-cache: true
- name: Build and push amd64 ${{ matrix.mode.name }} (edge)
if: steps.version.outputs.is_release == 'false'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
build-args: |
@@ -146,10 +145,10 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -172,7 +171,7 @@ jobs:
- name: Build and push arm64 ${{ matrix.mode.name }} (release)
if: steps.version.outputs.is_release == 'true'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
build-args: |
@@ -180,12 +179,11 @@ jobs:
tags: |
ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64
platforms: linux/arm64
cache-from: type=gha,scope=arm64-${{ matrix.mode.name }}
cache-to: type=gha,mode=max,scope=arm64-${{ matrix.mode.name }}
no-cache: true
- name: Build and push arm64 ${{ matrix.mode.name }} (edge)
if: steps.version.outputs.is_release == 'false'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: true
build-args: |
@@ -211,7 +209,7 @@ jobs:
suffix: "-simple"
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -275,14 +273,14 @@ jobs:
suffix: "-simple"
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
+2 -4
View File
@@ -11,7 +11,6 @@ permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla-check:
@@ -28,16 +27,15 @@ jobs:
)
steps:
- name: "CLA Assistant"
uses: contributor-assistant/[email protected]
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }}
with:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md'
# Branch to store signatures
branch: 'main'
branch: 'cla-signatures'
# Allowlist for bot accounts (won't need to sign CLA)
allowlist: 'bot*,*[bot],dependabot*,renovate*,github-actions*'
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: JamesIves/github-sponsors-readme-action@v1
- uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: 'README.md'
+8 -8
View File
@@ -28,10 +28,10 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image for scanning
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ${{ matrix.image.file }}
@@ -41,7 +41,7 @@ jobs:
no-cache: true
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ matrix.image.name }}:scan
format: sarif
@@ -51,7 +51,7 @@ jobs:
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
if: always() && hashFiles('trivy-*.sarif') != ''
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}
@@ -63,7 +63,7 @@ jobs:
uses: actions/checkout@v4
- name: Scan npm dependencies with Trivy
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
@@ -74,7 +74,7 @@ jobs:
- name: Upload dependency scan results
uses: github/codeql-action/upload-sarif@v4
if: always()
if: always() && hashFiles('trivy-deps.sarif') != ''
with:
sarif_file: trivy-deps.sarif
category: trivy-dependencies
@@ -86,7 +86,7 @@ jobs:
uses: actions/checkout@v4
- name: Scan Dockerfiles for misconfigurations
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: config
scan-ref: .
@@ -96,7 +96,7 @@ jobs:
- name: Upload config scan results
uses: github/codeql-action/upload-sarif@v4
if: always()
if: always() && hashFiles('trivy-config.sarif') != ''
with:
sarif_file: trivy-config.sarif
category: trivy-config
+90 -18
View File
@@ -6,8 +6,7 @@ on:
- cron: '17 4 * * *'
permissions:
contents: write
pull-requests: write
contents: read
concurrency:
group: update-engine
@@ -18,12 +17,19 @@ env:
HUSKY: 0
jobs:
update:
build:
runs-on: ubuntu-latest
if: github.repository == 'alam00000/bentopdf'
permissions:
contents: read
outputs:
run: ${{ steps.gate.outputs.run }}
version: ${{ steps.gate.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Read current upstream marker
id: current
@@ -40,13 +46,21 @@ jobs:
- name: Should update?
id: gate
env:
CURRENT: ${{ steps.current.outputs.version }}
UPSTREAM: ${{ steps.upstream.outputs.version }}
run: |
if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then
if ! [[ "$UPSTREAM" =~ ^[0-9a-f]{12}$ ]]; then
echo "::error::Unexpected fork HEAD: $UPSTREAM"
exit 1
fi
echo "version=$UPSTREAM" >> "$GITHUB_OUTPUT"
if [ "$UPSTREAM" = "$CURRENT" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "Already on ${{ steps.current.outputs.version }}."
echo "Already on $CURRENT."
else
echo "run=true" >> "$GITHUB_OUTPUT"
echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}"
echo "Updating $CURRENT -> $UPSTREAM"
fi
- name: Setup Node
@@ -73,9 +87,10 @@ jobs:
- name: Pack the engine
if: steps.gate.outputs.run == 'true'
id: engine
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
set -euo pipefail
SHA=${{ steps.upstream.outputs.version }}
VENDOR=./_fork/packages/pdfium/src/vendor
STAGE=$(mktemp -d)/package
mkdir -p "$STAGE"
@@ -107,9 +122,10 @@ jobs:
- name: Pack the viewer without a second engine
if: steps.gate.outputs.run == 'true'
id: viewer
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
set -euo pipefail
SHA=${{ steps.upstream.outputs.version }}
WORK=$(mktemp -d)
(cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null)
TMP=$(mktemp -d)
@@ -137,14 +153,17 @@ jobs:
- name: Point dependencies at the new tarballs
if: steps.gate.outputs.run == 'true'
env:
ENGINE_TARBALL: ${{ steps.engine.outputs.tarball }}
VIEWER_TARBALL: ${{ steps.viewer.outputs.tarball }}
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1];
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2];
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.env.ENGINE_TARBALL;
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.env.VIEWER_TARBALL;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
" "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}"
"
- name: Cleanup upstream clone
if: steps.gate.outputs.run == 'true'
@@ -174,19 +193,66 @@ jobs:
- name: Write upstream marker
if: steps.gate.outputs.run == 'true'
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version
echo "$SHA" > vendor/bentopdf-viewer/.upstream-version
echo "$SHA" > vendor/bentopdf-pdfium/.upstream-version
- name: Upload update
if: steps.gate.outputs.run == 'true'
uses: actions/upload-artifact@v4
with:
name: engine-update
include-hidden-files: true
retention-days: 1
path: |
package.json
package-lock.json
vendor/bentopdf-pdfium/
vendor/bentopdf-viewer/
pull-request:
runs-on: ubuntu-latest
needs: build
if: needs.build.outputs.run == 'true'
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Download update
uses: actions/download-artifact@v4
with:
name: engine-update
path: ${{ runner.temp }}/engine-update
- name: Apply update
id: apply
env:
UPDATE: ${{ runner.temp }}/engine-update
run: |
set -euo pipefail
rm -rf vendor/bentopdf-pdfium vendor/bentopdf-viewer
mkdir -p vendor
cp -R "$UPDATE/vendor/bentopdf-pdfium" "$UPDATE/vendor/bentopdf-viewer" vendor/
cp "$UPDATE/package.json" "$UPDATE/package-lock.json" .
{
echo 'checksums<<EOF'
sha256sum vendor/bentopdf-pdfium/*.tgz vendor/bentopdf-viewer/*.tgz
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create Pull Request
if: steps.gate.outputs.run == 'true'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
with:
commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
commit-message: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
title: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
body: |
Both vendored packages built from one clone of the viewer fork at
`${{ steps.upstream.outputs.version }}`, so the text editor and the
`${{ needs.build.outputs.version }}`, so the text editor and the
PDF editor run the same engine.
- `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor`
@@ -194,5 +260,11 @@ jobs:
The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`.
CI verified a single wasm on disk, typecheck, and the test suite.
Built in a read-only job. Tarball checksums:
```
${{ steps.apply.outputs.checksums }}
```
branch: chore/update-pdf-engine
delete-branch: true
+1 -1
View File
@@ -12,7 +12,7 @@ Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/ https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
</IfModule>
# Block source maps from being served
+1 -1
View File
@@ -3,7 +3,7 @@
ARG BASE_URL=
# Build stage
FROM public.ecr.aws/docker/library/node:20-alpine AS builder
FROM mirror.gcr.io/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY vendor ./vendor
+1 -1
View File
@@ -5,7 +5,7 @@
ARG BASE_URL=
# Build stage (identical to main Dockerfile)
FROM public.ecr.aws/docker/library/node:20-alpine AS builder
FROM mirror.gcr.io/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY vendor ./vendor
+11
View File
@@ -62,6 +62,17 @@ When the build completes, you can find the website at `https://[your-github-user
If/when you merge changes from the source BentoPDF repository, the build and deploy action will automatically be kicked off and the new version will be automatically deployed to GitHub Pages.
> **Note:** GitHub Pages cannot set custom response headers, so a GitHub Pages deployment is served without BentoPDF's Content-Security-Policy and other security headers. If you need them, use one of the other hosts below or put a proxy or CDN that can add headers in front of your Pages site.
## Security headers
BentoPDF ships a Content-Security-Policy and other security headers for each static host:
- **Netlify and Cloudflare Pages** read `_headers` from the deployed folder. The build copies `public/_headers` into `dist/_headers`, and `scripts/generate-security-headers.mjs` regenerates both during `npm run build`, so any `VITE_WASM_*`, `VITE_TESSERACT_*`, `VITE_CORS_PROXY_URL` or `VITE_OCR_FONT_BASE_URL` values you set at build time are allowed by the policy. The release `dist-{version}.zip` already contains it.
- **Vercel** uses the `headers` block in `vercel.json`. It holds the default policy, so if you point the `VITE_*` URLs at your own servers, update the Content-Security-Policy there to match `public/_headers`.
- **`npx serve`** reads `serve.json` from the folder it serves. To use the repository's `serve.json` with the build output, run `npx serve dist -c ../serve.json`.
- **GitHub Pages** cannot set headers (see the note above).
## SEO and search engine indexing
By default, every self-hosted BentoPDF build emits canonical URLs and an
+2 -2
View File
@@ -7,10 +7,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.8
version: 1.0.9
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: '2.8.9'
appVersion: '2.8.10'
+1 -1
View File
@@ -83,7 +83,7 @@ npx wrangler deploy -c wasm-wrangler.toml
## Security Features
- **Origin validation**: Only allows requests from configured origins
- **Rate limiting**: 100 requests/minute per IP (requires KV namespace)
- **Rate limiting**: 100 requests/minute per IP, per /64 for IPv6 (requires KV namespace; fails open on KV errors)
- **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.)
- **Size limits**: Max 100MB per file
- **Caching**: Reduces origin requests and improves performance
+166 -76
View File
@@ -81,53 +81,134 @@ function isAllowedOrigin(origin) {
return ALLOWED_ORIGINS.includes(origin);
}
function parseIPv4(address) {
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
if (!match) return null;
const octets = match.slice(1).map(Number);
if (octets.some((octet) => octet > 255)) return null;
return octets.reduce((value, octet) => value * 256 + octet, 0);
}
function parseIPv6(address) {
let text = address.toLowerCase();
const embedded = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
if (embedded) {
const ipv4 = parseIPv4(embedded[2]);
if (ipv4 === null) return null;
text = `${embedded[1]}${Math.floor(ipv4 / 65536).toString(16)}:${(ipv4 % 65536).toString(16)}`;
}
const sections = text.split('::');
if (sections.length > 2) return null;
const head = sections[0] ? sections[0].split(':') : [];
const tail =
sections.length === 2 && sections[1] ? sections[1].split(':') : [];
const missing = 8 - head.length - tail.length;
if (sections.length === 1 ? missing !== 0 : missing < 1) return null;
const groups = [...head, ...Array(missing).fill('0'), ...tail];
if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null;
return groups.map((group) => parseInt(group, 16));
}
const BLOCKED_IPV4_RANGES = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.2.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
].map((cidr) => {
const [address, prefixLength] = cidr.split('/');
return { base: parseIPv4(address), size: 2 ** (32 - Number(prefixLength)) };
});
const BLOCKED_IPV6_RANGES = [
'::/128',
'::1/128',
'::/96',
'::ffff:0:0/96',
'64:ff9b::/96',
'64:ff9b:1::/48',
'100::/64',
'2001::/32',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'fec0::/10',
'ff00::/8',
].map((cidr) => {
const [address, prefixLength] = cidr.split('/');
return { groups: parseIPv6(address), prefixLength: Number(prefixLength) };
});
function isBlockedIPv4(value) {
return BLOCKED_IPV4_RANGES.some(
({ base, size }) => value >= base && value < base + size
);
}
function isBlockedIPv6(groups) {
return BLOCKED_IPV6_RANGES.some((range) =>
range.groups.every((group, index) => {
const bitsInGroup = Math.min(
16,
Math.max(0, range.prefixLength - index * 16)
);
const groupMask = (0xffff << (16 - bitsInGroup)) & 0xffff;
return (groups[index] & groupMask) === (group & groupMask);
})
);
}
function isBlockedIp(address) {
const ipv4 = parseIPv4(address);
if (ipv4 !== null) return isBlockedIPv4(ipv4);
const ipv6Groups = parseIPv6(address);
if (ipv6Groups !== null) return isBlockedIPv6(ipv6Groups);
return true;
}
function isIpLiteral(host) {
return /^[\d.]+$/.test(host) || host.includes(':');
}
function getRateLimitKey(ip) {
const ipv6Groups = ip.includes(':') ? parseIPv6(ip) : null;
if (!ipv6Groups) return ip;
return `${ipv6Groups
.slice(0, 4)
.map((group) => group.toString(16))
.join(':')}::/64`;
}
function isPrivateOrReservedHost(hostname) {
if (
/^10\./.test(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
/^127\./.test(hostname) ||
/^0\./.test(hostname)
) {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (['localhost', 'localhost.localdomain'].includes(host)) {
return true;
}
if (/^\d+$/.test(hostname)) {
return true;
}
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
lower === '::1' ||
lower.startsWith('::ffff:') ||
lower.startsWith('fe80') ||
lower.startsWith('fc') ||
lower.startsWith('fd') ||
lower.startsWith('ff')
) {
return true;
}
const blockedNames = [
'localhost',
'localhost.localdomain',
'0.0.0.0',
'[::1]',
];
if (blockedNames.includes(hostname.toLowerCase())) {
return true;
}
return false;
return isIpLiteral(host) && isBlockedIp(host);
}
async function hostnameResolvesToPrivate(hostname) {
const clean = hostname.replace(/^\[|\]$/g, '');
if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) {
return isPrivateOrReservedHost(clean);
}
const clean = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (isPrivateOrReservedHost(clean)) return true;
if (isIpLiteral(clean)) return false;
const ips = [];
for (const type of ['A', 'AAAA']) {
try {
@@ -138,14 +219,16 @@ async function hostnameResolvesToPrivate(hostname) {
if (!res.ok) return true;
const data = await res.json();
for (const ans of data.Answer || []) {
if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, ''));
if (ans && (ans.type === 1 || ans.type === 28) && ans.data) {
ips.push(String(ans.data));
}
}
} catch {
return true;
}
}
if (ips.length === 0) return true;
return ips.some((ip) => isPrivateOrReservedHost(ip));
return ips.some((ip) => isBlockedIp(ip));
}
function isValidCertificateUrl(urlString) {
@@ -372,49 +455,56 @@ export default {
}
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `ratelimit:${clientIP}`;
const rateLimitKey = `ratelimit:${getRateLimitKey(clientIP)}`;
const now = Date.now();
if (env.RATE_LIMIT_KV) {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
try {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
),
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil(
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
).toString(),
},
),
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
).toString(),
},
}
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
} catch (error) {
console.error(
'[CORS Proxy] Rate limit KV error, allowing request:',
error
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
} else {
console.warn(
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
+97 -36
View File
@@ -39,9 +39,38 @@ const ALLOWED_EXTENSIONS = [
'.mem',
'.asm.js',
'.worker.js',
'.html',
'.whl',
];
const SAFE_SUBPATH = /^\/[A-Za-z0-9._/-]*$/;
function isSafeSubpath(subpath) {
return (
SAFE_SUBPATH.test(subpath) &&
!subpath.split('/').some((segment) => segment === '.' || segment === '..')
);
}
function getRateLimitKey(ip) {
if (!ip.includes(':') || ip.includes('.')) return ip;
const sections = ip.toLowerCase().split('::');
if (sections.length > 2) return ip;
const leadingGroups = sections[0] ? sections[0].split(':') : [];
const trailingGroups = sections[1] ? sections[1].split(':') : [];
const missing = 8 - leadingGroups.length - trailingGroups.length;
if (sections.length === 1 ? missing !== 0 : missing < 1) return ip;
const groups = [
...leadingGroups,
...Array(missing).fill('0'),
...trailingGroups,
];
if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return ip;
return `${groups
.slice(0, 4)
.map((group) => parseInt(group, 16).toString(16))
.join(':')}::/64`;
}
function isAllowedOrigin(origin) {
if (!origin) return true; // Allow no-origin requests (e.g., direct browser navigation)
return ALLOWED_ORIGINS.some((allowed) =>
@@ -66,6 +95,7 @@ function corsHeaders(origin) {
'Access-Control-Expose-Headers':
'Content-Length, Content-Range, Content-Type',
'Access-Control-Max-Age': '86400',
'X-Content-Type-Options': 'nosniff',
};
}
@@ -89,7 +119,6 @@ function getContentType(pathname) {
'.so': 'application/octet-stream',
'.zip': 'application/zip',
'.mem': 'application/octet-stream',
'.html': 'text/html',
};
return contentTypes[ext] || 'application/octet-stream';
}
@@ -115,7 +144,34 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
? sourceBaseUrl.slice(0, -1)
: sourceBaseUrl;
const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`;
const targetUrl = `${normalizedBase}${normalizedPath}`;
let targetUrl = null;
if (isSafeSubpath(normalizedPath)) {
try {
const candidate = new URL(`${normalizedBase}${normalizedPath}`).href;
if (candidate.startsWith(new URL(`${normalizedBase}/`).href)) {
targetUrl = candidate;
}
} catch {
targetUrl = null;
}
}
if (!targetUrl) {
return new Response(
JSON.stringify({
error: 'Invalid path',
message: 'The requested path is not allowed.',
}),
{
status: 400,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
if (!isAllowedFile(normalizedPath)) {
return new Response(
@@ -151,8 +207,6 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
JSON.stringify({
error: 'Failed to fetch resource',
status: response.status,
statusText: response.statusText,
targetUrl: targetUrl,
}),
{
status: response.status,
@@ -204,14 +258,14 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
'Content-Type': getContentType(normalizedPath),
'Content-Length': bodyData.byteLength.toString(),
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`,
'X-Proxied-From': new URL(targetUrl).hostname,
},
});
} catch (error) {
console.error('[WASM Proxy] Fetch error:', error);
return new Response(
JSON.stringify({
error: 'Proxy error',
message: error.message,
message: 'Failed to fetch the requested resource',
}),
{
status: 500,
@@ -260,42 +314,49 @@ export default {
if (env.RATE_LIMIT_KV) {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `wasm-ratelimit:${clientIP}`;
const rateLimitKey = `wasm-ratelimit:${getRateLimitKey(clientIP)}`;
const now = Date.now();
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
try {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`,
}),
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`,
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': '60',
},
}
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': '60',
},
expirationTtl: 120,
}
);
} catch (error) {
console.error(
'[WASM Proxy] Rate limit KV error, allowing request:',
error
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
}
if (pathname.startsWith('/pymupdf/')) {
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
description: "Documentation for BentoPDF - The free, open-source, privacy-first PDF toolkit",
base: '/docs/',
cleanUrls: true,
metaChunk: true,
transformPageData(pageData) {
const relPath = pageData.relativePath.replace(/\.md$/, '')
+1 -1
View File
@@ -123,7 +123,7 @@ The included Cloudflare Worker has several security measures:
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
| **Rate Limiting** | 60 requests per minute per IP or IPv6 /64 (requires KV, fails open on KV errors) |
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
### DNS rebinding (self-hosting off Cloudflare)
+4 -3
View File
@@ -126,10 +126,11 @@ The nginx image ships with an enforcing `Content-Security-Policy` header. The CS
As a result:
- If you override `VITE_CORS_PROXY_URL` or `VITE_WASM_*_URL` at build time, the CSP permits those origins automatically — no extra config needed.
- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg.
- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get the public `cdn.jsdelivr.net` removed from CSP (each default is replaced, not appended). Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default.
- For URLs on `cdn.jsdelivr.net`, `script-src` allows only the exact versioned package folder, for example `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/`, not the whole CDN. jsDelivr serves any public npm package or GitHub repository, so allowing the whole host would let any of them run on your instance. URLs on any other host are allowed by origin.
- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. This also applies to a different version of the same package on `cdn.jsdelivr.net`, because only the version set at build time is allowed. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg.
- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get those `cdn.jsdelivr.net` package folders removed from CSP (each default is replaced, not appended). The Tesseract OCR defaults on `cdn.jsdelivr.net` stay in the policy until you also set the three `VITE_TESSERACT_*` values. Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default.
The CSP includes `'unsafe-eval'` in `script-src` because the LibreOffice WASM runtime (used by Word/Excel/PowerPoint conversion tools) compiles internal dispatch code via `new Function()`. Removing it would break all LibreOffice-backed tools. If you build in `SIMPLE_MODE` (without LibreOffice), you can manually edit the generated `security-headers.conf` to drop `'unsafe-eval'` for a stricter policy.
The CSP includes `'unsafe-eval'` in `script-src` because the LibreOffice WASM runtime (used by Word/Excel/PowerPoint conversion tools) and the wasm-vips image library (used by PDF to TIFF) compile internal dispatch code via `new Function()`. Removing it would break those tools. If you build in `SIMPLE_MODE` (without LibreOffice) and do not need PDF to TIFF, you can manually edit the generated `security-headers.conf` to drop `'unsafe-eval'` for a stricter policy.
For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs.
+2 -1
View File
@@ -18,7 +18,7 @@ BentoPDF's PDF Editor lets you annotate, highlight, redact, comment, add shapes
- Full annotation toolbar with highlighting, underlining, strikethrough, and freehand drawing
- Text insertion and commenting
- Redaction tools for permanently removing sensitive content
- Redaction tools that permanently remove marked content once you apply the redaction. If any marks haven't been applied when you click **Download Edited PDF**, the editor offers to apply them first.
- Shape tools for rectangles, circles, lines, and arrows
- Image insertion onto PDF pages
- Built-in text search across the document
@@ -37,6 +37,7 @@ BentoPDF's PDF Editor lets you annotate, highlight, redact, comment, add shapes
- You can open multiple PDFs at once and switch between them using the tab bar at the top of the editor.
- The editor preserves the original PDF structure, so fonts, layouts, and form fields remain intact.
- Marking text or an area for redaction doesn't remove anything until the redaction is applied. When you redact selected text, copies of it in the file's properties, bookmarks, comments, and attachments are also removed from the download, unless the file is encrypted.
- For bulk watermarking or page numbering, use the dedicated tools instead for more control over formatting.
## Related Tools
+1 -1
View File
@@ -46,7 +46,7 @@ http {
if ($request_uri ~ ^/en/?(\?.*)?$) {
return 301 /$1;
}
if ($request_uri ~ ^/en/(.+)$) {
if ($request_uri ~ ^/en/([A-Za-z0-9].*)$) {
return 301 /$1;
}
+16 -16
View File
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
"version": "2.8.8",
"version": "2.8.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bento-pdf",
"version": "2.8.8",
"version": "2.8.9",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7",
@@ -27,8 +27,8 @@
"@types/node-forge": "^1.3.14",
"@types/papaparse": "^5.5.2",
"archiver": "^7.0.1",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.tgz",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-051abe5b72a9.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
"blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.2",
@@ -4272,15 +4272,15 @@
"license": "MIT"
},
"node_modules/bentopdf-pdfium": {
"version": "0.0.0-813903c93c75",
"resolved": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
"integrity": "sha512-Qse8wclamZdqLx7IbYpkFLsNcCFpj2+iJBbaiNnWU7136ZZwwq5lw4HWYGBOmQL6VQ4q8hJ1ID9XdSxBGvYCwQ==",
"version": "0.0.0-051abe5b72a9",
"resolved": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-051abe5b72a9.tgz",
"integrity": "sha512-ctM0Y6MEVa2UQO8+uD7/GYzl7GWyrEJJxzUJURHJttJKzxn02DtNpyEWgcLREXnfaFIo7UJ4j5sFmioVGi/WpQ==",
"license": "AGPL-3.0"
},
"node_modules/bentopdf-viewer": {
"version": "2.9.1",
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.tgz",
"integrity": "sha512-mlWp2VxU2Xq53K6pnvCQAv4PvCsVq10+vAMJaoeFvr0AYjpHT347UzHHXUS9bEZHm5XcinJFIvuePcDA1TSvJw==",
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
"integrity": "sha512-A+utjVLgFdE/MA2mlPLimi7jXdhMXex6RozdcJbt0YgNj4tt1W3ihk9QYbVi3dUHm2zfwGPntE+CSn1d8OGoZw==",
"license": "MIT",
"dependencies": {
"preact": "^10.17.0",
@@ -6744,13 +6744,13 @@
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"version": "4.7.10",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
"integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"minimist": "^1.2.8",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
@@ -10420,9 +10420,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "bento-pdf",
"private": true,
"version": "2.8.9",
"version": "2.8.10",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -91,8 +91,8 @@
"@types/node-forge": "^1.3.14",
"@types/papaparse": "^5.5.2",
"archiver": "^7.0.1",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.tgz",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-051abe5b72a9.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
"blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.2",
+11 -5
View File
@@ -313,11 +313,17 @@
Use the redaction tool in the
<a href="/edit-pdf" class="text-indigo-300 hover:text-indigo-400"
>PDF Editor</a
>, which permanently removes the marked content from the page
instead of just covering it with a box. Redaction only deals with
what's on the page, so if the file's properties, comments, or
attachments could reveal the same information, run Remove Metadata
or Sanitize PDF on the result as well.
>. Marking text or an area only flags it. Once you apply the
redaction, the marked content is permanently removed from the page
instead of just being covered with a box, and if you download with
marks that haven't been applied yet, the editor offers to apply
them first. When you redact selected text, copies of it in the
file's properties, bookmarks, comments, and attachments are
removed on download too, unless the file is encrypted. Area
redactions have no text to search for, so if the file's
properties, comments, or attachments could reveal the same
information, run Remove Metadata or Sanitize PDF on the result as
well.
</p>
</details>
<details class="faq-d">
+18
View File
@@ -0,0 +1,18 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/ https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: cross-origin
/docs
! Content-Security-Policy
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'
/docs/*
! Content-Security-Policy
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'
+24 -24
View File
@@ -5,7 +5,7 @@
* Version: 1.1.0
*/
const CACHE_VERSION = 'bentopdf-v11';
const CACHE_VERSION = 'bentopdf-v13';
const CACHE_NAME = `${CACHE_VERSION}-static`;
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
@@ -126,7 +126,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
const fileName = url.pathname.split('/').pop();
try {
const cachedResponse = await findCachedFile(fileName, request.url);
const cachedResponse = await findCachedFile(request.url);
if (cachedResponse) {
// console.log('⚡ [Cache HIT] Instant load:', fileName);
return cachedResponse;
@@ -136,7 +136,11 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
if (
networkResponse &&
networkResponse.status === 200 &&
!networkResponse.redirected
) {
const clone = networkResponse.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
@@ -163,8 +167,18 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
if (localPath) {
const localUrl = `${basePath}${localPath}${fileName}`;
try {
const cachedLocal = await findCachedFile(
new URL(localUrl, location.origin).href
);
if (cachedLocal) {
return cachedLocal;
}
const fallbackResponse = await fetch(localUrl);
if (fallbackResponse && fallbackResponse.status === 200) {
if (
fallbackResponse &&
fallbackResponse.status === 200 &&
!fallbackResponse.redirected
) {
const fbClone = fallbackResponse.clone();
const fbBuffer = await fbClone.arrayBuffer();
if (fbBuffer.byteLength > 0) {
@@ -192,7 +206,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
}
}
async function findCachedFile(fileName, requestUrl) {
async function findCachedFile(requestUrl) {
const cache = await caches.open(CACHE_NAME);
const exactMatch = await cache.match(requestUrl);
@@ -204,24 +218,6 @@ async function findCachedFile(fileName, requestUrl) {
}
await cache.delete(requestUrl);
}
const requests = await cache.keys();
for (const req of requests) {
const reqUrl = new URL(req.url);
const trustedOrigin =
reqUrl.origin === location.origin || trustedCdnOrigins.has(reqUrl.origin);
if (trustedOrigin && reqUrl.pathname.split('/').pop() === fileName) {
const response = await cache.match(req);
if (response) {
const clone = response.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
return response;
}
await cache.delete(req);
}
}
}
return null;
}
@@ -247,7 +243,11 @@ async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
if (
networkResponse &&
networkResponse.status === 200 &&
!networkResponse.redirected
) {
const clone = networkResponse.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
+253 -143
View File
@@ -2,12 +2,12 @@
/**
* Translation Checker for BentoPDF
*
*
* This script compares translation files across languages and reports:
* - Missing keys (keys present in English but absent in other languages)
* - Extra keys (keys present in other languages but not in English)
* - Untranslated keys (keys with the same value as English)
*
*
* Usage:
* node scripts/check-translations.js
* node scripts/check-translations.js --verbose
@@ -25,212 +25,322 @@ const LOCALES_DIR = path.join(__dirname, '../public/locales');
const REFERENCE_LANG = 'en';
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const args = process.argv.slice(2);
const verbose = args.includes('--verbose');
const specificLang = args.find(arg => arg.startsWith('--lang='))?.split('=')[1];
const specificLang = args
.find((arg) => arg.startsWith('--lang='))
?.split('=')[1];
/**
* Flatten nested JSON object into dot notation
* { a: { b: 'value' } } => { 'a.b': 'value' }
*/
function flattenObject(obj, prefix = '') {
const flattened = {};
const flattened = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(flattened, flattenObject(value, newKey));
} else {
flattened[newKey] = value;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(flattened, flattenObject(value, newKey));
} else {
flattened[newKey] = value;
}
}
return flattened;
return flattened;
}
/**
* Load and parse a translation file
*/
function loadTranslation(lang) {
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
if (!fs.existsSync(filePath)) {
return null;
}
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error(`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`, error.message);
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error(
`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`,
error.message
);
return null;
}
}
/**
* Get all available languages
*/
function getAvailableLanguages() {
if (!fs.existsSync(LOCALES_DIR)) {
console.error(`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`);
process.exit(1);
}
if (!fs.existsSync(LOCALES_DIR)) {
console.error(
`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`
);
process.exit(1);
}
return fs.readdirSync(LOCALES_DIR)
.filter(item => {
const itemPath = path.join(LOCALES_DIR, item);
return fs.statSync(itemPath).isDirectory();
})
.filter(lang => {
// Only include if common.json exists
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
});
return fs
.readdirSync(LOCALES_DIR)
.filter((item) => {
const itemPath = path.join(LOCALES_DIR, item);
return fs.statSync(itemPath).isDirectory();
})
.filter((lang) => {
// Only include if common.json exists
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
});
}
/**
* Compare two sets of keys and report differences
*/
function compareKeys(refKeys, targetKeys, refLang, targetLang, refFlat, targetFlat) {
const missing = refKeys.filter(key => !targetKeys.includes(key));
const extra = targetKeys.filter(key => !refKeys.includes(key));
const untranslated = refKeys.filter(key =>
targetKeys.includes(key) && refFlat[key] === targetFlat[key] && typeof refFlat[key] === 'string'
);
function compareKeys(
refKeys,
targetKeys,
refLang,
targetLang,
refFlat,
targetFlat
) {
const missing = refKeys.filter((key) => !targetKeys.includes(key));
const extra = targetKeys.filter((key) => !refKeys.includes(key));
const untranslated = refKeys.filter(
(key) =>
targetKeys.includes(key) &&
refFlat[key] === targetFlat[key] &&
typeof refFlat[key] === 'string'
);
return { missing, extra, untranslated };
return { missing, extra, untranslated };
}
/**
* Print section header
*/
function printHeader(text) {
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
console.log(`${colors.cyan}${text}${colors.reset}`);
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
console.log(`${colors.cyan}${text}${colors.reset}`);
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
}
/**
* Print section
*/
function printSection(title, items, color = colors.yellow) {
if (items.length === 0) return;
if (items.length === 0) return;
console.log(`${color}${title} (${items.length}):${colors.reset}`);
items.forEach(item => {
console.log(` ${colors.dim}•${colors.reset} ${item}`);
});
console.log();
console.log(`${color}${title} (${items.length}):${colors.reset}`);
items.forEach((item) => {
console.log(` ${colors.dim}•${colors.reset} ${item}`);
});
console.log();
}
const ALLOWED_HTML_TAGS = ['br', 'strong', 'em'];
const ALLOWED_TAG = new RegExp(
`^</?(?:${ALLOWED_HTML_TAGS.join('|')})\\s*/?>`,
'i'
);
function findUnsafeHtml(value) {
const problems = [];
if (/<script/i.test(value)) problems.push('<script');
if (/javascript:/i.test(value)) problems.push('javascript:');
if (/\bon[a-z]+\s*=/i.test(value)) problems.push('on* attribute');
for (const match of value.matchAll(/<[a-z/!?]/gi)) {
const markup = value.slice(match.index);
if (!ALLOWED_TAG.test(markup)) {
problems.push(`disallowed markup "${markup.slice(0, 40)}"`);
}
}
return problems;
}
function collectStrings(value, keyPath, out) {
if (typeof value === 'string') {
out.push([keyPath, value]);
} else if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
collectStrings(child, keyPath ? `${keyPath}.${key}` : key, out);
}
}
return out;
}
function checkHtmlSafety(langs) {
const findings = [];
for (const lang of langs) {
const dir = path.join(LOCALES_DIR, lang);
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} catch (error) {
findings.push(`${lang}/${file}: invalid JSON (${error.message})`);
continue;
}
for (const [keyPath, value] of collectStrings(data, '', [])) {
for (const problem of findUnsafeHtml(value)) {
findings.push(`${lang}/${file} ${keyPath}: ${problem}`);
}
}
}
}
return findings;
}
/**
* Main function
*/
function main() {
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
const languages = getAvailableLanguages();
const languages = getAvailableLanguages();
if (languages.length === 0) {
console.error(`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`);
process.exit(1);
if (languages.length === 0) {
console.error(
`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`
);
process.exit(1);
}
// Load reference language (English)
const refTranslation = loadTranslation(REFERENCE_LANG);
if (!refTranslation) {
console.error(
`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`
);
process.exit(1);
}
const refFlat = flattenObject(refTranslation);
const refKeys = Object.keys(refFlat);
console.log(
`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`
);
console.log(
`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`
);
// Filter languages to check
const langsToCheck = specificLang
? languages.filter((lang) => lang === specificLang)
: languages.filter((lang) => lang !== REFERENCE_LANG);
if (langsToCheck.length === 0) {
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
process.exit(0);
}
let hasIssues = false;
// Check each language
for (const lang of langsToCheck) {
printHeader(`Checking: ${lang.toUpperCase()}`);
const translation = loadTranslation(lang);
if (!translation) {
hasIssues = true;
continue;
}
// Load reference language (English)
const refTranslation = loadTranslation(REFERENCE_LANG);
if (!refTranslation) {
console.error(`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`);
process.exit(1);
const targetFlat = flattenObject(translation);
const targetKeys = Object.keys(targetFlat);
const { missing, extra, untranslated } = compareKeys(
refKeys,
targetKeys,
REFERENCE_LANG,
lang,
refFlat,
targetFlat
);
// Summary
console.log(
`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`
);
// Missing keys
if (missing.length > 0) {
hasIssues = true;
printSection(`Missing Keys`, missing, colors.red);
}
const refFlat = flattenObject(refTranslation);
const refKeys = Object.keys(refFlat);
console.log(`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`);
console.log(`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`);
// Filter languages to check
const langsToCheck = specificLang
? languages.filter(lang => lang === specificLang)
: languages.filter(lang => lang !== REFERENCE_LANG);
if (langsToCheck.length === 0) {
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
process.exit(0);
// Extra keys
if (extra.length > 0) {
hasIssues = true;
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
}
let hasIssues = false;
// Check each language
for (const lang of langsToCheck) {
printHeader(`Checking: ${lang.toUpperCase()}`);
const translation = loadTranslation(lang);
if (!translation) {
hasIssues = true;
continue;
}
const targetFlat = flattenObject(translation);
const targetKeys = Object.keys(targetFlat);
const { missing, extra, untranslated } = compareKeys(
refKeys,
targetKeys,
REFERENCE_LANG,
lang,
refFlat,
targetFlat
);
// Summary
console.log(`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`);
// Missing keys
if (missing.length > 0) {
hasIssues = true;
printSection(`Missing Keys`, missing, colors.red);
}
// Extra keys
if (extra.length > 0) {
hasIssues = true;
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
}
// Untranslated keys (same as English)
if (verbose && untranslated.length > 0) {
printSection(`Possibly Untranslated (same as English)`, untranslated, colors.cyan);
} else if (untranslated.length > 0) {
console.log(`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`);
console.log(`${colors.dim} (use --verbose to see details)${colors.reset}\n`);
}
// All good
if (missing.length === 0 && extra.length === 0) {
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
}
// Untranslated keys (same as English)
if (verbose && untranslated.length > 0) {
printSection(
`Possibly Untranslated (same as English)`,
untranslated,
colors.cyan
);
} else if (untranslated.length > 0) {
console.log(
`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`
);
console.log(
`${colors.dim} (use --verbose to see details)${colors.reset}\n`
);
}
// Final summary
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
if (!hasIssues) {
console.log(`${colors.green} All translations are in sync!${colors.reset}\n`);
process.exit(0);
} else {
console.log(`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`);
process.exit(1);
// All good
if (missing.length === 0 && extra.length === 0) {
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
}
}
const htmlFindings = checkHtmlSafety(
specificLang ? languages.filter((lang) => lang === specificLang) : languages
);
if (htmlFindings.length > 0) {
hasIssues = true;
printHeader('Unsafe HTML in translations');
printSection(
`Allowed tags: ${ALLOWED_HTML_TAGS.join(', ')} (no attributes)`,
htmlFindings,
colors.red
);
} else {
console.log(
`${colors.green} No unsafe HTML in translations (allowed tags: ${ALLOWED_HTML_TAGS.join(', ')})${colors.reset}\n`
);
}
// Final summary
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
if (!hasIssues) {
console.log(
`${colors.green} All translations are in sync!${colors.reset}\n`
);
process.exit(0);
} else {
console.log(
`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`
);
process.exit(1);
}
}
main();
+14 -21
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -62,7 +63,7 @@ function fixApplicationCategory(document) {
data.applicationCategory !== 'UtilitiesApplication'
) {
data.applicationCategory = 'UtilitiesApplication';
script.textContent = JSON.stringify(data, null, 2);
script.textContent = serializeJsonLd(data);
}
}
}
@@ -92,15 +93,11 @@ function injectFaqSchema(document) {
if (items.length === 0) return false;
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items,
},
null,
2
);
script.textContent = serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items,
});
document.body.appendChild(script);
return true;
}
@@ -109,17 +106,13 @@ function injectWebsiteSchema(document) {
if (hasLdType(document, 'WebSite')) return;
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'BentoPDF',
alternateName: ['Bento PDF', 'BentoPDF.com'],
url: `${SITE_URL}/`,
},
null,
2
);
script.textContent = serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'BentoPDF',
alternateName: ['Bento PDF', 'BentoPDF.com'],
url: `${SITE_URL}/`,
});
document.body.appendChild(script);
}
+60 -75
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { marked } from 'marked';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -123,19 +124,15 @@ function faqSchema(entries) {
if (entries.length === 0) return '';
return `
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: entries.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
})),
},
null,
2
)}
${serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: entries.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
})),
})}
</script>`;
}
@@ -246,55 +243,47 @@ ${renderFaqs(meta.faqs)}
</script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: meta.h1,
description: meta.description,
url,
datePublished: meta.date,
dateModified: updated,
image: `${SITE_URL}/images/og-tools.png`,
author: {
'@type': 'Person',
name: AUTHOR.name,
url: AUTHOR.url,
sameAs: AUTHOR.profiles,
},
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
${serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: meta.h1,
description: meta.description,
url,
datePublished: meta.date,
dateModified: updated,
image: `${SITE_URL}/images/og-tools.png`,
author: {
'@type': 'Person',
name: AUTHOR.name,
url: AUTHOR.url,
sameAs: AUTHOR.profiles,
},
null,
2
)}
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
})}
</script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: `${SITE_URL}/blog/`,
},
{
'@type': 'ListItem',
position: 2,
name: meta.breadcrumb,
item: url,
},
],
},
null,
2
)}
${serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: `${SITE_URL}/blog/`,
},
{
'@type': 'ListItem',
position: 2,
name: meta.breadcrumb,
item: url,
},
],
})}
</script>${faqSchema(faqEntries)}
</body>
</html>
@@ -370,23 +359,19 @@ ${cards}
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'The BentoPDF Blog',
url: `${SITE_URL}/blog/`,
description:
'Guides and honest comparisons from the maintainer of BentoPDF.',
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
${serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'The BentoPDF Blog',
url: `${SITE_URL}/blog/`,
description:
'Guides and honest comparisons from the maintainer of BentoPDF.',
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
null,
2
)}
})}
</script>
</body>
</html>
+3 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -133,7 +134,7 @@ function injectOrganizationLd(document) {
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(ORGANIZATION_LD_MARKER, '');
script.textContent = JSON.stringify(data, null, 2);
script.textContent = serializeJsonLd(data);
document.body.appendChild(script);
}
@@ -214,7 +215,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(BREADCRUMB_MARKER, '');
script.textContent = JSON.stringify(ld, null, 2);
script.textContent = serializeJsonLd(ld);
document.body.appendChild(script);
}
+165 -80
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { existsSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -16,103 +17,187 @@ function originOf(urlStr) {
}
}
const JSDELIVR_HOST = 'cdn.jsdelivr.net';
export const DOCS_INLINE_SCRIPT_HASHES = [
'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=',
'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M=',
];
function toScriptSource(url) {
const origin = originOf(url);
if (!origin) return null;
const { host, pathname } = new URL(url);
if (host !== JSDELIVR_HOST) return origin;
const directory = /\.m?js$/.test(pathname)
? pathname.replace(/[^/]*$/, '')
: pathname.replace(/\/?$/, '/');
return `${origin}${directory.replace(/;/g, '%3B').replace(/,/g, '%2C')}`;
}
function uniq(values) {
return Array.from(new Set(values.filter(Boolean)));
}
const DEFAULT_WASM_ORIGINS = {
pymupdf: 'https://cdn.jsdelivr.net',
gs: 'https://cdn.jsdelivr.net',
cpdf: 'https://cdn.jsdelivr.net',
const DEFAULT_WASM_URLS = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/',
gs: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/',
};
const tesseractPackage = createRequire(import.meta.url)(
'tesseract.js/package.json'
);
const DEFAULT_TESSERACT_WORKER_URL = `https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseractPackage.version}/dist/worker.min.js`;
const DEFAULT_TESSERACT_CORE_URL = `https://cdn.jsdelivr.net/npm/tesseract.js-core@v${tesseractPackage.dependencies['tesseract.js-core'].substring(1)}/`;
const DEFAULT_CORS_PROXY_ORIGIN =
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com';
const wasmOrigins = [
originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf,
originOf(process.env.VITE_WASM_GS_URL) || DEFAULT_WASM_ORIGINS.gs,
originOf(process.env.VITE_WASM_CPDF_URL) || DEFAULT_WASM_ORIGINS.cpdf,
const COMMON_HEADERS = [
['X-Frame-Options', 'SAMEORIGIN'],
['X-Content-Type-Options', 'nosniff'],
['X-XSS-Protection', '1; mode=block'],
['Referrer-Policy', 'strict-origin-when-cross-origin'],
[
'Permissions-Policy',
'geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()',
],
['Cross-Origin-Opener-Policy', 'same-origin'],
['Cross-Origin-Embedder-Policy', 'credentialless'],
['Cross-Origin-Resource-Policy', 'cross-origin'],
];
const tesseractOrigins = uniq([
originOf(process.env.VITE_TESSERACT_WORKER_URL),
originOf(process.env.VITE_TESSERACT_CORE_URL),
originOf(process.env.VITE_TESSERACT_LANG_URL),
]);
function urlOrDefault(value, fallback) {
return originOf(value) ? value : fallback;
}
const corsProxyOrigin =
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
export function buildSecurityHeaders(env = process.env) {
const wasmUrls = [
urlOrDefault(env.VITE_WASM_PYMUPDF_URL, DEFAULT_WASM_URLS.pymupdf),
urlOrDefault(env.VITE_WASM_GS_URL, DEFAULT_WASM_URLS.gs),
urlOrDefault(env.VITE_WASM_CPDF_URL, DEFAULT_WASM_URLS.cpdf),
];
const wasmOrigins = wasmUrls.map(originOf);
const ocrFontOrigin =
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
const tesseractOrigins = uniq([
originOf(env.VITE_TESSERACT_WORKER_URL),
originOf(env.VITE_TESSERACT_CORE_URL),
originOf(env.VITE_TESSERACT_LANG_URL),
]);
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
ocrFontOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
const corsProxyOrigin =
originOf(env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
const githubStarsDisabled =
process.env.DISABLE_GITHUB_STARS === 'true' ||
process.env.SIMPLE_MODE === 'true';
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
const ocrFontOrigin =
originOf(env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptOrigins.join(' ')}`.trim(),
`worker-src 'self' blob:`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const scriptSources = uniq([
...wasmUrls.map(toScriptSource),
toScriptSource(
urlOrDefault(env.VITE_TESSERACT_WORKER_URL, DEFAULT_TESSERACT_WORKER_URL)
),
toScriptSource(
urlOrDefault(env.VITE_TESSERACT_CORE_URL, DEFAULT_TESSERACT_CORE_URL)
),
]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
ocrFontOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin]);
const docsDirectives = [
`default-src 'self'`,
`script-src 'self' 'unsafe-inline' ${scriptOrigins.join(' ')}`.trim(),
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const githubStarsDisabled =
env.DISABLE_GITHUB_STARS === 'true' || env.SIMPLE_MODE === 'true';
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
const csp = directives.join('; ');
const docsCsp = docsDirectives.join('; ');
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptSources.join(' ')}`.trim(),
`worker-src 'self' blob:`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const commonHeaders = `add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
`;
const docsDirectives = [
`default-src 'self'`,
`script-src 'self' ${DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`).join(' ')}`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const contents = `add_header Content-Security-Policy "${csp}" always;
${commonHeaders}`;
const csp = directives.join('; ');
const docsCsp = docsDirectives.join('; ');
const docsContents = `add_header Content-Security-Policy "${docsCsp}" always;
${commonHeaders}`;
const nginxHeaders = (policy) =>
[['Content-Security-Policy', policy], ...COMMON_HEADERS]
.map(([name, value]) => `add_header ${name} "${value}" always;\n`)
.join('');
const outPath = join(repoRoot, 'security-headers.conf');
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
writeFileSync(outPath, contents);
writeFileSync(docsOutPath, docsContents);
console.log(
`[security-headers] wrote ${outPath} with ${scriptOrigins.length} script-src / ${connectOrigins.length} connect-src origin(s)`
);
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
const staticHeaders = [
'/*',
` Content-Security-Policy: ${csp}`,
...COMMON_HEADERS.map(([name, value]) => ` ${name}: ${value}`),
'',
...['/docs', '/docs/*'].flatMap((docsPath) => [
docsPath,
' ! Content-Security-Policy',
` Content-Security-Policy: ${docsCsp}`,
'',
]),
].join('\n');
return {
csp,
docsCsp,
scriptSources,
connectOrigins,
nginx: nginxHeaders(csp),
nginxDocs: nginxHeaders(docsCsp),
staticHeaders,
};
}
function main() {
const headers = buildSecurityHeaders();
const outPath = join(repoRoot, 'security-headers.conf');
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
const staticOutPath = join(repoRoot, 'public', '_headers');
writeFileSync(outPath, headers.nginx);
writeFileSync(docsOutPath, headers.nginxDocs);
writeFileSync(staticOutPath, headers.staticHeaders);
console.log(
`[security-headers] wrote ${outPath} with ${headers.scriptSources.length} script-src / ${headers.connectOrigins.length} connect-src source(s)`
);
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
console.log(`[security-headers] wrote ${staticOutPath}`);
const distDir = join(repoRoot, 'dist');
if (existsSync(distDir)) {
const distOutPath = join(distDir, '_headers');
writeFileSync(distOutPath, headers.staticHeaders);
console.log(`[security-headers] wrote ${distOutPath}`);
}
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}
+40
View File
@@ -1,6 +1,8 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { DOCS_INLINE_SCRIPT_HASHES } from './generate-security-headers.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -29,6 +31,32 @@ function copyDir(src, dest) {
}
}
function findUnlistedInlineScripts(dir, unlisted = new Set()) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
findUnlistedInlineScripts(entryPath, unlisted);
continue;
}
if (!entry.name.endsWith('.html')) continue;
const html = fs.readFileSync(entryPath, 'utf8');
const inlineScripts = html.matchAll(
/<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/g
);
for (const [, attributes, code] of inlineScripts) {
if (
!code.trim() ||
/type=["']?application\/(ld\+)?json/.test(attributes)
) {
continue;
}
const hash = `sha256-${crypto.createHash('sha256').update(code).digest('base64')}`;
if (!DOCS_INLINE_SCRIPT_HASHES.includes(hash)) unlisted.add(hash);
}
}
return unlisted;
}
console.log('Including docs in dist...');
if (!fs.existsSync(docsDistPath)) {
@@ -44,6 +72,18 @@ if (!fs.existsSync(mainDistPath)) {
process.exit(1);
}
const unlistedScripts = findUnlistedInlineScripts(docsDistPath);
if (unlistedScripts.size > 0) {
console.error(
'Error: The docs contain inline scripts that the docs Content-Security-Policy does not allow:'
);
for (const hash of unlistedScripts) console.error(` '${hash}'`);
console.error(
'Add these hashes to DOCS_INLINE_SCRIPT_HASHES in scripts/generate-security-headers.mjs and to the /docs script-src in vercel.json and serve.json, then rebuild.'
);
process.exit(1);
}
try {
console.log(`Copying from: ${docsDistPath}`);
console.log(`Copying to: ${targetDocsPath}`);
+6
View File
@@ -0,0 +1,6 @@
export function serializeJsonLd(data) {
return JSON.stringify(data, null, 2)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
+1 -1
View File
@@ -1,4 +1,4 @@
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
+13
View File
@@ -3,6 +3,10 @@
{
"source": "**/*",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/ https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
},
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
@@ -20,6 +24,15 @@
"value": "nosniff"
}
]
},
{
"source": "docs/**",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
}
]
}
],
"cleanUrls": true,
+1 -1
View File
@@ -196,7 +196,7 @@ const engineReady = new Promise((resolve) => {
}
: () => {};
trace('wired');
const demo = params.get('demo');
const demo = import.meta.env?.DEV ? params.get('demo') : null;
if (demo) {
try {
trace('fetching ' + demo);
+3 -2
View File
@@ -6,6 +6,7 @@ import {
downloadFile,
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { sanitizeTranslationHtml } from '../utils/translation-html.js';
import { t } from '../i18n/i18n';
import {
signPdf,
@@ -404,7 +405,7 @@ async function handleCertFile(file: File): Promise<void> {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`;
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateLoaded'))} <i data-lucide="check" class="inline w-4 h-4"></i>`;
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
@@ -559,7 +560,7 @@ async function handlePasswordInput(): Promise<void> {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateUnlocked')} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateUnlocked'))} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
+232 -1
View File
@@ -15,13 +15,164 @@ const embedPdfWasmUrl = new URL(
import type { EmbedPdfContainer } from 'bentopdf-viewer';
import type {
ActiveDocumentSource,
AnnotationPluginLite,
AppliedRedactionTracker,
DocManagerPlugin,
FreeTextSystemFontAnnotation,
RedactionPluginLite,
} from '@/types';
const FREETEXT_SUBTYPE = 3;
export function trackAppliedRedactions(
redaction: RedactionPluginLite
): AppliedRedactionTracker {
const pendingByDoc = new Map<string, Map<string, string | undefined>>();
const appliedByDoc = new Map<string, Map<string, string | undefined>>();
const lastLeftPendingByDoc = new Map<string, string[]>();
const getAppliedRedactions = (documentId: string) => {
let applied = appliedByDoc.get(documentId);
if (!applied) {
applied = new Map();
appliedByDoc.set(documentId, applied);
}
return applied;
};
redaction.onPendingChange(({ documentId, pending }) => {
const previous = pendingByDoc.get(documentId) ?? new Map();
const next = new Map<string, string | undefined>();
for (const items of Object.values(pending)) {
for (const item of items) {
next.set(item.id, item.text ?? previous.get(item.id));
}
}
const applied = getAppliedRedactions(documentId);
const leftPending: string[] = [];
for (const [id, text] of previous) {
if (!next.has(id)) {
applied.set(id, text);
leftPending.push(id);
}
}
for (const id of next.keys()) applied.delete(id);
pendingByDoc.set(documentId, next);
lastLeftPendingByDoc.set(documentId, leftPending);
});
redaction.onRedactionEvent((event) => {
const applied = appliedByDoc.get(event.documentId);
if (event.type === 'remove' && event.id) {
applied?.delete(event.id);
} else if (event.type === 'clear') {
for (const id of lastLeftPendingByDoc.get(event.documentId) ?? []) {
applied?.delete(id);
}
}
});
return {
appliedTexts: (documentId) => [
...(appliedByDoc.get(documentId)?.values() ?? []),
],
forget: (documentId) => {
pendingByDoc.delete(documentId);
appliedByDoc.delete(documentId);
lastLeftPendingByDoc.delete(documentId);
},
};
}
export function redactionScrubTerms(
texts: Iterable<string | undefined>
): string[] {
const terms = new Map<string, string>();
for (const text of texts) {
for (const line of text?.split(/[\r\n]+/) ?? []) {
const term = line.trim();
const key = term.toLowerCase();
if (term && !terms.has(key)) terms.set(key, term);
}
}
return [...terms.values()].sort((a, b) => b.length - a.length);
}
export async function scrubRedactedText(
bytes: Uint8Array,
terms: string[]
): Promise<Uint8Array> {
const { redactDocumentData } =
await import('../utils/redact-document-data.js');
return redactDocumentData(bytes, terms);
}
function confirmChoice(
title: string,
message: string,
confirmLabel: string
): Promise<boolean> {
return new Promise((resolve) => {
const previousActive = document.activeElement as HTMLElement | null;
const overlay = document.createElement('div');
overlay.className =
'fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'edit-pdf-confirm-title');
const panel = document.createElement('div');
panel.className =
'bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700';
const heading = document.createElement('h3');
heading.id = 'edit-pdf-confirm-title';
heading.className = 'text-xl font-bold text-white mb-2';
heading.textContent = title;
const body = document.createElement('p');
body.className = 'text-gray-300 mb-6';
body.textContent = message;
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className =
'flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
cancelBtn.textContent = 'Cancel';
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className =
'flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
confirmBtn.textContent = confirmLabel;
const actions = document.createElement('div');
actions.className = 'flex gap-3';
actions.append(cancelBtn, confirmBtn);
panel.append(heading, body, actions);
overlay.append(panel);
const close = (result: boolean) => {
overlay.remove();
document.removeEventListener('keydown', onKeydown);
previousActive?.focus();
resolve(result);
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(false);
};
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
document.addEventListener('keydown', onKeydown);
document.body.append(overlay);
cancelBtn.focus();
});
}
function collectAllFreeTexts(
annotationPlugin: AnnotationPluginLite | null
): FreeTextSystemFontAnnotation[] {
@@ -177,7 +328,10 @@ async function handleFiles(files: FileList) {
pdfWrapper.classList.remove('hidden');
const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = getEditorDisabledCategories();
const disabledCategories = [
...getEditorDisabledCategories(),
'document-export',
];
viewerInstance = EmbedPDF.init({
disabledCategories,
type: 'container',
@@ -198,9 +352,24 @@ async function handleFiles(files: FileList) {
docManagerPlugin = registry
.getPlugin('document-manager')
.provides() as unknown as DocManagerPlugin;
const activeDocuments =
docManagerPlugin as unknown as ActiveDocumentSource;
let redactionPlugin: RedactionPluginLite | null = null;
try {
redactionPlugin = registry
.getPlugin('redaction')
.provides() as unknown as RedactionPluginLite;
} catch {
redactionPlugin = null;
}
const redactionTracker = redactionPlugin
? trackAppliedRedactions(redactionPlugin)
: null;
docManagerPlugin.onDocumentClosed((data: { id?: string }) => {
const docId = data?.id || '';
redactionTracker?.forget(docId);
removeFileEntry(docId);
});
@@ -258,6 +427,32 @@ async function handleFiles(files: FileList) {
downloadBtn.onclick = async () => {
try {
const documentId = activeDocuments.getActiveDocumentId();
const pendingCount = redactionPlugin?.getState().pendingCount ?? 0;
if (redactionPlugin && pendingCount > 0) {
const marks =
pendingCount === 1
? "1 redaction mark that hasn't"
: `${pendingCount} redaction marks that haven't`;
const shouldApply = await confirmChoice(
'Apply redactions?',
`This document has ${marks} been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?`,
'Apply and download'
);
if (!shouldApply) return;
try {
await redactionPlugin.commitAllPending().toPromise();
} catch (err) {
console.error('Applying redactions failed:', err);
}
if (redactionPlugin.getState().pendingCount > 0) {
showAlert(
'Redaction Failed',
'Some redaction marks could not be applied, so the file was not downloaded. Apply or remove them in the editor and try again.'
);
return;
}
}
const exportPlugin = registry.getPlugin('export').provides();
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
let outBytes = new Uint8Array(arrayBuffer);
@@ -301,6 +496,42 @@ async function handleFiles(files: FileList) {
console.error('Font embed pass failed:', err);
}
}
const appliedTexts =
documentId && redactionTracker
? redactionTracker.appliedTexts(documentId)
: [];
if (appliedTexts.length > 0) {
const { PDFDocument } = await import('pdf-lib');
const exported = await PDFDocument.load(outBytes, {
ignoreEncryption: true,
updateMetadata: false,
});
if (exported.isEncrypted) {
const shouldDownload = await confirmChoice(
'Encrypted file',
"Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?",
'Download anyway'
);
if (!shouldDownload) return;
} else {
showLoader('Removing redacted text from document data...');
try {
outBytes = await scrubRedactedText(
outBytes,
redactionScrubTerms(appliedTexts)
);
} catch (err) {
console.error('Redaction cleanup failed:', err);
showAlert(
'Error',
'Could not remove the redacted text from the document data, so the file was not downloaded.'
);
return;
} finally {
hideLoader();
}
}
}
const blob = new Blob([new Uint8Array(outBytes)], {
type: 'application/pdf',
});
+12 -10
View File
@@ -1,23 +1,23 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { PDFDocument } from 'pdf-lib';
import { flattenAnnotations } from '../utils/flatten-annotations.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../utils/flatten-annotations.js';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { FlattenPdfState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
const pageState: FlattenPdfState = {
files: [],
};
function flattenFormsInDoc(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
form.flatten();
}
function resetState() {
pageState.files = [];
@@ -134,7 +134,7 @@ async function flattenPdf() {
console.warn('Could not flatten annotations:', msg);
}
const newPdfBytes = await pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
downloadFile(
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
`flattened_${file.name}`
@@ -173,7 +173,9 @@ async function flattenPdf() {
console.warn('Could not flatten annotations:', msg);
}
const flattenedBytes = await pdfDoc.save();
const flattenedBytes = await removeUnreferencedObjects(
await pdfDoc.save()
);
const zipEntryName = deduplicateFileName(file.name, usedNames);
zip.file(zipEntryName, flattenedBytes);
processedCount++;
+24 -187
View File
@@ -43,7 +43,7 @@ import {
templateNameExists,
deleteTemplate,
} from '@/js/workflow/serialization';
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
import { getControlSpec } from '@/js/workflow/nodes/control-specs';
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
@@ -1124,181 +1124,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
return;
}
const dropdownOptions: Record<string, { label: string; value: string }[]> = {
format: [
{ label: 'JPG', value: 'jpg' },
{ label: 'PNG', value: 'png' },
{ label: 'WebP', value: 'webp' },
{ label: 'SVG', value: 'svg' },
],
position: [
{ label: 'Bottom Center', value: 'bottom-center' },
{ label: 'Bottom Left', value: 'bottom-left' },
{ label: 'Bottom Right', value: 'bottom-right' },
{ label: 'Top Center', value: 'top-center' },
{ label: 'Top Left', value: 'top-left' },
{ label: 'Top Right', value: 'top-right' },
],
orientation: [
{ label: 'Auto (Keep Original)', value: 'auto' },
{ label: 'Portrait', value: 'portrait' },
{ label: 'Landscape', value: 'landscape' },
],
direction: [
{ label: 'Vertical', value: 'vertical' },
{ label: 'Horizontal', value: 'horizontal' },
],
pagesPerSheet: [
{ label: '2', value: '2' },
{ label: '4', value: '4' },
{ label: '9', value: '9' },
{ label: '16', value: '16' },
],
fontFamily: [
{ label: 'Helvetica', value: 'helv' },
{ label: 'Times Roman', value: 'times' },
{ label: 'Courier', value: 'cour' },
{ label: 'Times Italic', value: 'tiro' },
],
pageSize: [
{ label: 'A4', value: 'a4' },
{ label: 'Letter', value: 'letter' },
{ label: 'Legal', value: 'legal' },
],
targetSize: [
{ label: 'A4', value: 'A4' },
{ label: 'Letter', value: 'Letter' },
{ label: 'Legal', value: 'Legal' },
{ label: 'A3', value: 'A3' },
{ label: 'A5', value: 'A5' },
{ label: 'Tabloid', value: 'Tabloid' },
{ label: 'Custom', value: 'Custom' },
],
scalingMode: [
{ label: 'Fit (keep full page visible)', value: 'fit' },
{ label: 'Fill (cover full target page)', value: 'fill' },
],
customUnits: [
{ label: 'Millimeters (mm)', value: 'mm' },
{ label: 'Inches (in)', value: 'in' },
],
numberFormat: [
{ label: 'Simple (1, 2, 3)', value: 'simple' },
{ label: 'Page X of Y', value: 'page_x_of_y' },
],
angle: [
{ label: '90° Clockwise', value: '90' },
{ label: '180°', value: '180' },
{ label: '90° Counter-clockwise', value: '270' },
],
blankPosition: [
{ label: 'End', value: 'end' },
{ label: 'Beginning', value: 'start' },
{ label: 'After Page...', value: 'after' },
],
resolution: [
{ label: 'Standard (192 DPI)', value: '2.0' },
{ label: 'High (288 DPI)', value: '3.0' },
{ label: 'Ultra (384 DPI)', value: '4.0' },
],
language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({
label: name,
value: code,
})),
gridMode: [
{ label: '1x2 (Booklet)', value: '1x2' },
{ label: '2x2 (4-up)', value: '2x2' },
{ label: '2x4 (8-up)', value: '2x4' },
{ label: '4x4 (16-up)', value: '4x4' },
],
paperSize: [
{ label: 'Letter', value: 'Letter' },
{ label: 'A4', value: 'A4' },
{ label: 'A3', value: 'A3' },
{ label: 'Tabloid', value: 'Tabloid' },
{ label: 'Legal', value: 'Legal' },
],
rasterizeDpi: [
{ label: '72 (Screen)', value: '72' },
{ label: '150 (Default)', value: '150' },
{ label: '200 (Good)', value: '200' },
{ label: '300 (Print)', value: '300' },
{ label: '600 (High Quality)', value: '600' },
],
imageFormat: [
{ label: 'PNG (Lossless)', value: 'png' },
{ label: 'JPEG (Smaller file size)', value: 'jpeg' },
],
skewThreshold: [
{ label: '0.1° (Very Sensitive)', value: '0.1' },
{ label: '0.5° (Default)', value: '0.5' },
{ label: '1.0° (Normal)', value: '1.0' },
{ label: '2.0° (Less Sensitive)', value: '2.0' },
],
processingDpi: [
{ label: '100 (Fast)', value: '100' },
{ label: '150 (Default)', value: '150' },
{ label: '200 (Better)', value: '200' },
{ label: '300 (Best Quality)', value: '300' },
],
level: [
{ label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' },
{ label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' },
{ label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' },
],
algorithm: [
{ label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' },
{ label: 'Photon (Rasterize pages)', value: 'photon' },
],
compressionLevel: [
{ label: 'Light', value: 'light' },
{ label: 'Balanced', value: 'balanced' },
{ label: 'Aggressive', value: 'aggressive' },
{ label: 'Extreme', value: 'extreme' },
],
redactMode: [
{ label: 'Search Text', value: 'text' },
{ label: 'Area (Coordinates)', value: 'area' },
],
};
const booleanControls = new Set([
'grayscale',
'border',
'margins',
'separator',
'sepia',
'includeCcBcc',
'includeAttachments',
'binarize',
'preFlatten',
'flattenForms',
'removeMetadata',
'removeAnnotations',
'removeJavascript',
'removeEmbeddedFiles',
'removeLayers',
'removeLinks',
'removeStructureTree',
'removeMarkInfo',
'removeFonts',
'subsetFonts',
'convertToGrayscale',
'removeThumbnails',
'retainPageLabels',
]);
const multiSelectDropdowns = new Set(['language']);
const advancedControls = new Set(['resolution', 'binarize', 'whitelist']);
const colorControls = new Set([
'color',
'borderColor',
'backgroundColor',
'separatorColor',
'fontColor',
'fillColor',
]);
const controlHints: Record<string, string> = {
pages: 'e.g. 1-3, 5, 7-9',
whitelist: 'Limit recognized characters (leave empty for all)',
@@ -1333,15 +1160,17 @@ function showNodeSettings(node: BaseWorkflowNode) {
controlWrappers[key] = wrapper;
const ctrl = control as { value?: unknown; type?: string };
const currentValue = String(ctrl.value ?? '');
const spec = getControlSpec(node.nodeType, key);
const controlLabel = document.createElement('label');
controlLabel.className = 'block text-xs text-gray-400 mb-1';
controlLabel.textContent = formatLabel(key);
wrapper.appendChild(controlLabel);
if (dropdownOptions[key] && multiSelectDropdowns.has(key)) {
if (spec?.kind === 'multiselect') {
const { options, separator } = spec;
const selectedValues = new Set(
currentValue ? currentValue.split('+') : []
currentValue ? currentValue.split(separator) : []
);
const container = document.createElement('div');
container.className = 'flex flex-col gap-1';
@@ -1364,7 +1193,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
function updateTags() {
tagsDiv.innerHTML = '';
for (const val of selectedValues) {
const opt = dropdownOptions[key].find((o) => o.value === val);
const opt = options.find((o) => o.value === val);
if (!opt) continue;
const tag = document.createElement('span');
tag.className =
@@ -1388,13 +1217,13 @@ function showNodeSettings(node: BaseWorkflowNode) {
function updateCtrl() {
(ctrl as { value: string }).value =
Array.from(selectedValues).join('+');
Array.from(selectedValues).join(separator);
}
function renderList(filter: string) {
listDiv.innerHTML = '';
const lowerFilter = filter.toLowerCase();
for (const opt of dropdownOptions[key]) {
for (const opt of options) {
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
continue;
const label = document.createElement('label');
@@ -1427,10 +1256,10 @@ function showNodeSettings(node: BaseWorkflowNode) {
updateTags();
renderList('');
wrapper.appendChild(container);
} else if (dropdownOptions[key]) {
} else if (spec?.kind === 'select') {
const select = document.createElement('select');
select.className = inputClass;
for (const opt of dropdownOptions[key]) {
for (const opt of spec.options) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
@@ -1438,13 +1267,14 @@ function showNodeSettings(node: BaseWorkflowNode) {
select.appendChild(option);
}
select.addEventListener('change', () => {
(ctrl as { value: string }).value = select.value;
ctrl.value =
typeof ctrl.value === 'number' ? Number(select.value) : select.value;
if (conditionalVisibility[key]) {
applyConditionalVisibility(key, select.value);
}
});
wrapper.appendChild(select);
} else if (booleanControls.has(key)) {
} else if (spec?.kind === 'boolean') {
const toggle = document.createElement('button');
toggle.type = 'button';
const isOn = currentValue === 'true';
@@ -1461,7 +1291,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`;
});
wrapper.appendChild(toggle);
} else if (colorControls.has(key)) {
} else if (spec?.kind === 'color') {
const colorRow = document.createElement('div');
colorRow.className = 'flex items-center gap-2';
const colorInput = document.createElement('input');
@@ -1489,18 +1319,25 @@ function showNodeSettings(node: BaseWorkflowNode) {
const input = document.createElement('input');
input.type = 'number';
input.className = inputClass;
if (spec?.kind === 'number') {
input.min = String(spec.min);
input.max = String(spec.max);
input.step = spec.step === undefined ? 'any' : String(spec.step);
}
input.value = currentValue;
input.addEventListener('input', () => {
const num = parseFloat(input.value);
if (!isNaN(num)) {
(ctrl as { value: number }).value = num;
ctrl.value = node.sanitizeControlValue(key, num);
}
});
input.addEventListener('change', () => {
input.value = String(ctrl.value ?? '');
});
wrapper.appendChild(input);
} else {
const input = document.createElement('input');
const isPasswordField = key === 'password' || key === 'ownerPassword';
input.type = isPasswordField ? 'password' : 'text';
input.type = spec?.kind === 'password' ? 'password' : 'text';
input.className = inputClass;
input.value = currentValue;
input.addEventListener('input', () => {
+7 -2
View File
@@ -1,7 +1,10 @@
import { PDFDocument, PDFName } from 'pdf-lib';
import { createIcons, icons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
// State management
@@ -142,7 +145,9 @@ async function processRemoveAnnotations() {
}
}
const newPdfBytes = await pageState.pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(
await pageState.pdfDoc.save()
);
downloadFile(
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
+6 -3
View File
@@ -3,7 +3,10 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PDFDocument, PDFName } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
interface PageState {
file: File | null;
@@ -155,9 +158,9 @@ async function removeMetadata() {
removeMetadataFromDoc(pdfDoc);
const newPdfBytes = await pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
downloadFile(
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
);
showAlert('Success', 'Metadata removed successfully!', 'success', () => {
+19 -8
View File
@@ -149,14 +149,25 @@ async function runSanitize() {
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
);
showAlert(
'Success',
'PDF has been sanitized and downloaded.',
'success',
() => {
resetState();
}
);
if (result.skipped.length > 0) {
showAlert(
'Sanitized With Warnings',
`The PDF was downloaded, but these steps could not be completed: ${result.skipped.join(', ')}. The content they cover may still be in the file.`,
'warning',
() => {
resetState();
}
);
} else {
showAlert(
'Success',
'PDF has been sanitized and downloaded.',
'success',
() => {
resetState();
}
);
}
} catch (e: unknown) {
console.error('Sanitization Error:', e);
const msg = e instanceof Error ? e.message : String(e);
+12 -4
View File
@@ -7,8 +7,14 @@ import {
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { t } from '../i18n/i18n';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { flattenAnnotations } from '../utils/flatten-annotations.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../utils/flatten-annotations.js';
import type { SignState, PDFViewerWindow } from '@/types';
const signState: SignState = {
@@ -285,7 +291,7 @@ async function applyAndSaveSignatures() {
const pdfBytes = new Uint8Array(rawPdfBytes);
const pdfDoc = await loadPdfDocument(pdfBytes);
try {
pdfDoc.getForm().flatten();
flattenFormsInDoc(pdfDoc);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('getForm')) {
@@ -298,7 +304,9 @@ async function applyAndSaveSignatures() {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Could not flatten annotations:', msg);
}
const flattenedPdfBytes = await pdfDoc.save();
const flattenedPdfBytes = await removeUnreferencedObjects(
await pdfDoc.save()
);
const blob = new Blob([new Uint8Array(flattenedPdfBytes)], {
type: 'application/pdf',
+3 -3
View File
@@ -377,9 +377,9 @@ function createSignatureCard(
result.cryptoVerified === true &&
result.coverageStatus === 'partial'
) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid — Modified After Signing (partial coverage)';
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Modified After Signing (signed part intact)';
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
+360 -100
View File
@@ -1,82 +1,296 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types';
import { SignatureValidationResult } from '@/types';
import type { ExtractedSignatureWithError, PdfValueSpan } from '@/types';
const INSECURE_DIGEST_OIDS = new Set<string>([
'1.2.840.113549.2.5',
'1.3.14.3.2.26',
]);
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = [];
const PDF_WHITESPACE = new Set('\0\t\n\f\r ');
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
const PDF_STRING_ESCAPES: Record<string, string> = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
'\r\n': '',
'\r': '',
'\n': '',
};
export function extractSignatures(
pdfBytes: Uint8Array
): ExtractedSignatureWithError[] {
const signatures: ExtractedSignatureWithError[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
// Find all signature objects for /Type /Sig
const sigRegex = /\/Type\s*\/Sig\b/g;
let sigMatch;
let sigIndex = 0;
const objectHeader = /\d+\s+\d+\s+obj\b/g;
let endobj = -1;
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
try {
const searchStart = Math.max(0, sigMatch.index - 5000);
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
const context = pdfString.substring(searchStart, searchEnd);
const byteRangeMatch = context.match(
/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/
);
if (!byteRangeMatch) continue;
while (objectHeader.exec(pdfString) !== null) {
const start = objectHeader.lastIndex;
if (endobj < start) {
endobj = pdfString.indexOf('endobj', start);
if (endobj < 0) endobj = pdfString.length;
}
const body = pdfString.slice(0, endobj);
const dictionaries: Map<string, PdfValueSpan>[] = [];
const end = parsePdfValue(
body,
skipPdfWhitespace(body, start),
dictionaries,
0
);
objectHeader.lastIndex = end < 0 ? endobj : end;
if (end < 0) continue;
const byteRange = [
parseInt(byteRangeMatch[1], 10),
parseInt(byteRangeMatch[2], 10),
parseInt(byteRangeMatch[3], 10),
parseInt(byteRangeMatch[4], 10),
];
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
if (!contentsMatch) continue;
const hexContents = contentsMatch[1];
const contentsBytes = hexToBytes(hexContents);
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
const timeMatch = context.match(
/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
);
let signingTime: string | undefined;
if (timeMatch) {
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
for (const entries of dictionaries) {
const type = entries.get('Type');
if (type && pdfString.slice(type.start, type.end) === '/Sig') {
signatures.push(
readSignatureDictionary(
pdfString,
pdfBytes,
entries,
signatures.length
)
);
}
signatures.push({
index: sigIndex++,
contents: contentsBytes,
byteRange,
reason: reasonMatch
? decodeURIComponent(escape(reasonMatch[1]))
: undefined,
location: locationMatch
? decodeURIComponent(escape(locationMatch[1]))
: undefined,
contactInfo: contactMatch
? decodeURIComponent(escape(contactMatch[1]))
: undefined,
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
signingTime,
});
} catch (e) {
console.warn('Error extracting signature at index', sigIndex, e);
}
}
return signatures;
}
function readSignatureDictionary(
pdfString: string,
pdfBytes: Uint8Array,
entries: Map<string, PdfValueSpan>,
index: number
): ExtractedSignatureWithError {
const text = (key: string) => decodePdfText(pdfBytes, entries.get(key));
const timeMatch = text('M')?.match(
/^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
);
const signature: ExtractedSignatureWithError = {
index,
contents: new Uint8Array(0),
byteRange: [],
reason: text('Reason'),
location: text('Location'),
contactInfo: text('ContactInfo'),
name: text('Name'),
signingTime: timeMatch
? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`
: undefined,
};
const rangeSpan = entries.get('ByteRange');
const rangeMatch = rangeSpan
? /^\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]$/.exec(
pdfString.slice(rangeSpan.start, rangeSpan.end)
)
: null;
if (!rangeMatch) {
signature.extractionError =
'Signature has a missing or malformed /ByteRange';
return signature;
}
signature.byteRange = rangeMatch.slice(1).map(Number);
const byteRangeError = getByteRangeError(
signature.byteRange,
pdfBytes.length
);
if (byteRangeError) {
signature.extractionError = byteRangeError;
return signature;
}
const gapStart = signature.byteRange[0] + signature.byteRange[1];
const gapEnd = signature.byteRange[2];
const gap = pdfString.slice(gapStart, gapEnd);
if (gap.length % 2 !== 0 || !/^<[0-9A-Fa-f]+>$/.test(gap)) {
signature.extractionError =
'The bytes excluded by /ByteRange are not exactly a hex-encoded signature';
return signature;
}
const contentsSpan = entries.get('Contents');
if (contentsSpan?.start !== gapStart || contentsSpan.end !== gapEnd) {
signature.extractionError =
'The bytes excluded by /ByteRange are not the /Contents of this signature';
return signature;
}
const contents = hexToBytes(gap.slice(1, -1));
const derObjectLength = getDerObjectLength(contents);
if (
derObjectLength === null ||
derObjectLength > contents.length ||
contents.subarray(derObjectLength).some((byte) => byte !== 0)
) {
signature.extractionError =
'Signature /Contents is not a single DER object followed by zero padding';
return signature;
}
signature.contents = contents.slice(0, derObjectLength);
return signature;
}
function getByteRangeError(
byteRange: number[],
fileLength: number
): string | null {
if (
byteRange.length !== 4 ||
!byteRange.every((value) => Number.isSafeInteger(value) && value >= 0)
) {
return 'Malformed ByteRange';
}
const [start1, len1, start2, len2] = byteRange;
if (start1 !== 0) {
return 'ByteRange does not start at the beginning of the file';
}
if (start1 + len1 > start2 || start2 + len2 > fileLength) {
return 'ByteRange out of bounds';
}
return null;
}
function skipPdfWhitespace(text: string, pos: number): number {
while (pos < text.length) {
if (text[pos] === '%') {
while (pos < text.length && text[pos] !== '\n' && text[pos] !== '\r') {
pos++;
}
} else if (PDF_WHITESPACE.has(text[pos])) {
pos++;
} else {
break;
}
}
return pos;
}
function findTokenEnd(text: string, pos: number): number {
while (pos < text.length && !PDF_TOKEN_TERMINATORS.has(text[pos])) pos++;
return pos;
}
function findLiteralStringEnd(text: string, start: number): number {
let depth = 0;
for (let pos = start; pos < text.length; pos++) {
if (text[pos] === '\\') {
pos++;
} else if (text[pos] === '(') {
depth++;
} else if (text[pos] === ')') {
depth--;
if (depth === 0) return pos + 1;
}
}
return -1;
}
function parsePdfValue(
text: string,
start: number,
dictionaries: Map<string, PdfValueSpan>[],
depth: number
): number {
if (depth > 32 || start >= text.length) return -1;
if (text.startsWith('<<', start)) {
const entries = new Map<string, PdfValueSpan>();
dictionaries.push(entries);
let pos = skipPdfWhitespace(text, start + 2);
while (!text.startsWith('>>', pos)) {
const keyEnd = text[pos] === '/' ? findTokenEnd(text, pos + 1) : pos;
const valueStart = skipPdfWhitespace(text, keyEnd);
const valueEnd = parsePdfValue(text, valueStart, dictionaries, depth + 1);
if (valueEnd < 0) return -1;
if (keyEnd > pos) {
entries.set(text.slice(pos + 1, keyEnd), {
start: valueStart,
end: valueEnd,
});
}
pos = skipPdfWhitespace(text, valueEnd);
}
return pos + 2;
}
if (text[start] === '<') {
const end = text.indexOf('>', start);
return end < 0 ? -1 : end + 1;
}
if (text[start] === '(') return findLiteralStringEnd(text, start);
if (text[start] === '[') {
let pos = skipPdfWhitespace(text, start + 1);
while (text[pos] !== ']') {
const end = parsePdfValue(text, pos, dictionaries, depth + 1);
if (end < 0) return -1;
pos = skipPdfWhitespace(text, end);
}
return pos + 1;
}
const end = findTokenEnd(text, text[start] === '/' ? start + 1 : start);
return end > start ? end : -1;
}
function decodePdfText(
pdfBytes: Uint8Array,
span: PdfValueSpan | undefined
): string | undefined {
if (!span) return undefined;
const raw = uint8ToLatin1(pdfBytes.subarray(span.start, span.end));
let text: string;
if (raw.startsWith('(')) {
text = raw
.slice(1, -1)
.replace(/\\([0-7]{1,3}|\r\n|[\s\S])/g, (_, escaped: string) =>
/^[0-7]/.test(escaped)
? String.fromCharCode(parseInt(escaped, 8) & 0xff)
: (PDF_STRING_ESCAPES[escaped] ?? escaped)
);
} else if (/^<[0-9A-Fa-f\s]*>$/.test(raw)) {
const hex = raw.slice(1, -1).replace(/\s/g, '');
text = uint8ToLatin1(hexToBytes(hex.length % 2 ? hex + '0' : hex));
} else {
return undefined;
}
const bytes = latin1ToUint8(text);
if (text.startsWith('\xfe\xff')) {
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
return text;
}
}
function getDerObjectLength(bytes: Uint8Array): number | null {
if (bytes.length < 2) return null;
if (bytes[1] < 0x80) return 2 + bytes[1];
const count = bytes[1] & 0x7f;
if (count === 0 || count > 4 || bytes.length < 2 + count) return null;
let length = 0;
for (let i = 0; i < count; i++) length = length * 256 + bytes[2 + i];
return 2 + count + length;
}
export async function validateSignature(
signature: ExtractedSignature,
signature: ExtractedSignatureWithError,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult> {
@@ -99,6 +313,13 @@ export async function validateSignature(
contactInfo: signature.contactInfo,
};
if (signature.extractionError) {
result.errorMessage = signature.extractionError;
result.cryptoVerified = false;
result.cryptoVerificationStatus = 'failed';
return result;
}
try {
const binaryString = String.fromCharCode.apply(
null,
@@ -113,6 +334,8 @@ export async function validateSignature(
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
signatureAlgorithm?: forge.asn1.Asn1[];
issuer?: forge.asn1.Asn1;
serial?: string;
};
};
@@ -121,7 +344,12 @@ export async function validateSignature(
return result;
}
const signerCert = p7.certificates[0] as forge.pki.Certificate;
const certificates = p7.certificates as forge.pki.Certificate[];
const signerCert = findSignerCertificate(
certificates,
p7.rawCapture?.issuer,
p7.rawCapture?.serial
);
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
@@ -148,27 +376,11 @@ export async function validateSignature(
// Check trust against provided certificate (cryptographic, not name/serial match)
if (trustedCert) {
try {
const trustedPem = forge.pki.certificateToPem(trustedCert);
const isSameCert =
forge.pki.certificateToPem(signerCert) === trustedPem;
let cryptoIssued = false;
const chain = [
result.isTrusted = chainsToTrustedCert(
signerCert,
...(p7.certificates as forge.pki.Certificate[]),
];
for (const cert of chain) {
try {
if (trustedCert.verify(cert)) {
cryptoIssued = true;
break;
}
} catch {
continue;
}
}
result.isTrusted = isSameCert || cryptoIssued;
certificates,
trustedCert
);
} catch {
result.isTrusted = false;
}
@@ -267,6 +479,70 @@ export async function validateSignature(
return result;
}
function findSignerCertificate(
certificates: forge.pki.Certificate[],
issuer: forge.asn1.Asn1 | undefined,
serial: string | undefined
): forge.pki.Certificate {
if (issuer && serial) {
const serialHex = forge.util.createBuffer(serial).toHex();
const issuerDer = forge.asn1.toDer(issuer).getBytes();
const match = certificates.find(
(cert) =>
cert.serialNumber === serialHex &&
forge.asn1
.toDer(forge.pki.distinguishedNameToAsn1(cert.issuer))
.getBytes() === issuerDer
);
if (match) return match;
}
return certificates[0];
}
function isIssuedBy(
issuer: forge.pki.Certificate,
child: forge.pki.Certificate
): boolean {
try {
return issuer.verify(child);
} catch {
return false;
}
}
function isCertificateAuthority(cert: forge.pki.Certificate): boolean {
const basicConstraints = cert.getExtension('basicConstraints') as
| { cA?: boolean }
| undefined;
return basicConstraints?.cA === true;
}
function chainsToTrustedCert(
signerCert: forge.pki.Certificate,
certificates: forge.pki.Certificate[],
trustedCert: forge.pki.Certificate
): boolean {
const trustedPem = forge.pki.certificateToPem(trustedCert);
const visited = new Set<string>();
let current = signerCert;
for (let depth = 0; depth < 10; depth++) {
const currentPem = forge.pki.certificateToPem(current);
if (currentPem === trustedPem || isIssuedBy(trustedCert, current)) {
return true;
}
if (visited.has(currentPem)) return false;
visited.add(currentPem);
const parent = certificates.find(
(cert) => isCertificateAuthority(cert) && isIssuedBy(cert, current)
);
if (!parent) return false;
current = parent;
}
return false;
}
export async function validatePdfSignatures(
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
@@ -286,13 +562,7 @@ function hexToBytes(hex: string): Uint8Array {
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
let actualLength = bytes.length;
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
actualLength--;
}
return bytes.slice(0, actualLength);
return bytes;
}
function getDigestAlgorithmName(oid: string): string {
@@ -690,8 +960,9 @@ async function performCryptoVerification(
if (!fields) {
return { status: 'failed', reason: 'Could not parse signer info' };
}
if (byteRange.length !== 4) {
return { status: 'failed', reason: 'Malformed ByteRange' };
const byteRangeError = getByteRangeError(byteRange, pdfBytes.length);
if (byteRangeError) {
return { status: 'failed', reason: byteRangeError };
}
const md = createMd(fields.digestOid);
@@ -703,17 +974,6 @@ async function performCryptoVerification(
}
const [start1, len1, start2, len2] = byteRange;
if (
start1 < 0 ||
len1 < 0 ||
start2 < 0 ||
len2 < 0 ||
start1 + len1 > pdfBytes.length ||
start2 + len2 > pdfBytes.length
) {
return { status: 'failed', reason: 'ByteRange out of bounds' };
}
const signedContent = new Uint8Array(len1 + len2);
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
+5 -4
View File
@@ -29,6 +29,7 @@ import {
setStoredItem,
removeStoredItem,
} from './utils/safe-storage.js';
import { sanitizeTranslationHtml } from './utils/translation-html.js';
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -48,9 +49,9 @@ const init = async () => {
main.innerHTML = `
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<i class="ph ph-prohibit text-6xl text-gray-500 mb-4"></i>
<h1 class="text-2xl font-bold text-white mb-2">${heading}</h1>
<p class="text-gray-400 mb-6">${message}</p>
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${backHome}</a>
<h1 class="text-2xl font-bold text-white mb-2">${sanitizeTranslationHtml(heading)}</h1>
<p class="text-gray-400 mb-6">${sanitizeTranslationHtml(message)}</p>
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${sanitizeTranslationHtml(backHome)}</a>
</div>
`;
return;
@@ -898,7 +899,7 @@ const init = async () => {
}
dom.warningTitle.textContent = title;
dom.warningMessage.innerHTML = message;
dom.warningMessage.innerHTML = sanitizeTranslationHtml(message);
dom.warningModal.classList.remove('hidden');
dom.warningModal.classList.add('flex');
+36
View File
@@ -0,0 +1,36 @@
export interface PendingRedactionItem {
id: string;
kind: 'text' | 'area';
text?: string;
}
export interface PendingRedactionChange {
documentId: string;
pending: Record<number, PendingRedactionItem[]>;
}
export interface RedactionPluginEvent {
type: string;
documentId: string;
id?: string;
}
export interface RedactionPluginLite {
getState: () => { pendingCount: number };
commitAllPending: () => { toPromise: () => Promise<boolean> };
onPendingChange: (
listener: (event: PendingRedactionChange) => void
) => () => void;
onRedactionEvent: (
listener: (event: RedactionPluginEvent) => void
) => () => void;
}
export interface ActiveDocumentSource {
getActiveDocumentId: () => string | null;
}
export interface AppliedRedactionTracker {
appliedTexts: (documentId: string) => (string | undefined)[];
forget: (documentId: string) => void;
}
+4
View File
@@ -66,3 +66,7 @@ export * from './ui-type.ts';
export * from './markdown-editor-type.ts';
export * from './sanitize-type.ts';
export * from './overlay-pdf-type.ts';
export * from './workflow-control-spec-type.ts';
export * from './edit-pdf-redaction-type.ts';
export * from './validate-signature-extraction-type.ts';
export * from './pdf-content-tokens-type.ts';
+13
View File
@@ -0,0 +1,13 @@
export interface ContentToken {
kind: 'name' | 'dict' | 'operand' | 'operator';
value: string;
start: number;
end: number;
}
export interface ContentOperation {
operator: string;
operands: ContentToken[];
start: number;
end: number;
}
@@ -0,0 +1,10 @@
import { ExtractedSignature } from './validate-signature-type.ts';
export interface PdfValueSpan {
start: number;
end: number;
}
export interface ExtractedSignatureWithError extends ExtractedSignature {
extractionError?: string;
}
@@ -0,0 +1,12 @@
export interface WorkflowControlOption {
label: string;
value: string;
}
export type WorkflowControlSpec =
| { kind: 'select'; options: WorkflowControlOption[] }
| { kind: 'multiselect'; options: WorkflowControlOption[]; separator: string }
| { kind: 'number'; min: number; max: number; step?: number }
| { kind: 'boolean' }
| { kind: 'color' }
| { kind: 'password' };
+58 -3
View File
@@ -7,8 +7,65 @@ import {
PDFNumber,
PDFOperator,
PDFOperatorNames,
PDFObject,
PDFTextField,
} from 'pdf-lib';
const HIDDEN_ANNOTATION_FLAGS = 0x02 | 0x20;
function isHiddenAnnotation(annot: PDFDict): boolean {
const flags = annot.lookup(PDFName.of('F'));
return (
flags instanceof PDFNumber &&
(flags.asNumber() & HIDDEN_ANNOTATION_FLAGS) !== 0
);
}
function detachAnnotations(pdfDoc: PDFDocument, refs: Set<PDFObject>) {
for (const page of pdfDoc.getPages()) {
const annots = page.node.Annots();
if (!annots) continue;
for (let i = annots.size() - 1; i >= 0; i--) {
if (refs.has(annots.get(i))) annots.remove(i);
}
}
for (const ref of refs) {
if (ref instanceof PDFRef) pdfDoc.context.delete(ref);
}
}
function removeHiddenFormFields(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
const removedRefs = new Set<PDFObject>();
for (const field of form.getFields()) {
const removeAll = field instanceof PDFTextField && field.isPassword();
const kids = field.acroField.Kids();
if (kids) {
for (let i = kids.size() - 1; i >= 0; i--) {
const widget = kids.lookup(i);
if (
removeAll ||
(widget instanceof PDFDict && isHiddenAnnotation(widget))
) {
removedRefs.add(kids.get(i));
kids.remove(i);
}
}
if (kids.size() > 0) continue;
} else if (!removeAll && !isHiddenAnnotation(field.acroField.dict)) {
continue;
}
form.acroForm.removeField(field.acroField);
removedRefs.add(field.ref);
}
if (removedRefs.size > 0) detachAnnotations(pdfDoc, removedRefs);
}
export function flattenFormsInDoc(pdfDoc: PDFDocument): void {
removeHiddenFormFields(pdfDoc);
pdfDoc.getForm().flatten();
}
function extractNumbers(arr: PDFArray, count: number): number[] | null {
if (arr.size() < count) return null;
const result: number[] = [];
@@ -68,9 +125,7 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void {
continue;
}
const flagsObj = annot.get(PDFName.of('F'));
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
if (flags & 0x02) {
if (isHiddenAnnotation(annot)) {
hasChanges = true;
continue;
}
+2 -2
View File
@@ -163,7 +163,7 @@ export async function convertToPdfA(
}
const args = [
'-dNOSAFER',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
@@ -418,7 +418,7 @@ export async function convertFontsToOutlines(
onProgress?.('Converting fonts to outlines...');
const args = [
'-dNOSAFER',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
+22 -2
View File
@@ -4,13 +4,16 @@ import { initializeQpdf } from './helpers.js';
type LoadOptions = Parameters<typeof PDFDocument.load>[1];
type PDFDocumentInstance = Awaited<ReturnType<typeof PDFDocument.load>>;
async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
async function runQpdf(
pdf: Uint8Array,
args: string[]
): Promise<Uint8Array | null> {
try {
const qpdf = await initializeQpdf();
qpdf.FS.writeFile('/input.pdf', pdf);
try {
qpdf.callMain(['/input.pdf', '--decrypt', '/output.pdf']);
qpdf.callMain(['/input.pdf', ...args, '/output.pdf']);
} catch (e) {
console.warn('[loadPdfDocument] qpdf repair warning:', e);
}
@@ -40,6 +43,23 @@ async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
}
}
function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
return runQpdf(pdf, ['--decrypt']);
}
export async function removeUnreferencedObjects(
pdf: Uint8Array | ArrayBuffer
): Promise<Uint8Array> {
const cleaned = await runQpdf(
pdf instanceof Uint8Array ? pdf : new Uint8Array(pdf),
[]
);
if (!cleaned) {
throw new Error('Could not remove leftover data from the PDF.');
}
return cleaned;
}
export async function loadPdfDocument(
pdf: Uint8Array | ArrayBuffer,
options?: LoadOptions
+299
View File
@@ -0,0 +1,299 @@
import type { ContentOperation, ContentToken } from '@/types';
const charCode = (char: string) => char.charCodeAt(0);
const BACKSLASH = charCode('\\');
const OPEN_PAREN = charCode('(');
const CLOSE_PAREN = charCode(')');
const LESS_THAN = charCode('<');
const GREATER_THAN = charCode('>');
const OPEN_BRACKET = charCode('[');
const CLOSE_BRACKET = charCode(']');
const SLASH = charCode('/');
const PERCENT = charCode('%');
const LINE_FEED = charCode('\n');
const CARRIAGE_RETURN = charCode('\r');
const LETTER_E = charCode('E');
const LETTER_I = charCode('I');
const WHITESPACE = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
const DELIMITERS = new Set([...'()<>[]{}/%'].map(charCode));
const OPERAND_KEYWORDS = new Set(['true', 'false', 'null']);
const TEXT_CHECK_LENGTH = 16;
const COMPONENTS_PER_COLOR_SPACE = new Map([
['G', 1],
['DeviceGray', 1],
['RGB', 3],
['DeviceRGB', 3],
['CMYK', 4],
['DeviceCMYK', 4],
['I', 1],
['Indexed', 1],
]);
function isRegularChar(byte: number): boolean {
return !WHITESPACE.has(byte) && !DELIMITERS.has(byte);
}
function isHexDigit(byte: number): boolean {
return (
(byte >= charCode('0') && byte <= charCode('9')) ||
(byte >= charCode('A') && byte <= charCode('F')) ||
(byte >= charCode('a') && byte <= charCode('f'))
);
}
function bytesToString(bytes: Uint8Array, start: number, end: number): string {
let text = '';
for (let i = start; i < end; i++) text += String.fromCharCode(bytes[i]);
return text;
}
function skipWhitespaceAndComments(bytes: Uint8Array, pos: number): number {
while (pos < bytes.length) {
if (WHITESPACE.has(bytes[pos])) {
pos++;
} else if (bytes[pos] === PERCENT) {
while (
pos < bytes.length &&
bytes[pos] !== LINE_FEED &&
bytes[pos] !== CARRIAGE_RETURN
) {
pos++;
}
} else {
break;
}
}
return pos;
}
function findLiteralStringEnd(bytes: Uint8Array, start: number): number {
let depth = 0;
for (let i = start; i < bytes.length; i++) {
const byte = bytes[i];
if (byte === BACKSLASH) {
i++;
} else if (byte === OPEN_PAREN) {
depth++;
} else if (byte === CLOSE_PAREN) {
depth--;
if (depth === 0) return i + 1;
}
}
throw new Error('Unterminated string in content stream');
}
function findHexStringEnd(bytes: Uint8Array, start: number): number {
for (let i = start + 1; i < bytes.length; i++) {
const byte = bytes[i];
if (byte === GREATER_THAN) return i + 1;
if (!isHexDigit(byte) && !WHITESPACE.has(byte)) {
throw new Error('Invalid hex string in content stream');
}
}
throw new Error('Unterminated hex string in content stream');
}
function findClosingDelimiter(
bytes: Uint8Array,
start: number,
closing: number[]
): number {
let pos = start;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated array or dictionary in content stream');
}
if (closing.every((byte, offset) => bytes[pos + offset] === byte)) {
return pos + closing.length;
}
pos = readToken(bytes, pos).end;
}
}
function readToken(bytes: Uint8Array, start: number): ContentToken {
const byte = bytes[start];
if (byte === OPEN_PAREN) {
const end = findLiteralStringEnd(bytes, start);
return { kind: 'operand', value: '', start, end };
}
if (byte === LESS_THAN && bytes[start + 1] === LESS_THAN) {
const end = findClosingDelimiter(bytes, start + 2, [
GREATER_THAN,
GREATER_THAN,
]);
return { kind: 'dict', value: '', start, end };
}
if (byte === LESS_THAN) {
const end = findHexStringEnd(bytes, start);
return { kind: 'operand', value: '', start, end };
}
if (byte === OPEN_BRACKET) {
const end = findClosingDelimiter(bytes, start + 1, [CLOSE_BRACKET]);
return { kind: 'operand', value: '', start, end };
}
if (byte === SLASH) {
let end = start + 1;
while (end < bytes.length && isRegularChar(bytes[end])) end++;
const name = bytesToString(bytes, start + 1, end);
return { kind: 'name', value: name, start, end };
}
if (DELIMITERS.has(byte)) {
throw new Error('Unexpected delimiter in content stream');
}
let end = start;
while (end < bytes.length && isRegularChar(bytes[end])) end++;
const word = bytesToString(bytes, start, end);
const isNumber = /^[\d+.-]/.test(word);
const kind = isNumber || OPERAND_KEYWORDS.has(word) ? 'operand' : 'operator';
return { kind, value: word, start, end };
}
function isFollowedByText(bytes: Uint8Array, start: number): boolean {
const end = Math.min(bytes.length, start + TEXT_CHECK_LENGTH);
for (let i = start; i < end; i++) {
const byte = bytes[i];
const isPrintable = byte >= 0x20 && byte <= 0x7e;
if (!isPrintable && !WHITESPACE.has(byte)) return false;
}
return true;
}
function getImageDataLength(
bytes: Uint8Array,
imageDict: Map<string, ContentToken>
): number | undefined {
const lookup = (shortKey: string, longKey: string) =>
imageDict.get(shortKey) ?? imageDict.get(longKey);
const toInteger = (token: ContentToken | undefined) =>
token?.kind === 'operand' && /^\d+$/.test(token.value)
? Number(token.value)
: undefined;
if (lookup('F', 'Filter')) return undefined;
const isImageMask = lookup('IM', 'ImageMask')?.value === 'true';
const colorSpace = lookup('CS', 'ColorSpace');
const isIndexedColor =
colorSpace?.kind === 'operand' &&
/^\[\s*\/(I|Indexed)[\s/]/.test(
bytesToString(bytes, colorSpace.start, colorSpace.end)
);
let componentsPerPixel: number | undefined;
if (isImageMask || isIndexedColor) {
componentsPerPixel = 1;
} else if (colorSpace?.kind === 'name') {
componentsPerPixel = COMPONENTS_PER_COLOR_SPACE.get(colorSpace.value);
}
const width = toInteger(lookup('W', 'Width'));
const height = toInteger(lookup('H', 'Height'));
const bitsPerComponent = isImageMask
? 1
: toInteger(lookup('BPC', 'BitsPerComponent'));
if (
width === undefined ||
height === undefined ||
bitsPerComponent === undefined ||
componentsPerPixel === undefined
) {
return undefined;
}
const bytesPerRow = Math.ceil(
(width * bitsPerComponent * componentsPerPixel) / 8
);
return height * bytesPerRow;
}
function isEndImageOperator(bytes: Uint8Array, pos: number): boolean {
return (
bytes[pos] === LETTER_E &&
bytes[pos + 1] === LETTER_I &&
(pos + 2 >= bytes.length || !isRegularChar(bytes[pos + 2]))
);
}
function skipInlineImage(bytes: Uint8Array, start: number): number {
const imageDict = new Map<string, ContentToken>();
let pendingKey: ContentToken | undefined;
let pos = start;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated inline image in content stream');
}
const token = readToken(bytes, pos);
pos = token.end;
if (token.kind === 'operator' && token.value === 'ID') break;
if (pendingKey) {
imageDict.set(pendingKey.value, token);
pendingKey = undefined;
} else if (token.kind === 'name') {
pendingKey = token;
} else {
throw new Error('Malformed inline image in content stream');
}
}
const dataStart = WHITESPACE.has(bytes[pos]) ? pos + 1 : pos;
const dataLength = getImageDataLength(bytes, imageDict);
if (dataLength !== undefined) {
let end = dataStart + dataLength;
while (end < bytes.length && WHITESPACE.has(bytes[end])) end++;
if (isEndImageOperator(bytes, end)) return end + 2;
throw new Error('Malformed inline image in content stream');
}
for (let i = dataStart; i + 1 < bytes.length; i++) {
if (!WHITESPACE.has(bytes[i - 1]) || !isEndImageOperator(bytes, i)) {
continue;
}
if (!isFollowedByText(bytes, i + 2)) break;
return i + 2;
}
throw new Error('Unterminated inline image in content stream');
}
export function parseContentOperations(bytes: Uint8Array): ContentOperation[] {
const operations: ContentOperation[] = [];
let operands: ContentToken[] = [];
let pos = 0;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) break;
const token = readToken(bytes, pos);
pos = token.end;
if (token.kind !== 'operator') {
operands.push(token);
continue;
}
if (token.value === 'BI') pos = skipInlineImage(bytes, pos);
operations.push({
operator: token.value,
operands,
start: operands.length > 0 ? operands[0].start : token.start,
end: pos,
});
operands = [];
}
if (operands.length > 0) {
operations.push({
operator: '',
operands,
start: operands[0].start,
end: operands[operands.length - 1].end,
});
}
return operations;
}
+253
View File
@@ -0,0 +1,253 @@
import {
PDFArray,
PDFDict,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
PDFStream,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
import type { PDFDocument } from 'pdf-lib';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from './load-pdf-document.js';
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
const NON_TEXT_KEYS = new Set(
['CreationDate', 'ModDate', 'M', 'ID'].map((key) => PDFName.of(key))
);
const SIGNATURE_TYPES = new Set(
['Sig', 'DocTimeStamp'].map((type) => PDFName.of(type))
);
const SIGNATURE_BINARY_KEYS = new Set(
['Contents', 'Cert'].map((key) => PDFName.of(key))
);
function decodeText(value: PDFObject | undefined): string | null {
if (value instanceof PDFString || value instanceof PDFHexString) {
return value.decodeText();
}
return null;
}
function decodeStream(stream: PDFRawStream): Uint8Array | null {
try {
return decodePDFRawStream(stream).decode();
} catch {
return null;
}
}
function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
const context = doc.context;
const needles = terms.map((term) => term.toLowerCase());
const pattern = new RegExp(
[...terms]
.sort((a, b) => b.length - a.length)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
'gi'
);
const contains = (text: string) => {
const lowered = text.toLowerCase();
return needles.some((needle) => lowered.includes(needle));
};
const valueContains = (value: PDFObject | undefined): boolean => {
if (value instanceof PDFArray) return value.asArray().some(valueContains);
const text = decodeText(value);
return text !== null && contains(text);
};
const resolve = (value: PDFObject | undefined) =>
value instanceof PDFRef ? context.lookup(value) : value;
const matchedFiles = new Set<string>();
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (
!(object instanceof PDFRawStream) ||
object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile')
) {
continue;
}
const bytes = decodeStream(object);
if (!bytes) continue;
const latin1 = new TextDecoder('latin1').decode(bytes);
const utf16 = new TextDecoder('utf-16le').decode(bytes);
if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString());
}
const isMatchedFileSpec = (value: PDFObject | undefined): boolean => {
const spec = resolve(value);
if (!(spec instanceof PDFDict)) return false;
const ef = resolve(spec.get(PDFName.of('EF')));
if (!(ef instanceof PDFDict)) return false;
return ef
.values()
.some((v) => v instanceof PDFRef && matchedFiles.has(v.toString()));
};
const fieldValueContains = (widget: PDFDict): boolean => {
let node: PDFObject | undefined = widget;
for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) {
if (valueContains(resolve(node.get(PDFName.of('V'))))) return true;
node = resolve(node.get(PDFName.of('Parent')));
}
return false;
};
let clearedFieldAppearance = false;
for (const page of doc.getPages()) {
page.node.delete(PDFName.of('Thumb'));
const annots = page.node.Annots();
if (!annots) continue;
const removed = new Set<string>();
for (const entry of annots.asArray()) {
const annot = resolve(entry);
if (!(annot instanceof PDFDict)) continue;
const subtype = annot.get(PDFName.of('Subtype'));
if (subtype === PDFName.of('Widget')) {
if (fieldValueContains(annot)) {
annot.delete(PDFName.of('AP'));
clearedFieldAppearance = true;
}
continue;
}
const hasText = ANNOTATION_TEXT_KEYS.some((key) =>
valueContains(resolve(annot.get(PDFName.of(key))))
);
const hasFile =
subtype === PDFName.of('FileAttachment') &&
isMatchedFileSpec(annot.get(PDFName.of('FS')));
if (hasText || hasFile) {
removed.add(entry.toString());
const popup = annot.get(PDFName.of('Popup'));
if (popup) removed.add(popup.toString());
}
}
if (removed.size > 0) {
page.node.set(
PDFName.of('Annots'),
context.obj(
annots.asArray().filter((entry) => !removed.has(entry.toString()))
)
);
}
}
if (clearedFieldAppearance) {
const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm')));
if (acroForm instanceof PDFDict) {
acroForm.set(PDFName.of('NeedAppearances'), context.obj(true));
}
}
const pruneNameTree = (value: PDFObject | undefined, depth = 0) => {
const node = resolve(value);
if (!(node instanceof PDFDict) || depth > 32) return;
const names = resolve(node.get(PDFName.of('Names')));
if (names instanceof PDFArray) {
const items = names.asArray();
const kept: PDFObject[] = [];
for (let i = 0; i + 1 < items.length; i += 2) {
if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]);
}
node.set(PDFName.of('Names'), context.obj(kept));
}
const kids = resolve(node.get(PDFName.of('Kids')));
if (kids instanceof PDFArray) {
for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1);
}
};
const pruneAssociatedFiles = (dict: PDFDict) => {
const list = resolve(dict.get(PDFName.of('AF')));
if (!(list instanceof PDFArray)) return;
const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec));
if (kept.length !== list.size()) {
dict.set(PDFName.of('AF'), context.obj(kept));
}
};
if (matchedFiles.size > 0) {
const names = resolve(doc.catalog.get(PDFName.of('Names')));
if (names instanceof PDFDict) {
pruneNameTree(names.get(PDFName.of('EmbeddedFiles')));
}
for (const [, object] of context.enumerateIndirectObjects()) {
if (object instanceof PDFDict) pruneAssociatedFiles(object);
else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict);
}
}
const scrubValue = (value: PDFObject): PDFObject | null => {
const text = decodeText(value);
if (text !== null && contains(text)) {
return PDFHexString.fromText(text.replace(pattern, ''));
}
if (value instanceof PDFDict || value instanceof PDFArray) {
scrubContainer(value);
}
return null;
};
const scrubContainer = (container: PDFDict | PDFArray) => {
if (container instanceof PDFDict) {
const type = container.get(PDFName.of('Type'));
const isSignature = type instanceof PDFName && SIGNATURE_TYPES.has(type);
for (const [key, value] of container.entries()) {
if (NON_TEXT_KEYS.has(key)) continue;
if (isSignature && SIGNATURE_BINARY_KEYS.has(key)) continue;
const replacement = scrubValue(value);
if (replacement) container.set(key, replacement);
}
} else {
container.asArray().forEach((value, index) => {
const replacement = scrubValue(value);
if (replacement) container.set(index, replacement);
});
}
};
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (object instanceof PDFStream) {
scrubContainer(object.dict);
const isMetadata =
object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata');
if (isMetadata && object instanceof PDFRawStream) {
const bytes = decodeStream(object);
const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null;
if (xml !== null && contains(xml)) {
context.assign(
ref,
context.flateStream(xml.replace(pattern, ''), {
Type: 'Metadata',
Subtype: 'XML',
})
);
}
}
} else if (object instanceof PDFDict || object instanceof PDFArray) {
scrubContainer(object);
}
}
}
export async function redactDocumentData(
pdf: Uint8Array,
terms: readonly string[] = []
): Promise<Uint8Array> {
const doc = await loadPdfDocument(pdf);
const searchTerms = terms.filter((term) => term.length > 0);
if (searchTerms.length > 0) {
removeTextFromDocument(doc, searchTerms);
} else {
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
}
return removeUnreferencedObjects(await doc.save());
}
+533 -129
View File
@@ -1,6 +1,24 @@
import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib';
import { loadPdfDocument } from './load-pdf-document.js';
import type { PDFDocumentInternal } from '@/types';
import {
PDFDocument,
PDFName,
PDFDict,
PDFArray,
PDFStream,
PDFObject,
PDFRef,
PDFRawStream,
PDFFlateStream,
PDFPageLeaf,
PDFContext,
decodePDFRawStream,
} from 'pdf-lib';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from './load-pdf-document.js';
import { flattenFormsInDoc } from './flatten-annotations.js';
import { parseContentOperations } from './pdf-content-tokens.js';
import type { ContentOperation, PDFDocumentInternal } from '@/types';
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
return pdfDoc.catalog;
@@ -30,6 +48,22 @@ function getErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function forEachDict(pdfDoc: PDFDocument, visit: (dict: PDFDict) => void) {
const walk = (value: PDFObject) => {
if (value instanceof PDFStream) {
walk(value.dict);
} else if (value instanceof PDFDict) {
visit(value);
for (const [, entry] of value.entries()) walk(entry);
} else if (value instanceof PDFArray) {
for (const entry of value.asArray()) walk(entry);
}
};
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
walk(object);
}
}
export interface SanitizeOptions {
flattenForms: boolean;
removeMetadata: boolean;
@@ -71,10 +105,11 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
pdfDoc.setProducer('');
try {
const catalogDict = getCatalogDict(pdfDoc);
if (catalogDict.has(PDFName.of('Metadata'))) {
catalogDict.delete(PDFName.of('Metadata'));
}
forEachDict(pdfDoc, (dict) => {
dict.delete(PDFName.of('Metadata'));
dict.delete(PDFName.of('PieceInfo'));
dict.delete(PDFName.of('Thumb'));
});
} catch (e: unknown) {
console.warn('Could not remove XMP metadata:', getErrorMessage(e));
}
@@ -87,15 +122,6 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
} catch (e: unknown) {
console.warn('Could not remove document IDs:', getErrorMessage(e));
}
try {
const catalogDict = getCatalogDict(pdfDoc);
if (catalogDict.has(PDFName.of('PieceInfo'))) {
catalogDict.delete(PDFName.of('PieceInfo'));
}
} catch (e: unknown) {
console.warn('Could not remove PieceInfo:', getErrorMessage(e));
}
}
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
@@ -112,9 +138,72 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
}
}
function flattenFormsInDoc(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
form.flatten();
const DANGEROUS_ACTIONS = new Set([
'JavaScript',
'Launch',
'ImportData',
'SubmitForm',
'GoToE',
'Rendition',
]);
function isDangerousAction(action: PDFDict): boolean {
const type = action.lookup(PDFName.of('S'));
return (
action.has(PDFName.of('JS')) ||
(type instanceof PDFName && DANGEROUS_ACTIONS.has(type.decodeText()))
);
}
function keepSafeActions(
context: PDFContext,
value: PDFObject,
seen: Set<PDFObject>
): PDFObject[] {
const resolved = context.lookup(value);
if (resolved instanceof PDFArray) {
if (seen.has(resolved)) return [];
seen.add(resolved);
return resolved
.asArray()
.flatMap((item) => keepSafeActions(context, item, seen));
}
if (!(resolved instanceof PDFDict) || !isDangerousAction(resolved)) {
return [value];
}
if (seen.has(resolved)) return [];
seen.add(resolved);
const next = resolved.get(PDFName.of('Next'));
return next ? keepSafeActions(context, next, seen) : [];
}
function removeDangerousActions(context: PDFContext, dict: PDFDict) {
dict.delete(PDFName.of('AA'));
const keys = ['A', 'OpenAction', 'PA'];
if (dict.has(PDFName.of('S'))) keys.push('Next');
for (const key of keys) {
const name = PDFName.of(key);
const value = dict.get(name);
if (value === undefined) continue;
const resolved = context.lookup(value);
if (key !== 'Next' && !(resolved instanceof PDFDict)) continue;
const safeActions = keepSafeActions(context, value, new Set());
const originalActions =
resolved instanceof PDFArray ? resolved.asArray() : [value];
if (
safeActions.length === originalActions.length &&
safeActions.every((item, i) => item === originalActions[i])
) {
continue;
}
if (safeActions.length === 0) {
dict.delete(name);
} else if (key === 'Next' && safeActions.length > 1) {
dict.set(name, context.obj(safeActions));
} else {
dict.set(name, safeActions[0]);
}
}
}
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
@@ -137,92 +226,13 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
}
}
if (catalogDict.has(PDFName.of('OpenAction'))) {
catalogDict.delete(PDFName.of('OpenAction'));
const acroForm = catalogDict.lookup(PDFName.of('AcroForm'));
if (acroForm instanceof PDFDict) {
acroForm.delete(PDFName.of('XFA'));
catalogDict.delete(PDFName.of('NeedsRendering'));
}
if (catalogDict.has(PDFName.of('AA'))) {
catalogDict.delete(PDFName.of('AA'));
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
if (pageDict.has(PDFName.of('AA'))) {
pageDict.delete(PDFName.of('AA'));
}
const annotRefs = pageDict.Annots()?.asArray() || [];
for (const annotRef of annotRefs) {
try {
const annot = lookupAsDict(pdfDoc, annotRef);
if (!annot) continue;
if (annot.has(PDFName.of('A'))) {
const actionRef = annot.get(PDFName.of('A'));
try {
const actionDict = lookupAsDict(pdfDoc, actionRef);
const actionType = actionDict
?.get(PDFName.of('S'))
?.toString()
.substring(1);
if (actionType === 'JavaScript') {
annot.delete(PDFName.of('A'));
}
} catch (e: unknown) {
console.warn('Could not read action:', getErrorMessage(e));
}
}
if (annot.has(PDFName.of('AA'))) {
annot.delete(PDFName.of('AA'));
}
} catch (e: unknown) {
console.warn(
'Could not process annotation for JS:',
getErrorMessage(e)
);
}
}
} catch (e: unknown) {
console.warn('Could not remove page actions:', getErrorMessage(e));
}
}
try {
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
if (acroFormRef) {
const acroFormDict = lookupAsDict(pdfDoc, acroFormRef);
const fieldsRef = acroFormDict?.get(PDFName.of('Fields'));
if (fieldsRef) {
const fieldsArray = lookupAsArray(pdfDoc, fieldsRef);
const fields = fieldsArray?.asArray() || [];
for (const fieldRef of fields) {
try {
const field = lookupAsDict(pdfDoc, fieldRef);
if (!field) continue;
if (field.has(PDFName.of('A'))) {
field.delete(PDFName.of('A'));
}
if (field.has(PDFName.of('AA'))) {
field.delete(PDFName.of('AA'));
}
} catch (e: unknown) {
console.warn('Could not process field for JS:', getErrorMessage(e));
}
}
}
}
} catch (e: unknown) {
console.warn('Could not process form fields for JS:', getErrorMessage(e));
}
forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict));
}
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
@@ -281,6 +291,14 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
}
}
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
if (object instanceof PDFDict) {
object.delete(PDFName.of('AF'));
} else if (object instanceof PDFStream) {
object.dict.delete(PDFName.of('AF'));
}
}
const pdfDocInternal = pdfDoc as unknown as PDFDocumentInternal;
if (pdfDocInternal.embeddedFiles && pdfDocInternal.embeddedFiles.length > 0) {
pdfDocInternal.embeddedFiles = [];
@@ -291,37 +309,410 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
}
}
function removeLayersFromDoc(pdfDoc: PDFDocument) {
const catalogDict = getCatalogDict(pdfDoc);
function createOptionalContentVisibility(pdfDoc: PDFDocument) {
const context = pdfDoc.context;
const ocProperties = pdfDoc.catalog.lookup(PDFName.of('OCProperties'));
const config =
ocProperties instanceof PDFDict
? ocProperties.lookup(PDFName.of('D'))
: undefined;
const getConfigGroups = (key: string) => {
const list =
config instanceof PDFDict ? config.lookup(PDFName.of(key)) : undefined;
return new Set(
list instanceof PDFArray
? list.asArray().map((item) => context.lookup(item))
: []
);
};
const onGroups = getConfigGroups('ON');
const offGroups = getConfigGroups('OFF');
const hiddenByDefault =
config instanceof PDFDict &&
config.lookup(PDFName.of('BaseState')) === PDFName.of('OFF');
const isGroupVisible = (group: PDFObject) =>
!offGroups.has(group) && (!hiddenByDefault || onGroups.has(group));
const expressionCache = new Map<PDFObject, boolean | undefined>();
if (catalogDict.has(PDFName.of('OCProperties'))) {
catalogDict.delete(PDFName.of('OCProperties'));
const isExpressionVisible = (
value: PDFObject | undefined,
depth: number
): boolean | undefined => {
const resolved = context.lookup(value);
if (resolved instanceof PDFDict) return isGroupVisible(resolved);
if (!(resolved instanceof PDFArray) || resolved.size() < 2 || depth > 32) {
return undefined;
}
if (expressionCache.has(resolved)) return expressionCache.get(resolved);
const operator = resolved.lookup(0);
const results = resolved
.asArray()
.slice(1)
.map((item) => isExpressionVisible(item, depth + 1));
let result: boolean | undefined;
if (!results.every((item) => item !== undefined)) {
result = undefined;
} else if (operator === PDFName.of('And')) {
result = results.every(Boolean);
} else if (operator === PDFName.of('Or')) {
result = results.some(Boolean);
} else if (operator === PDFName.of('Not') && results.length === 1) {
result = !results[0];
}
expressionCache.set(resolved, result);
return result;
};
return (value: PDFObject | undefined): boolean => {
const dict = context.lookup(value);
if (!(dict instanceof PDFDict)) return true;
if (dict.lookup(PDFName.of('Type')) !== PDFName.of('OCMD')) {
return isGroupVisible(dict);
}
if (dict.has(PDFName.of('VE'))) {
return isExpressionVisible(dict.get(PDFName.of('VE')), 0) === true;
}
const groups = dict.lookup(PDFName.of('OCGs'));
const states = (
groups instanceof PDFArray
? groups.asArray().map((group) => context.lookup(group))
: [groups]
)
.filter((group) => group instanceof PDFDict)
.map((group) => isGroupVisible(group));
if (states.length === 0) return true;
const policy = dict.lookup(PDFName.of('P'));
if (policy === undefined || policy === PDFName.of('AnyOn')) {
return states.some(Boolean);
}
if (policy === PDFName.of('AllOn')) return states.every(Boolean);
if (policy === PDFName.of('AnyOff')) return !states.every(Boolean);
if (policy === PDFName.of('AllOff')) return !states.some(Boolean);
return false;
};
}
const STATE_OPERATORS = new Set(
'q Q cm w J j M d ri i gs CS cs SC SCN sc scn G g RG rg K k BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* BX EX'.split(
' '
)
);
const PATH_OPERATORS = new Set('m l c v y h re W W*'.split(' '));
const PAINT_OPERATORS = new Set('S s f F f* B B* b b* n'.split(' '));
function readStreamBytes(stream: PDFStream): Uint8Array {
if (stream instanceof PDFRawStream) {
return decodePDFRawStream(stream).decode();
}
if (stream instanceof PDFFlateStream) return stream.getUnencodedContents();
return stream.getContents();
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
const NEWLINE = new Uint8Array([0x0a]);
if (pageDict.has(PDFName.of('OCProperties'))) {
pageDict.delete(PDFName.of('OCProperties'));
function concatBytes(pieces: Uint8Array[]): Uint8Array {
const result = new Uint8Array(
pieces.reduce((size, piece) => size + piece.length, 0)
);
let offset = 0;
for (const piece of pieces) {
result.set(piece, offset);
offset += piece.length;
}
return result;
}
function readPageContent(page: PDFPageLeaf): Uint8Array | undefined {
const contents = page.Contents();
if (!contents) return undefined;
const streams =
contents instanceof PDFArray ? contents.asArray() : [contents];
return concatBytes(
streams.flatMap((item) => {
const stream = page.context.lookup(item);
return stream instanceof PDFStream
? [readStreamBytes(stream), NEWLINE]
: [];
})
);
}
function isContentStream(dict: PDFDict): boolean {
const subtype = dict.lookup(PDFName.of('Subtype'));
return (
subtype === PDFName.of('Form') ||
(subtype === undefined &&
dict.has(PDFName.of('BBox')) &&
!dict.has(PDFName.of('ShadingType')))
);
}
function removeHiddenContent(
context: PDFContext,
bytes: Uint8Array,
resources: PDFDict | undefined,
isVisible: (value: PDFObject | undefined) => boolean
): Uint8Array | undefined {
const hiddenXObjects = new Set<PDFName>();
const xObjects = resources?.lookup(PDFName.of('XObject'));
if (xObjects instanceof PDFDict) {
for (const [name, value] of xObjects.entries()) {
const xObject = context.lookup(value);
if (
xObject instanceof PDFStream &&
xObject.dict.has(PDFName.of('OC')) &&
!isVisible(xObject.dict.get(PDFName.of('OC')))
) {
hiddenXObjects.add(name);
}
const resourcesRef = pageDict.get(PDFName.of('Resources'));
if (resourcesRef) {
try {
const resourcesDict = lookupAsDict(pdfDoc, resourcesRef);
if (resourcesDict?.has(PDFName.of('Properties'))) {
resourcesDict.delete(PDFName.of('Properties'));
}
} catch (e: unknown) {
console.warn('Could not access Resources:', getErrorMessage(e));
}
}
} catch (e: unknown) {
console.warn('Could not remove page layers:', getErrorMessage(e));
}
}
if (
hiddenXObjects.size === 0 &&
!new TextDecoder('latin1').decode(bytes).includes('BDC')
) {
return undefined;
}
const properties = resources?.lookup(PDFName.of('Properties'));
const startsHiddenSection = (operation: ContentOperation) => {
const [tag, property] = operation.operands;
if (tag?.kind !== 'name' || PDFName.of(tag.value) !== PDFName.of('OC')) {
return false;
}
const value =
property?.kind === 'name' && properties instanceof PDFDict
? properties.get(PDFName.of(property.value))
: undefined;
if (value === undefined) {
throw new Error('Unresolved optional content reference');
}
return !isVisible(value);
};
const encoder = new TextEncoder();
const pieces: Uint8Array[] = [];
const write = (...parts: Uint8Array[]) => pieces.push(NEWLINE, ...parts);
const writeOperation = (operation: ContentOperation) =>
write(bytes.subarray(operation.start, operation.end));
const openSections: boolean[] = [];
let hiddenDepth = 0;
let path: ContentOperation[] = [];
let changed = false;
let previousEnd = 0;
for (const operation of parseContentOperations(bytes)) {
const { operator, operands } = operation;
const hidden = hiddenDepth > 0;
const gapStart = previousEnd;
previousEnd = operation.end;
const keepOriginal = () =>
pieces.push(bytes.subarray(gapStart, operation.end));
if (operator === 'BMC' || operator === 'BDC') {
const startsHidden =
!hidden && operator === 'BDC' && startsHiddenSection(operation);
openSections.push(startsHidden);
if (startsHidden) hiddenDepth++;
if (hidden || startsHidden) {
changed = true;
} else {
keepOriginal();
}
continue;
}
if (operator === 'EMC') {
if (openSections.pop()) hiddenDepth--;
if (hidden) {
changed = true;
if (hiddenDepth === 0) path = [];
} else {
keepOriginal();
}
continue;
}
if (!hidden) {
const target = operands[0];
if (
operator === 'Do' &&
target?.kind === 'name' &&
hiddenXObjects.has(PDFName.of(target.value))
) {
changed = true;
} else {
keepOriginal();
}
continue;
}
changed = true;
if (STATE_OPERATORS.has(operator)) {
writeOperation(operation);
} else if (PATH_OPERATORS.has(operator)) {
path.push(operation);
} else if (PAINT_OPERATORS.has(operator)) {
if (
path.some((item) => item.operator === 'W' || item.operator === 'W*')
) {
path.forEach(writeOperation);
write(encoder.encode('n'));
}
path = [];
} else if (operator === "'") {
write(encoder.encode('T*'));
} else if (operator === '"' && operands.length === 3) {
write(
bytes.subarray(operands[0].start, operands[0].end),
encoder.encode(' Tw '),
bytes.subarray(operands[1].start, operands[1].end),
encoder.encode(' Tc T*')
);
}
}
if (!changed) return undefined;
if (hiddenDepth === 0) pieces.push(bytes.subarray(previousEnd));
return concatBytes(pieces);
}
function mergePageResources(pdfDoc: PDFDocument): PDFDict {
const context = pdfDoc.context;
const merged = new Map<string, Map<string, PDFObject>>();
const conflicts = new Set<string>();
for (const page of pdfDoc.getPages()) {
const resources = page.node.Resources();
for (const category of ['XObject', 'Properties']) {
const entries = resources?.lookup(PDFName.of(category));
if (!(entries instanceof PDFDict)) continue;
const mergedEntries = merged.get(category) ?? new Map();
merged.set(category, mergedEntries);
for (const [name, value] of entries.entries()) {
const key = `${category}/${name.decodeText()}`;
const existing = mergedEntries.get(name.decodeText());
if (existing && existing.toString() !== value.toString()) {
conflicts.add(key);
}
mergedEntries.set(name.decodeText(), value);
}
}
}
const resources = context.obj({});
for (const [category, entries] of merged) {
const dict = context.obj({});
for (const [name, value] of entries) {
if (!conflicts.has(`${category}/${name}`)) {
dict.set(PDFName.of(name), value);
}
}
resources.set(PDFName.of(category), dict);
}
return resources;
}
function removeLayersFromDoc(pdfDoc: PDFDocument) {
const catalogDict = getCatalogDict(pdfDoc);
const context = pdfDoc.context;
const pageContents: [PDFPageLeaf, Uint8Array][] = [];
const streamContents: [PDFRef, PDFStream, Uint8Array][] = [];
const hiddenAnnots = new Set<PDFObject>();
const hiddenXObjects = new Set<PDFObject>();
if (catalogDict.has(PDFName.of('OCProperties'))) {
const isVisible = createOptionalContentVisibility(pdfDoc);
for (const page of pdfDoc.getPages()) {
const content = readPageContent(page.node);
const filtered =
content &&
removeHiddenContent(context, content, page.node.Resources(), isVisible);
if (filtered) pageContents.push([page.node, filtered]);
for (const annotRef of page.node.Annots()?.asArray() ?? []) {
const annot = context.lookup(annotRef);
if (
annot instanceof PDFDict &&
annot.has(PDFName.of('OC')) &&
!isVisible(annot.get(PDFName.of('OC')))
) {
hiddenAnnots.add(annotRef);
}
}
}
const inheritedResources = mergePageResources(pdfDoc);
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (!(object instanceof PDFStream)) continue;
if (
object.dict.has(PDFName.of('OC')) &&
!isVisible(object.dict.get(PDFName.of('OC')))
) {
hiddenXObjects.add(ref);
}
if (!isContentStream(object.dict)) continue;
const resources = object.dict.lookup(PDFName.of('Resources'));
const filtered = removeHiddenContent(
context,
readStreamBytes(object),
resources instanceof PDFDict ? resources : inheritedResources,
isVisible
);
if (filtered) streamContents.push([ref, object, filtered]);
}
}
for (const [page, content] of pageContents) {
page.set(
PDFName.of('Contents'),
context.register(context.flateStream(content))
);
}
for (const [ref, stream, content] of streamContents) {
const replacement = context.flateStream(content);
for (const [key, value] of stream.dict.entries()) {
if (
!['Filter', 'DecodeParms', 'Length', 'DL'].includes(key.decodeText())
) {
replacement.dict.set(key, value);
}
}
context.assign(ref, replacement);
}
for (const page of pdfDoc.getPages()) {
const annots = page.node.Annots();
if (!annots) continue;
for (let i = annots.size() - 1; i >= 0; i--) {
if (hiddenAnnots.has(annots.get(i))) annots.remove(i);
}
}
catalogDict.delete(PDFName.of('OCProperties'));
forEachDict(pdfDoc, (dict) => {
dict.delete(PDFName.of('OCProperties'));
dict.delete(PDFName.of('OC'));
const xObjects = dict.lookup(PDFName.of('XObject'));
if (xObjects instanceof PDFDict) {
for (const [name, value] of xObjects.entries()) {
if (hiddenXObjects.has(value)) xObjects.delete(name);
}
}
const properties = dict.lookup(PDFName.of('Properties'));
if (properties instanceof PDFDict) {
for (const [name, value] of properties.entries()) {
const type = lookupAsDict(pdfDoc, value)?.lookup(PDFName.of('Type'));
if (type === PDFName.of('OCG') || type === PDFName.of('OCMD')) {
properties.delete(name);
}
}
if (properties.entries().length === 0) {
dict.delete(PDFName.of('Properties'));
}
}
});
}
function removeLinksFromDoc(pdfDoc: PDFDocument) {
@@ -554,8 +945,9 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) {
export async function sanitizePdf(
pdfBytes: Uint8Array,
options: SanitizeOptions
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> {
const pdfDoc = await loadPdfDocument(pdfBytes);
const skipped: string[] = [];
if (options.flattenForms) {
try {
@@ -572,6 +964,7 @@ export async function sanitizePdf(
'Could not remove AcroForm:',
getErrorMessage(removeError)
);
skipped.push('flatten forms');
}
}
}
@@ -589,6 +982,7 @@ export async function sanitizePdf(
removeJavascriptFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`);
skipped.push('remove JavaScript');
}
}
@@ -597,6 +991,7 @@ export async function sanitizePdf(
removeEmbeddedFilesFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`);
skipped.push('remove embedded files');
}
}
@@ -605,6 +1000,7 @@ export async function sanitizePdf(
removeLayersFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove layers: ${getErrorMessage(e)}`);
skipped.push('remove layers');
}
}
@@ -613,6 +1009,7 @@ export async function sanitizePdf(
removeLinksFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove links: ${getErrorMessage(e)}`);
skipped.push('remove links');
}
}
@@ -621,6 +1018,7 @@ export async function sanitizePdf(
removeStructureTreeFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`);
skipped.push('remove the structure tree');
}
}
@@ -629,6 +1027,7 @@ export async function sanitizePdf(
removeMarkInfoFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`);
skipped.push('remove MarkInfo');
}
}
@@ -637,9 +1036,14 @@ export async function sanitizePdf(
removeFontsFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove fonts: ${getErrorMessage(e)}`);
skipped.push('remove fonts');
}
}
const savedBytes = await pdfDoc.save();
return { pdfDoc, bytes: new Uint8Array(savedBytes) };
return {
pdfDoc,
bytes: await removeUnreferencedObjects(savedBytes),
skipped,
};
}
+7
View File
@@ -0,0 +1,7 @@
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = ['br', 'strong', 'em'];
export function sanitizeTranslationHtml(html: string): string {
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] });
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { ClassicPreset } from 'rete';
import type { NodeCategory, NodeMeta, SocketData } from '../types';
import { getControlSpec, sanitizeWithControlSpec } from './control-specs';
export abstract class BaseWorkflowNode extends ClassicPreset.Node {
abstract readonly category: NodeCategory;
@@ -41,6 +42,10 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
if (typeof value !== typeof current) {
return current;
}
return value;
return sanitizeWithControlSpec(
getControlSpec(this.nodeType, key),
value,
current
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import type { WorkflowControlOption, WorkflowControlSpec } from '@/types';
import { getAvailableTesseractLanguageEntries } from '../../utils/tesseract-language-availability.js';
export const MAX_TEXT_CONTROL_LENGTH = 1000;
const BOOLEAN: WorkflowControlSpec = { kind: 'boolean' };
const COLOR: WorkflowControlSpec = { kind: 'color' };
const PASSWORD: WorkflowControlSpec = { kind: 'password' };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
function toOptions(entries: [string, string][]): WorkflowControlOption[] {
return entries.map(([value, label]) => ({ value, label }));
}
function selectSpec(...entries: [string, string][]): WorkflowControlSpec {
return { kind: 'select', options: toOptions(entries) };
}
function numberSpec(
min: number,
max: number,
step?: number
): WorkflowControlSpec {
return { kind: 'number', min, max, step };
}
const PAGE_ORIENTATION = selectSpec(
['auto', 'Auto (Keep Original)'],
['portrait', 'Portrait'],
['landscape', 'Landscape']
);
const PAGE_COORDINATE = numberSpec(0, 14400);
const CONTROL_SPECS: Record<string, Record<string, WorkflowControlSpec>> = {
AddBlankPageNode: {
blankPosition: selectSpec(
['end', 'End'],
['start', 'Beginning'],
['after', 'After Page...']
),
afterPage: numberSpec(1, 100000, 1),
count: numberSpec(1, 100, 1),
},
AdjustColorsNode: {
brightness: numberSpec(-100, 100, 1),
contrast: numberSpec(-100, 100, 1),
saturation: numberSpec(-100, 100, 1),
hueShift: numberSpec(0, 360, 1),
temperature: numberSpec(-50, 50, 1),
tint: numberSpec(-50, 50, 1),
gamma: numberSpec(0.2, 5, 0.1),
sepia: numberSpec(0, 100, 1),
},
AlternateMergeNode: {
retainPageLabels: BOOLEAN,
},
BackgroundColorNode: {
color: COLOR,
},
BookletNode: {
gridMode: selectSpec(
['1x2', '1x2 (Booklet)'],
['2x2', '2x2 (4-up)'],
['2x4', '2x4 (8-up)'],
['4x4', '4x4 (16-up)']
),
paperSize: selectSpec(
['Letter', 'Letter'],
['A4', 'A4'],
['A3', 'A3'],
['Tabloid', 'Tabloid'],
['Legal', 'Legal']
),
orientation: PAGE_ORIENTATION,
},
CombineSinglePageNode: {
orientation: selectSpec(
['vertical', 'Vertical'],
['horizontal', 'Horizontal']
),
spacing: numberSpec(0, 1000),
backgroundColor: COLOR,
separator: BOOLEAN,
separatorThickness: numberSpec(0.5, 50, 0.5),
separatorColor: COLOR,
},
CompressNode: {
algorithm: selectSpec(
['condense', 'Condense (Smart, requires PyMuPDF)'],
['photon', 'Photon (Rasterize pages)']
),
compressionLevel: selectSpec(
['light', 'Light'],
['balanced', 'Balanced'],
['aggressive', 'Aggressive'],
['extreme', 'Extreme']
),
imageQuality: numberSpec(1, 100, 1),
dpiTarget: numberSpec(36, 300, 1),
dpiThreshold: numberSpec(72, 600, 1),
removeMetadata: BOOLEAN,
subsetFonts: BOOLEAN,
convertToGrayscale: BOOLEAN,
removeThumbnails: BOOLEAN,
},
CropNode: {
top: PAGE_COORDINATE,
bottom: PAGE_COORDINATE,
left: PAGE_COORDINATE,
right: PAGE_COORDINATE,
},
DecryptNode: {
password: PASSWORD,
},
DeskewNode: {
skewThreshold: selectSpec(
['0.1', '0.1° (Very Sensitive)'],
['0.5', '0.5° (Default)'],
['1.0', '1.0° (Normal)'],
['2.0', '2.0° (Less Sensitive)']
),
processingDpi: selectSpec(
['100', '100 (Fast)'],
['150', '150 (Default)'],
['200', '200 (Better)'],
['300', '300 (Best Quality)']
),
},
DividePagesNode: {
direction: selectSpec(
['vertical', 'Vertical'],
['horizontal', 'Horizontal']
),
},
EmailToPdfNode: {
pageSize: selectSpec(
['a4', 'A4'],
['letter', 'Letter'],
['legal', 'Legal']
),
includeCcBcc: BOOLEAN,
includeAttachments: BOOLEAN,
},
EncryptNode: {
userPassword: PASSWORD,
ownerPassword: PASSWORD,
},
FixPageSizeNode: {
targetSize: selectSpec(
['A4', 'A4'],
['Letter', 'Letter'],
['Legal', 'Legal'],
['A3', 'A3'],
['A5', 'A5'],
['Tabloid', 'Tabloid'],
['Custom', 'Custom']
),
orientation: PAGE_ORIENTATION,
scalingMode: selectSpec(
['fit', 'Fit (keep full page visible)'],
['fill', 'Fill (cover full target page)']
),
backgroundColor: COLOR,
customWidth: numberSpec(1, 5080),
customHeight: numberSpec(1, 5080),
customUnits: selectSpec(['mm', 'Millimeters (mm)'], ['in', 'Inches (in)']),
},
HeaderFooterNode: {
fontSize: numberSpec(4, 72, 1),
color: COLOR,
},
MergeNode: {
retainPageLabels: BOOLEAN,
},
NUpNode: {
pagesPerSheet: selectSpec(['2', '2'], ['4', '4'], ['9', '9'], ['16', '16']),
orientation: PAGE_ORIENTATION,
margins: BOOLEAN,
border: BOOLEAN,
borderColor: COLOR,
},
OCRNode: {
language: {
kind: 'multiselect',
options: toOptions(getAvailableTesseractLanguageEntries()),
separator: '+',
},
resolution: selectSpec(
['2.0', 'Standard (192 DPI)'],
['3.0', 'High (288 DPI)'],
['4.0', 'Ultra (384 DPI)']
),
binarize: BOOLEAN,
},
PageNumbersNode: {
position: selectSpec(
['bottom-center', 'Bottom Center'],
['bottom-left', 'Bottom Left'],
['bottom-right', 'Bottom Right'],
['top-center', 'Top Center'],
['top-left', 'Top Left'],
['top-right', 'Top Right']
),
fontSize: numberSpec(4, 72, 1),
numberFormat: selectSpec(
['simple', 'Simple (1, 2, 3)'],
['page_x_of_y', 'Page X of Y']
),
color: COLOR,
},
PdfToImagesNode: {
format: selectSpec(
['jpg', 'JPG'],
['png', 'PNG'],
['webp', 'WebP'],
['svg', 'SVG']
),
quality: numberSpec(10, 100, 1),
dpi: numberSpec(72, 600, 1),
},
PdfToPdfANode: {
level: selectSpec(
['PDF/A-1b', 'PDF/A-1b (Strict, no transparency)'],
['PDF/A-2b', 'PDF/A-2b (Recommended)'],
['PDF/A-3b', 'PDF/A-3b (Modern, allows attachments)']
),
preFlatten: BOOLEAN,
},
PosterizeNode: {
rows: numberSpec(1, 8, 1),
cols: numberSpec(1, 8, 1),
},
RasterizeNode: {
rasterizeDpi: selectSpec(
['72', '72 (Screen)'],
['150', '150 (Default)'],
['200', '200 (Good)'],
['300', '300 (Print)'],
['600', '600 (High Quality)']
),
imageFormat: selectSpec(
['png', 'PNG (Lossless)'],
['jpeg', 'JPEG (Smaller file size)']
),
grayscale: BOOLEAN,
},
RedactNode: {
redactMode: selectSpec(
['text', 'Search Text'],
['area', 'Area (Coordinates)']
),
x0: PAGE_COORDINATE,
y0: PAGE_COORDINATE,
x1: PAGE_COORDINATE,
y1: PAGE_COORDINATE,
fillColor: COLOR,
},
RemoveBlankPagesNode: {
threshold: numberSpec(0.1, 5, 0.1),
},
RotateNode: {
angle: selectSpec(
['90', '90° Clockwise'],
['180', '180°'],
['270', '90° Counter-clockwise']
),
},
SanitizeNode: {
flattenForms: BOOLEAN,
removeMetadata: BOOLEAN,
removeAnnotations: BOOLEAN,
removeJavascript: BOOLEAN,
removeEmbeddedFiles: BOOLEAN,
removeLayers: BOOLEAN,
removeLinks: BOOLEAN,
removeStructureTree: BOOLEAN,
removeMarkInfo: BOOLEAN,
removeFonts: BOOLEAN,
},
ScannerEffectNode: {
grayscale: BOOLEAN,
border: BOOLEAN,
rotation: numberSpec(-5, 5, 0.1),
rotationVariance: numberSpec(0, 3, 0.1),
brightness: numberSpec(-50, 50, 1),
contrast: numberSpec(-50, 50, 1),
blur: numberSpec(0, 3, 0.1),
noise: numberSpec(0, 50, 1),
yellowish: numberSpec(0, 50, 1),
resolution: numberSpec(72, 300, 1),
},
TableOfContentsNode: {
fontSize: numberSpec(10, 72, 1),
fontFamily: selectSpec(
['0', 'Times Roman'],
['1', 'Times Bold'],
['2', 'Times Italic'],
['3', 'Times Bold Italic'],
['4', 'Helvetica'],
['5', 'Helvetica Bold'],
['6', 'Helvetica Oblique'],
['7', 'Helvetica Bold Oblique'],
['8', 'Courier'],
['9', 'Courier Bold'],
['10', 'Courier Oblique'],
['11', 'Courier Bold Oblique']
),
},
TextToPdfNode: {
fontSize: numberSpec(6, 72, 1),
fontFamily: selectSpec(
['helv', 'Helvetica'],
['times', 'Times Roman'],
['cour', 'Courier'],
['tiro', 'Times Italic']
),
fontColor: COLOR,
},
WatermarkNode: {
fontSize: numberSpec(10, 200, 1),
color: COLOR,
opacity: numberSpec(0, 100, 1),
angle: numberSpec(-180, 180, 1),
position: selectSpec(
['center', 'Center'],
['top-left', 'Top Left'],
['top', 'Top'],
['top-right', 'Top Right'],
['left', 'Left'],
['right', 'Right'],
['bottom-left', 'Bottom Left'],
['bottom', 'Bottom'],
['bottom-right', 'Bottom Right']
),
tileGapX: numberSpec(0, 200, 1),
tileGapY: numberSpec(0, 200, 1),
},
};
export function getControlSpec(
nodeType: string,
key: string
): WorkflowControlSpec | undefined {
if (!Object.hasOwn(CONTROL_SPECS, nodeType)) return undefined;
const specs = CONTROL_SPECS[nodeType];
return Object.hasOwn(specs, key) ? specs[key] : undefined;
}
export function sanitizeWithControlSpec(
spec: WorkflowControlSpec | undefined,
value: unknown,
fallback: unknown
): unknown {
switch (spec?.kind) {
case 'password':
return '';
case 'select':
return spec.options.some((option) => option.value === String(value))
? value
: fallback;
case 'multiselect': {
if (typeof value !== 'string') return fallback;
const allowed = new Set(spec.options.map((option) => option.value));
const kept = [...new Set(value.split(spec.separator))].filter((part) =>
allowed.has(part)
);
return kept.length > 0 ? kept.join(spec.separator) : fallback;
}
case 'number':
return typeof value === 'number' && Number.isFinite(value)
? Math.min(spec.max, Math.max(spec.min, value))
: fallback;
case 'boolean':
return value === 'true' || value === 'false' ? value : fallback;
case 'color':
return typeof value === 'string' && HEX_COLOR.test(value)
? value
: fallback;
default:
if (typeof value === 'string') {
return value.slice(0, MAX_TEXT_CONTROL_LENGTH);
}
if (typeof value === 'number' && Number.isFinite(value)) return value;
return fallback;
}
}
+11 -6
View File
@@ -3,8 +3,14 @@ import { BaseWorkflowNode } from './base-node';
import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../../utils/flatten-annotations.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../../utils/load-pdf-document.js';
export class FlattenNode extends BaseWorkflowNode {
readonly category = 'Secure PDF' as const;
@@ -27,8 +33,7 @@ export class FlattenNode extends BaseWorkflowNode {
const pdfDoc = await loadPdfDocument(input.bytes);
try {
const form = pdfDoc.getForm();
form.flatten();
flattenFormsInDoc(pdfDoc);
} catch (err) {
console.error('Flatten form error (may have no forms):', err);
}
@@ -39,11 +44,11 @@ export class FlattenNode extends BaseWorkflowNode {
console.error('Flatten annotations error:', err);
}
const pdfBytes = await pdfDoc.save();
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
return {
type: 'pdf',
document: pdfDoc,
bytes: new Uint8Array(pdfBytes),
bytes: pdfBytes,
filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'),
};
}),
+7 -1
View File
@@ -6,6 +6,7 @@ import { requirePdfInput, processBatch } from '../types';
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
import { hexToRgb } from '../../utils/helpers.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import { redactDocumentData } from '../../utils/redact-document-data.js';
import { wfError } from '../errors';
export class RedactNode extends BaseWorkflowNode {
@@ -106,9 +107,14 @@ export class RedactNode extends BaseWorkflowNode {
}
}
const resultBytes = new Uint8Array(doc.save());
const redactedBytes = new Uint8Array(doc.save());
doc.close();
const resultBytes = await redactDocumentData(
redactedBytes,
mode === 'text' ? [searchText] : []
);
const resultDoc = await loadPdfDocument(resultBytes);
return {
+1 -1
View File
@@ -698,8 +698,8 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
};
export function createNodeByType(type: string): BaseWorkflowNode | null {
if (!Object.hasOwn(nodeRegistry, type)) return null;
const entry = nodeRegistry[type];
if (!entry) return null;
const node = entry.factory();
node.nodeType = type;
node.label = translateNodeLabel(type, entry);
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { PDFName } from 'pdf-lib';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../../utils/load-pdf-document.js';
export class RemoveAnnotationsNode extends BaseWorkflowNode {
readonly category = 'Edit & Annotate' as const;
@@ -34,11 +37,11 @@ export class RemoveAnnotationsNode extends BaseWorkflowNode {
}
}
const pdfBytes = await pdfDoc.save();
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
return {
type: 'pdf',
document: pdfDoc,
bytes: new Uint8Array(pdfBytes),
bytes: pdfBytes,
filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'),
};
}),
@@ -19,7 +19,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
this.addControl(
'threshold',
new ClassicPreset.InputControl('number', { initial: 250 })
new ClassicPreset.InputControl('number', { initial: 5 })
);
}
+5
View File
@@ -84,6 +84,11 @@ export class SanitizeNode extends BaseWorkflowNode {
return {
pdf: await processBatch(pdfInputs, async (input) => {
const result = await sanitizePdf(input.bytes, options);
if (result.skipped.length > 0) {
console.warn(
`[Sanitize] ${input.filename}: could not ${result.skipped.join(', ')}`
);
}
return {
type: 'pdf',
document: result.pdfDoc,
+37 -3
View File
@@ -3,6 +3,7 @@ import type { AreaPlugin } from 'rete-area-plugin';
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
import type { BaseWorkflowNode } from './nodes/base-node';
import { createNodeByType } from './nodes/registry';
import { getControlSpec } from './nodes/control-specs';
import { ClassicPreset } from 'rete';
import type {
SerializedWorkflow,
@@ -18,6 +19,24 @@ function getNodeType(node: BaseWorkflowNode): string | null {
return node.nodeType || null;
}
function clearPasswordControls(workflow: SerializedWorkflow): boolean {
let cleared = false;
for (const node of Array.isArray(workflow?.nodes) ? workflow.nodes : []) {
const controls = node?.controls;
if (!controls || typeof controls !== 'object') continue;
for (const key of Object.keys(controls)) {
if (
controls[key] !== '' &&
getControlSpec(String(node.type), key)?.kind === 'password'
) {
controls[key] = '';
cleared = true;
}
}
}
return cleared;
}
function serializeWorkflow(
editor: NodeEditor<ClassicScheme>,
area: AreaPlugin<ClassicScheme, AreaExtra>
@@ -56,11 +75,13 @@ function serializeWorkflow(
});
}
return {
const workflow: SerializedWorkflow = {
version: WORKFLOW_VERSION,
nodes,
connections,
} as SerializedWorkflow;
};
clearPasswordControls(workflow);
return workflow;
}
async function deserializeWorkflow(
@@ -96,6 +117,7 @@ async function deserializeWorkflow(
}
for (const [key, value] of Object.entries(serializedNode.controls || {})) {
if (!Object.hasOwn(node.controls, key)) continue;
const control = node.controls[key];
if (control && 'value' in control) {
(control as { value: unknown }).value = node.sanitizeControlValue(
@@ -144,7 +166,19 @@ function getStoredTemplates(): StoredTemplates {
const json = localStorage.getItem(TEMPLATES_KEY);
if (!json) return {};
try {
return JSON.parse(json) as StoredTemplates;
const templates = JSON.parse(json) as StoredTemplates;
let cleared = false;
for (const template of Object.values(templates)) {
if (clearPasswordControls(template)) cleared = true;
}
if (cleared) {
try {
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
} catch (e) {
console.warn('Failed to clear stored workflow passwords:', e);
}
}
return templates;
} catch {
return {};
}
+8 -3
View File
@@ -350,9 +350,14 @@
<i data-lucide="plus" class="faq-d-icon"></i>
</summary>
<p class="faq-d-a">
Yes. Applying a redaction removes the content underneath the marked
area from the file rather than covering it with a box, so the text
can't be recovered by copying or searching. Open the downloaded file
Yes, once it's applied. Marking text or an area only flags it.
Applying a redaction removes the content underneath the marked area
from the file rather than covering it with a box, so the text can't
be recovered by copying or searching. If you download with marks
that haven't been applied yet, the editor offers to apply them
first. When you redact selected text, copies of it in the file's
properties, bookmarks, comments, and attachments are removed on
download too, unless the file is encrypted. Open the downloaded file
and check before you share it.
</p>
</details>
+7 -6
View File
@@ -401,12 +401,13 @@
<i data-lucide="plus" class="faq-d-icon"></i>
</summary>
<p class="faq-d-a">
It means bytes were appended after this signature was applied, and
the tool reports it as Invalid with the note Modified After Signing
(partial coverage). That is not proof of tampering by itself: a
second signature or an incremental save produces the same result,
and only the last signature added can cover the entire file. Treat
it as a reason to look at what was added.
It means bytes were appended after this signature was applied. The
part that was signed is still intact, so the tool shows a Modified
After Signing warning instead of marking the signature invalid. That
is not proof of tampering by itself: a second signature or an
incremental save produces the same result, and only the last
signature added can cover the entire file. Treat it as a reason to
look at what was added.
</p>
</details>
<details class="faq-d">
+155
View File
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import {
buildSecurityHeaders,
DOCS_INLINE_SCRIPT_HASHES,
} from '../../scripts/generate-security-headers.mjs';
import { WasmProvider } from '../js/utils/wasm-provider';
const ROOT = path.resolve(__dirname, '../..');
const read = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
const defaults = buildSecurityHeaders({});
function directive(policy: string, name: string): string[] {
const found = policy
.split(';')
.map((d) => d.trim().split(/\s+/))
.find(([n]) => n === name);
return found ? found.slice(1) : [];
}
function allowedBy(sources: string[], url: string): boolean {
return sources.some((source) =>
source.endsWith('/')
? url.startsWith(source)
: new URL(url).origin === source
);
}
function headerRuleCsp(file: string, source: string): string | undefined {
const config = JSON.parse(read(file)) as {
headers: Array<{
source: string;
headers: Array<{ key: string; value: string }>;
}>;
};
return config.headers
.find((rule) => rule.source === source)
?.headers.find((h) => h.key === 'Content-Security-Policy')?.value;
}
describe('CSP script-src', () => {
const scriptSrc = directive(defaults.csp, 'script-src');
it('allows jsDelivr only through version-pinned package prefixes', () => {
const jsdelivr = scriptSrc.filter((s) => s.includes('cdn.jsdelivr.net'));
expect(jsdelivr.length).toBeGreaterThan(0);
for (const source of jsdelivr) {
expect(source.startsWith('https://cdn.jsdelivr.net/npm/'), source).toBe(
true
);
expect(source.endsWith('/'), source).toBe(true);
expect(source).toMatch(/@v?\d+\.\d+\.\d+\//);
}
});
it('covers every script the app loads from its default CDN URLs', () => {
const { pymupdf, ghostscript, cpdf } = WasmProvider.getAllProviders();
const tesseract = JSON.parse(
read('node_modules/tesseract.js/package.json')
) as { version: string; dependencies: Record<string, string> };
const core = tesseract.dependencies['tesseract.js-core'].substring(1);
const urls = [
`${pymupdf}dist/index.js`,
`${pymupdf}assets/pyodide.js`,
`${pymupdf}assets/pyodide.asm.js`,
`${ghostscript}gs.js`,
`${cpdf}coherentpdf.browser.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseract.version}/dist/worker.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js-core@v${core}/tesseract-core-simd-lstm.wasm.js`,
];
for (const url of urls) {
expect(allowedBy(scriptSrc, url), url).toBe(true);
}
});
it('rejects arbitrary jsDelivr packages and GitHub repos', () => {
for (const url of [
'https://cdn.jsdelivr.net/gh/attacker/repo@main/x.js',
'https://cdn.jsdelivr.net/npm/[email protected]/x.js',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/coherentpdf.browser.min.js',
'https://cdn.jsdelivr.net/combine/npm/evil@1',
]) {
expect(allowedBy(scriptSrc, url), url).toBe(false);
}
});
it('keeps self-hosted VITE_* URLs working', () => {
const custom = buildSecurityHeaders({
VITE_WASM_PYMUPDF_URL: 'https://internal.example.com/wasm/pymupdf/',
VITE_WASM_GS_URL:
'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets',
VITE_WASM_CPDF_URL: 'https://cdn.jsdelivr.net/npm/[email protected]/a;b,c/',
VITE_TESSERACT_WORKER_URL:
'https://cdn.jsdelivr.net/npm/[email protected]/dist/worker.min.js',
VITE_TESSERACT_CORE_URL: 'https://ocr.example.com/core',
VITE_TESSERACT_LANG_URL: 'https://ocr.example.com/lang',
});
expect(custom.scriptSources).toEqual([
'https://internal.example.com',
'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/',
'https://cdn.jsdelivr.net/npm/[email protected]/a%3Bb%2Cc/',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/',
'https://ocr.example.com',
]);
expect(directive(custom.csp, 'connect-src')).toEqual(
expect.arrayContaining([
'https://internal.example.com',
'https://cdn.jsdelivr.net',
'https://ocr.example.com',
])
);
});
it('only allows the pinned docs inline scripts', () => {
const sources = directive(defaults.docsCsp, 'script-src');
expect(sources).toEqual([
"'self'",
...DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`),
]);
expect(sources).not.toContain("'unsafe-inline'");
});
});
describe('deployment configs match the generator', () => {
it('vercel.json', () => {
expect(headerRuleCsp('vercel.json', '/(.*)')).toBe(defaults.csp);
expect(headerRuleCsp('vercel.json', '/docs')).toBe(defaults.docsCsp);
expect(headerRuleCsp('vercel.json', '/docs/:path*')).toBe(defaults.docsCsp);
});
it('serve.json', () => {
expect(headerRuleCsp('serve.json', '**/*')).toBe(defaults.csp);
expect(headerRuleCsp('serve.json', 'docs/**')).toBe(defaults.docsCsp);
});
it('public/_headers for Netlify and Cloudflare Pages', () => {
const file = read('public/_headers');
expect(file).toBe(defaults.staticHeaders);
expect(file).toContain(
`/docs/*\n ! Content-Security-Policy\n Content-Security-Policy: ${defaults.docsCsp}\n`
);
for (const line of file.split('\n')) {
expect(line.length).toBeLessThanOrEqual(2000);
}
});
it('nginx docs config and .htaccess', () => {
expect(read('security-headers-docs.conf')).toBe(defaults.nginxDocs);
expect(read('.htaccess')).toContain(
`Header always set Content-Security-Policy "${defaults.csp}"`
);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type {
PendingRedactionChange,
PendingRedactionItem,
RedactionPluginEvent,
RedactionPluginLite,
} from '../js/types/edit-pdf-redaction-type.js';
const { redactDocumentData } = vi.hoisted(() => ({
redactDocumentData: vi.fn(
async (pdf: Uint8Array, terms: readonly string[] = []) =>
Uint8Array.from([...pdf, terms.length])
),
}));
vi.mock('../js/utils/redact-document-data.js', () => ({
redactDocumentData,
}));
vi.mock('../js/utils/disabled-tools.js', () => ({
getEditorDisabledCategories: (): string[] => [],
}));
const { trackAppliedRedactions, redactionScrubTerms, scrubRedactedText } =
await import('../js/logic/edit-pdf-page.js');
function mockRedactionPlugin() {
const pendingListeners = new Set<(event: PendingRedactionChange) => void>();
const eventListeners = new Set<(event: RedactionPluginEvent) => void>();
const plugin: RedactionPluginLite = {
getState: () => ({ pendingCount: 0 }),
commitAllPending: () => ({ toPromise: async () => true }),
onPendingChange: (listener) => {
pendingListeners.add(listener);
return () => pendingListeners.delete(listener);
},
onRedactionEvent: (listener) => {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
};
const pending = new Map<string, PendingRedactionItem[]>();
const publish = (documentId: string) => {
const items = pending.get(documentId) ?? [];
for (const listener of pendingListeners) {
listener({ documentId, pending: items.length ? { 0: items } : {} });
}
};
const emit = (event: RedactionPluginEvent) => {
for (const listener of eventListeners) listener(event);
};
return {
plugin,
mark(documentId: string, item: PendingRedactionItem) {
pending.set(documentId, [...(pending.get(documentId) ?? []), item]);
publish(documentId);
emit({ type: 'add', documentId });
},
unmark(documentId: string, id: string) {
pending.set(
documentId,
(pending.get(documentId) ?? []).filter((item) => item.id !== id)
);
publish(documentId);
emit({ type: 'remove', documentId, id });
},
clearAll(documentId: string) {
pending.set(documentId, []);
publish(documentId);
emit({ type: 'clear', documentId });
},
apply(documentId: string, ids?: string[]) {
pending.set(
documentId,
ids
? (pending.get(documentId) ?? []).filter(
(item) => !ids.includes(item.id)
)
: []
);
publish(documentId);
emit({ type: 'commit', documentId });
},
};
}
describe('trackAppliedRedactions', () => {
it('reports nothing when no redaction was applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
expect(tracker.appliedTexts('doc')).toEqual([]);
});
it('keeps the text of applied text marks and skips removed marks', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Removed Mark' });
viewer.unmark('doc', 'b');
viewer.mark('doc', { id: 'c', kind: 'area' });
viewer.apply('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Jane Doe', undefined]);
});
it('does not count marks removed with Clear all as applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Applied Text' });
viewer.apply('doc');
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Acme Corp' });
viewer.clearAll('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Applied Text']);
});
it('counts a single applied mark while others stay pending', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'First' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Second' });
viewer.apply('doc', ['a']);
expect(tracker.appliedTexts('doc')).toEqual(['First']);
viewer.apply('doc', ['b']);
expect(tracker.appliedTexts('doc')).toEqual(['First', 'Second']);
});
it('keeps applied marks separate per document and forgets closed ones', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('one', { id: 'a', kind: 'text', text: 'Alpha' });
viewer.mark('two', { id: 'b', kind: 'area' });
viewer.apply('one');
expect(tracker.appliedTexts('one')).toEqual(['Alpha']);
expect(tracker.appliedTexts('two')).toEqual([]);
tracker.forget('one');
expect(tracker.appliedTexts('one')).toEqual([]);
});
it('treats a mark that comes back after a reload as pending again', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
const item: PendingRedactionItem = { id: 'a', kind: 'text', text: 'X' };
viewer.mark('doc', item);
viewer.apply('doc');
viewer.mark('doc', item);
expect(tracker.appliedTexts('doc')).toEqual([]);
});
});
describe('redactionScrubTerms', () => {
it('splits lines, trims, dedupes case-insensitively and sorts longest first', () => {
expect(
redactionScrubTerms([
'John',
undefined,
' John Smith \r\nAcme Corp',
'john',
'\n',
])
).toEqual(['John Smith', 'Acme Corp', 'John']);
});
it('returns no terms for area-only redactions', () => {
expect(redactionScrubTerms([undefined, undefined])).toEqual([]);
});
});
describe('scrubRedactedText', () => {
beforeEach(() => {
redactDocumentData.mockClear();
});
it('runs one termless pass when no text is known', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), []);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual([]);
expect([...out]).toEqual([1, 0]);
});
it('removes every term in a single pass', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), ['abc', 'de']);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual(['abc', 'de']);
expect([...out]).toEqual([1, 2]);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFRawStream,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
vi.mock('rete', () => ({
ClassicPreset: {
Input: class {},
Output: class {},
},
}));
vi.mock('@/js/workflow/sockets', () => ({ pdfSocket: {} }));
vi.mock('@/js/workflow/nodes/base-node', () => ({
BaseWorkflowNode: class {
addInput() {}
addOutput() {}
},
}));
const { flattenAnnotations, flattenFormsInDoc } =
await import('../js/utils/flatten-annotations.js');
const { removeUnreferencedObjects } =
await import('../js/utils/load-pdf-document.js');
const { sanitizePdf } = await import('../js/utils/sanitize.js');
const { FlattenNode } = await import('../js/workflow/nodes/flatten-node.js');
const SECRETS = [
'HIDDEN-VALUE',
'NOVIEW-VALUE',
'PASSWORD-SECRET',
'GROUP-SECRET',
];
async function buildFormDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([400, 400]);
const form = doc.getForm();
let y = 10;
const addField = (name: string, value: string, flags?: number) => {
const field = form.createTextField(name);
field.setText(value);
field.addToPage(page, { x: 10, y, width: 200, height: 20 });
y += 30;
if (flags !== undefined) {
const widgets = field.acroField.getWidgets();
widgets[widgets.length - 1].setFlags(flags);
}
return field;
};
addField('visible', 'VISIBLE-VALUE');
addField('hiddenField', 'HIDDEN-VALUE', 2);
addField('noViewField', 'NOVIEW-VALUE', 32 | 4);
addField('group.secret', 'GROUP-SECRET', 2);
const password = addField('pw', 'PASSWORD-SECRET');
password.enablePassword();
password.acroField.getWidgets()[0].dict.delete(PDFName.of('AP'));
const multi = addField('multi', 'MULTI-VALUE');
multi.addToPage(page, { x: 250, y: 10, width: 120, height: 20 });
multi.acroField.getWidgets()[1].setFlags(2);
const ctx = doc.context;
const appearance = (text: string) =>
ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${text}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const annots = page.node.Annots() as PDFArray;
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 300, 110, 320],
F: 32 | 4,
AP: { N: appearance('NOVIEW-ANNOT') },
})
)
);
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 330, 110, 350],
AP: { N: appearance('SHOWN-ANNOT') },
})
)
);
return doc.save();
}
function hex(text: string): string {
return Buffer.from(text).toString('hex');
}
async function inspect(bytes: Uint8Array) {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const streams: string[] = [];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
streams.push(
Buffer.from(decodePDFRawStream(object).decode()).toString('latin1')
);
}
}
const text = [Buffer.from(bytes).toString('latin1'), ...streams]
.join('\n')
.toLowerCase();
const count = (value: string) =>
text.split(value.toLowerCase()).length -
1 +
text.split(hex(value)).length -
1;
return { doc, count };
}
async function expectHiddenFieldsGone(bytes: Uint8Array) {
const { doc, count } = await inspect(bytes);
for (const secret of SECRETS) expect(count(secret)).toBe(0);
expect(count('VISIBLE-VALUE')).toBe(1);
expect(count('MULTI-VALUE')).toBe(1);
const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
const fields =
acroForm instanceof PDFDict
? acroForm.lookup(PDFName.of('Fields'), PDFArray)
: undefined;
expect(fields?.size() ?? 0).toBe(0);
return count;
}
describe('flattenFormsInDoc', () => {
it('does not draw hidden, no-view or password fields', async () => {
const input = await buildFormDocument();
const before = await inspect(input);
for (const secret of SECRETS)
expect(before.count(secret)).toBeGreaterThan(0);
const doc = await PDFDocument.load(input);
flattenFormsInDoc(doc);
flattenAnnotations(doc);
const bytes = await removeUnreferencedObjects(await doc.save());
const count = await expectHiddenFieldsGone(bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
expect(count('SHOWN-ANNOT')).toBe(1);
});
it('is used by the sanitize flatten option', async () => {
const { bytes } = await sanitizePdf(await buildFormDocument(), {
flattenForms: true,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
});
await expectHiddenFieldsGone(bytes);
});
it('is used by the workflow flatten node', async () => {
const node = new FlattenNode();
const result = await node.data({
pdf: [
{
type: 'pdf',
document: await PDFDocument.create(),
bytes: await buildFormDocument(),
filename: 'form.pdf',
},
],
});
const output = result.pdf;
if (output.type !== 'pdf') throw new Error('Expected a single PDF');
const count = await expectHiddenFieldsGone(output.bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
const { redactDocumentData } =
await import('../js/utils/redact-document-data.js');
const SECRET = 'SECRET-4471';
async function buildDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
doc.setTitle(`Report for ${SECRET}`);
doc.setAuthor('Keep Author');
doc.catalog.set(
PDFName.of('Metadata'),
ctx.register(
ctx.flateStream(`<x:xmpmeta><dc:title>${SECRET}</dc:title></x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
)
);
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of(`${SECRET} chapter`),
Parent: outlines,
Next: second,
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Keep chapter'),
Parent: outlines,
Prev: first,
})
);
ctx.assign(
outlines,
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
);
doc.catalog.set(PDFName.of('Outlines'), outlines);
doc.catalog.set(
PDFName.of('StructTreeRoot'),
ctx.register(
ctx.obj({
Type: 'StructTreeRoot',
K: ctx.obj({
S: 'Figure',
Alt: PDFHexString.fromText(`Photo of ${SECRET}`),
}),
})
)
);
const secretNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [10, 10, 30, 30],
Contents: PDFString.of(`note about ${secret()}`),
})
);
const keepNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [40, 10, 60, 30],
Contents: PDFString.of('keep note'),
})
);
const appearance = ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${SECRET}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const widget = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('ssn'),
V: PDFString.of(SECRET),
Rect: [10, 100, 110, 120],
AP: { N: appearance },
})
);
doc.catalog.set(PDFName.of('AcroForm'), ctx.obj({ Fields: [widget] }));
page.node.set(PDFName.of('Annots'), ctx.obj([secretNote, keepNote, widget]));
page.node.set(
PDFName.of('Thumb'),
ctx.register(ctx.flateStream(SECRET, { Width: 1, Height: 1 }))
);
await doc.attach(
new Uint8Array(Buffer.from(`payroll ${SECRET}`)),
'payroll.txt',
{
mimeType: 'text/plain',
}
);
await doc.attach(new Uint8Array(Buffer.from('harmless')), 'readme.txt', {
mimeType: 'text/plain',
});
return doc.save();
}
function secret() {
return SECRET.toLowerCase();
}
function collectText(value: PDFObject, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [, v] of value.entries()) collectText(v, out);
} else if (value instanceof PDFArray) {
for (const v of value.asArray()) collectText(v, out);
}
}
async function allText(bytes: Uint8Array): Promise<string> {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const out: string[] = [new TextDecoder('latin1').decode(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
try {
out.push(
new TextDecoder('latin1').decode(decodePDFRawStream(object).decode())
);
} catch {
continue;
}
} else {
collectText(object, out);
}
}
return out.join('\n');
}
describe('redactDocumentData', () => {
it('removes the redacted text from every place outside the page content', async () => {
const input = await buildDocument();
expect((await allText(input)).toLowerCase()).toContain(
SECRET.toLowerCase()
);
const output = await redactDocumentData(input, [secret()]);
const text = await allText(output);
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
expect(text).toContain('Keep chapter');
expect(text).toContain('keep note');
expect(text).toContain('Keep Author');
expect(text).toContain('harmless');
expect(text).toContain('readme.txt');
const result = await PDFDocument.load(output, { updateMetadata: false });
const annots = result.getPages()[0].node.Annots();
expect(annots?.size()).toBe(2);
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
});
it('keeps dates intact when the redacted text is short', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const created = new Date(Date.UTC(2026, 0, 2, 3, 4, 5));
doc.setCreationDate(created);
doc.setModificationDate(created);
doc.setTitle('Invoice 20 of 2026');
const output = await redactDocumentData(await doc.save(), ['20']);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getCreationDate()?.toISOString()).toBe(created.toISOString());
expect(Number.isNaN(result.getModificationDate()?.getTime())).toBe(false);
expect(result.getTitle()).toBe('Invoice of 26');
});
it('removes the text from signature fields but keeps the signature value', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const signatureValue = PDFHexString.of('3082ABCDEF01');
const signature = doc.context.register(
doc.context.obj({
Type: 'Sig',
Name: PDFString.of(`Signed by ${SECRET}`),
Reason: PDFString.of(`Approved by ${SECRET}`),
Contents: signatureValue,
})
);
doc.catalog.set(PDFName.of('TestSig'), signature);
const output = await redactDocumentData(await doc.save(), [SECRET]);
const result = await PDFDocument.load(output, { updateMetadata: false });
const saved = result.catalog.lookup(PDFName.of('TestSig'), PDFDict);
expect(saved.lookup(PDFName.of('Name'), PDFHexString).decodeText()).toBe(
'Signed by '
);
expect(saved.lookup(PDFName.of('Reason'), PDFHexString).decodeText()).toBe(
'Approved by '
);
expect(
Array.from(saved.lookup(PDFName.of('Contents'), PDFHexString).asBytes())
).toEqual(Array.from(signatureValue.asBytes()));
});
it('removes several terms in one pass, longest match first', async () => {
const doc = await PDFDocument.create();
doc.addPage();
doc.setTitle('Report by John Smith for Acme and John');
doc.setSubject('acme corp');
const output = await redactDocumentData(await doc.save(), [
'John',
'John Smith',
'Acme',
]);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getTitle()).toBe('Report by for and ');
expect(result.getSubject()).toBe(' corp');
});
it('only drops thumbnails and leftovers when no search text is given', async () => {
const input = await buildDocument();
const output = await redactDocumentData(input);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
expect(result.getTitle()).toBe(`Report for ${SECRET}`);
});
});
+662
View File
@@ -0,0 +1,662 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
const { sanitizePdf, defaultSanitizeOptions } =
await import('../js/utils/sanitize.js');
const { parseContentOperations } =
await import('../js/utils/pdf-content-tokens.js');
const NONE = {
flattenForms: false,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
};
const latin1 = (bytes: Uint8Array) => Buffer.from(bytes).toString('latin1');
function collectText(value: PDFObject | undefined, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [key, v] of value.entries()) {
out.push(key.decodeText());
collectText(v, out);
}
} else if (value instanceof PDFArray) {
for (const v of value.asArray()) collectText(v, out);
}
}
async function allText(bytes: Uint8Array): Promise<string> {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const out: string[] = [latin1(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
out.push(latin1(decodePDFRawStream(object).decode()));
} else {
collectText(object, out);
}
}
return out.join('\n');
}
async function pageContent(doc: PDFDocument): Promise<string> {
const contents = doc.getPages()[0].node.Contents();
const streams =
contents instanceof PDFArray ? contents.asArray() : [contents];
return streams
.map((item) => doc.context.lookup(item))
.map((stream) =>
stream instanceof PDFRawStream
? latin1(decodePDFRawStream(stream).decode())
: ''
)
.join('\n');
}
function lookupDict(doc: PDFDocument, value: PDFObject | undefined) {
const result = doc.context.lookup(value);
return result instanceof PDFDict ? result : undefined;
}
async function buildActionDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of('Chapter one'),
Parent: outlines,
Next: second,
A: ctx.register(
ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('outline-js')"),
})
),
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Chapter two'),
Parent: outlines,
Prev: first,
A: ctx.obj({
S: 'Launch',
F: PDFString.of('launch-calc.exe'),
}),
})
);
ctx.assign(
outlines,
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
);
doc.catalog.set(PDFName.of('Outlines'), outlines);
doc.catalog.set(
PDFName.of('OpenAction'),
ctx.obj([page.ref, PDFName.of('Fit')])
);
const nextJs = ctx.register(
ctx.obj({ S: 'JavaScript', JS: PDFString.of("app.alert('next-js')") })
);
const link = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 10, 100, 30],
A: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/keep'),
Next: nextJs,
}),
})
);
const chained = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 40, 100, 60],
A: ctx.obj({
S: 'SubmitForm',
F: PDFString.of('https://submit-url.example'),
Next: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/after-submit'),
}),
}),
})
);
const importData = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 70, 100, 90],
A: ctx.obj({ S: 'ImportData', F: PDFString.of('import-data.fdf') }),
})
);
const appearance = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 80 20 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 80, 20],
})
);
const parentField = ctx.nextRef();
const childField = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('child'),
Parent: parentField,
Rect: [10, 100, 90, 120],
P: page.ref,
AP: { N: appearance },
AA: {
K: ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('nested-aa-js')"),
}),
},
})
);
ctx.assign(
parentField,
ctx.obj({ T: PDFString.of('parent'), Kids: [childField] })
);
const xfa = ctx.register(
ctx.flateStream('<xdp:xdp><script>xfa-script</script></xdp:xdp>')
);
doc.catalog.set(
PDFName.of('AcroForm'),
ctx.obj({ Fields: [parentField], XFA: xfa })
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([link, chained, importData, childField])
);
return doc.save();
}
async function buildMetadataDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const xmp = (text: string) =>
ctx.register(
ctx.flateStream(`<x:xmpmeta>${text}</x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
);
const image = ctx.register(
ctx.stream(new Uint8Array([0, 128, 255, 64]), {
Type: 'XObject',
Subtype: 'Image',
Width: 2,
Height: 2,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
Metadata: xmp('image-xmp-secret'),
})
);
const form = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 10 10 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 10, 10],
Metadata: xmp('form-xmp-secret'),
PieceInfo: { App: { Private: PDFString.of('form-piece-secret') } },
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({ XObject: { Im1: image, Fm1: form } })
);
page.node.set(
PDFName.of('Contents'),
ctx.register(ctx.flateStream('q 100 0 0 100 0 0 cm /Im1 Do Q q /Fm1 Do Q'))
);
page.node.set(PDFName.of('Metadata'), xmp('page-xmp-secret'));
page.node.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('page-piece-secret') } })
);
page.node.set(
PDFName.of('Thumb'),
ctx.register(
ctx.flateStream('thumb-secret-pixels', {
Width: 1,
Height: 1,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
})
)
);
doc.catalog.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('catalog-piece-secret') } })
);
doc.catalog.set(PDFName.of('Metadata'), xmp('catalog-xmp-secret'));
return doc.save();
}
const PAGE_CONTENT = [
'q',
'/OC /MCon BDC 0 0 1 rg 10 10 50 50 re f',
'BT /F1 12 Tf 20 200 Td (ON-LAYER-TEXT) Tj ET',
'BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI',
'EMC',
'/OC /MCoff BDC 1 0 0 rg 100 100 m 200 200 l S',
'q 0 0 10 10 re W n Q',
'BT /F1 12 Tf 20 220 Td (OFF-LAYER-TEXT EMC (inner) \\) tail) Tj',
'<4f46462d484558> Tj % EMC in a comment',
'/Span <</ActualText (OFF-ACTUAL)>> BDC (x) Tj EMC ET',
'BI /W 2 /H 1 /BPC 8 /CS /G ID \x10\xff EI',
'/ImOff Do',
'EMC',
'/OC /MCmd BDC BT /F1 12 Tf 20 240 Td (OCMD-HIDDEN-TEXT) Tj ET EMC',
'/OC /MCve BDC BT /F1 12 Tf 20 260 Td (VE-HIDDEN-TEXT) Tj ET EMC',
'/FmOff Do',
'/FmOn Do',
'/ImOff Do',
'Q',
].join('\n');
async function buildLayerDocument(content = PAGE_CONTENT): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const on = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('On') }));
const off = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') }));
const ocmd = ctx.register(
ctx.obj({ Type: 'OCMD', OCGs: [on, off], P: 'AllOn' })
);
const ocmdVe = ctx.register(
ctx.obj({
Type: 'OCMD',
OCGs: [on],
VE: [PDFName.of('And'), on, [PDFName.of('Not'), on]],
})
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [on, off], D: { Order: [on, off], ON: [on], OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const formOff = ctx.register(
ctx.flateStream('BT /F1 12 Tf (FORM-OFF-TEXT) Tj ET', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: off,
Resources: { Font: { F1: font } },
})
);
const formOn = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-ON-TEXT) Tj ET /OC /P0 BDC BT /F1 12 Tf (FORM-NESTED-OFF) Tj ET EMC',
{
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: on,
Resources: { Font: { F1: font }, Properties: { P0: off } },
}
)
);
const imageOff = ctx.register(
ctx.stream('IMAGE-OFF-PIXELS', {
Type: 'XObject',
Subtype: 'Image',
Width: 4,
Height: 4,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
OC: off,
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCon: on, MCoff: off, MCmd: ocmd, MCve: ocmdVe },
XObject: { FmOff: formOff, FmOn: formOn, ImOff: imageOff },
})
);
page.node.set(PDFName.of('Contents'), ctx.register(ctx.flateStream(content)));
const square = (text: string, oc: PDFRef) =>
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 10, 50, 50],
Contents: PDFString.of(text),
OC: oc,
})
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([square('annot-off-layer', off), square('annot-on-layer', on)])
);
return doc.save();
}
describe('sanitize remove JavaScript', () => {
it('removes every reachable script, launch, submit and XFA but keeps links', async () => {
const input = await buildActionDocument();
const before = await allText(input);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
]) {
expect(before).toContain(secret);
}
const { bytes } = await sanitizePdf(input, {
...NONE,
removeJavascript: true,
});
const after = await allText(bytes);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
'JavaScript',
'XFA',
]) {
expect(after).not.toContain(secret);
}
expect(after).toContain('https://example.com/keep');
expect(after).toContain('https://example.com/after-submit');
expect(after).toContain('Chapter one');
expect(after).toContain('Chapter two');
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const annots = doc.getPages()[0].node.Annots()!;
const linkAction = lookupDict(
doc,
lookupDict(doc, annots.get(0))?.get(PDFName.of('A'))
);
expect(linkAction?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(linkAction?.has(PDFName.of('Next'))).toBe(false);
const promoted = lookupDict(
doc,
lookupDict(doc, annots.get(1))?.get(PDFName.of('A'))
);
expect(promoted?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(doc.catalog.get(PDFName.of('OpenAction'))).toBeInstanceOf(PDFArray);
});
});
describe('sanitize remove metadata', () => {
it('drops page, XObject and catalog metadata, piece info and thumbnails', async () => {
const input = await buildMetadataDocument();
const secrets = [
'image-xmp-secret',
'form-xmp-secret',
'form-piece-secret',
'page-xmp-secret',
'page-piece-secret',
'thumb-secret-pixels',
'catalog-piece-secret',
'catalog-xmp-secret',
];
const before = await allText(input);
for (const secret of secrets) expect(before).toContain(secret);
const { bytes } = await sanitizePdf(input, {
...NONE,
removeMetadata: true,
});
const after = await allText(bytes);
for (const secret of secrets) expect(after).not.toContain(secret);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const xObjects = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('XObject'), PDFDict);
expect(xObjects?.has(PDFName.of('Im1'))).toBe(true);
expect(xObjects?.has(PDFName.of('Fm1'))).toBe(true);
});
});
describe('sanitize remove layers', () => {
it('removes content in hidden layers and keeps visible layers', async () => {
const input = await buildLayerDocument();
const { bytes } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const content = await pageContent(doc);
expect(content).toContain('(ON-LAYER-TEXT) Tj');
expect(content).toContain('0 0 1 rg 10 10 50 50 re f');
expect(content).toContain('BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI');
expect(content).toContain('/FmOn Do');
expect(content).toMatch(/0 0 10 10 re\s+W\s+n/);
for (const hidden of [
'OFF-LAYER-TEXT',
'inner',
'tail',
'4f46462d484558',
'OFF-ACTUAL',
'OCMD-HIDDEN-TEXT',
'VE-HIDDEN-TEXT',
'200 200 l',
'/FmOff',
'/ImOff',
'\x10\xff',
]) {
expect(content).not.toContain(hidden);
}
expect(content.match(/\bq\b/g)?.length).toBe(
content.match(/\bQ\b/g)?.length
);
const text = await allText(bytes);
expect(text).toContain('FORM-ON-TEXT');
expect(text).toContain('annot-on-layer');
for (const hidden of [
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
'annot-off-layer',
'OCMD',
]) {
expect(text).not.toContain(hidden);
}
expect(doc.getPages()[0].node.Annots()?.size()).toBe(1);
});
it('removes hidden layers with the default options', async () => {
const { bytes } = await sanitizePdf(
await buildLayerDocument(),
defaultSanitizeOptions
);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const text = await allText(bytes);
expect(text).toContain('ON-LAYER-TEXT');
expect(text).toContain('FORM-ON-TEXT');
for (const hidden of [
'OFF-LAYER-TEXT',
'OCMD-HIDDEN-TEXT',
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
]) {
expect(text).not.toContain(hidden);
}
});
it('keeps the layers when hidden content cannot be parsed safely', async () => {
const input = await buildLayerDocument(
'BT /F1 12 Tf /OC /MCoff BDC (UNPARSABLE-OFF Tj EMC ET'
);
const { bytes, skipped } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual(['remove layers']);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(true);
const properties = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('Properties'), PDFDict);
expect(properties?.has(PDFName.of('MCoff'))).toBe(true);
});
});
describe('sanitize remove layers edge cases', () => {
it('uses page resources for forms without their own and ignores missing content streams', async () => {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const off = ctx.register(
ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') })
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [off], D: { OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const bareForm = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-VISIBLE-TEXT) Tj ET /OC /MCoff BDC BT /F1 12 Tf (INHERITED-OFF-TEXT) Tj ET EMC',
{ Type: 'XObject', Subtype: 'Form', BBox: [0, 0, 300, 300] }
)
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCoff: off },
XObject: { FmBare: bareForm },
})
);
page.node.set(
PDFName.of('Contents'),
ctx.obj([ctx.register(ctx.flateStream('/FmBare Do')), ctx.nextRef()])
);
const { bytes, skipped } = await sanitizePdf(await doc.save(), {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual([]);
const text = await allText(bytes);
expect(text).not.toContain('INHERITED-OFF-TEXT');
expect(text).toContain('FORM-VISIBLE-TEXT');
const result = await PDFDocument.load(bytes, { updateMetadata: false });
expect(result.catalog.has(PDFName.of('OCProperties'))).toBe(false);
});
});
describe('content stream tokenizer', () => {
it('handles strings, comments, dictionaries and inline images', () => {
const source =
'/OC /L1 BDC (a\\) EMC (b)) Tj <45 4d 43> Tj % EMC\n' +
'[(x) -20 (EMC)] TJ /P <</MCID 3 /Alt (EMC)>> BDC EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 ID \x00EI\x7f EI EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 /F /AHx ID 00ff00ff> EI EMC';
const bytes = new Uint8Array([...source].map((c) => c.charCodeAt(0)));
const ops = parseContentOperations(bytes);
expect(ops.map((op) => op.operator)).toEqual([
'BDC',
'Tj',
'Tj',
'TJ',
'BDC',
'EMC',
'BI',
'EMC',
'BI',
'EMC',
]);
expect(ops[4].operands[1].kind).toBe('dict');
});
it('rejects content it cannot tokenize', () => {
const encode = (text: string) =>
new Uint8Array([...text].map((c) => c.charCodeAt(0)));
expect(() => parseContentOperations(encode('(open Tj'))).toThrow();
expect(() => parseContentOperations(encode('<</A 1 BDC'))).toThrow();
expect(() => parseContentOperations(encode('BI /W 1 ID \x00'))).toThrow();
expect(() => parseContentOperations(encode('<zz> Tj'))).toThrow();
expect(() =>
parseContentOperations(encode('BI /F /Fl ID x EI\x00\x01 EI'))
).toThrow();
expect(() =>
parseContentOperations(encode('BI /W 9 /H 1 /CS /G /BPC 8 ID ab EI'))
).toThrow();
});
});
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { serializeJsonLd } from '../../scripts/json-ld.mjs';
import { sanitizeTranslationHtml } from '../js/utils/translation-html';
const LOCALES_DIR = path.resolve(__dirname, '../../public/locales');
const PAYLOAD =
'Merge</script><script src=https://cdn.jsdelivr.net/gh/a/b/c.js></script>';
function localeStrings(): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (typeof value === 'string') out.push(value);
else if (value && typeof value === 'object')
Object.values(value).forEach(visit);
};
for (const lang of fs.readdirSync(LOCALES_DIR)) {
const dir = path.join(LOCALES_DIR, lang);
if (!fs.statSync(dir).isDirectory()) continue;
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
visit(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
}
}
return out;
}
function parse(html: string): Document {
return new DOMParser().parseFromString(html, 'text/html');
}
function scriptsAfterRoundTrip(jsonLd: string): HTMLScriptElement[] {
const doc = parse('<!doctype html><html><head></head><body></body></html>');
const script = doc.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = jsonLd;
doc.body.appendChild(script);
return Array.from(
parse(doc.documentElement.outerHTML).querySelectorAll('script')
);
}
describe('JSON-LD serialization', () => {
it('raw JSON.stringify lets a translated name break out of the script', () => {
expect(
scriptsAfterRoundTrip(JSON.stringify({ name: PAYLOAD })).length
).toBeGreaterThan(1);
});
it('serializeJsonLd keeps the payload inside one ld+json script', () => {
const data = { name: `${PAYLOAD}\u2028\u2029<!--` };
const scripts = scriptsAfterRoundTrip(serializeJsonLd(data));
expect(scripts).toHaveLength(1);
expect(scripts[0].textContent).not.toMatch(/[<\u2028\u2029]/);
expect(JSON.parse(scripts[0].textContent || '')).toEqual(data);
});
});
describe('sanitizeTranslationHtml', () => {
it('keeps the allowed formatting tags', () => {
expect(
sanitizeTranslationHtml('a<br>b <strong>c</strong> <em>d</em>')
).toBe('a<br>b <strong>c</strong> <em>d</em>');
});
it('strips scripts, event handlers, links and attributes', () => {
expect(sanitizeTranslationHtml(PAYLOAD)).toBe('Merge');
expect(sanitizeTranslationHtml('<img src=x onerror=alert(1)>x')).toBe('x');
expect(
sanitizeTranslationHtml('<a href="javascript:alert(1)">go</a>')
).toBe('go');
expect(
sanitizeTranslationHtml('<strong onclick="alert(1)" class="x">y</strong>')
).toBe('<strong>y</strong>');
expect(
sanitizeTranslationHtml('<iframe srcdoc="<script>alert(1)</script>">')
).toBe('');
});
it('leaves every shipped locale string intact', () => {
const withMarkup = localeStrings().filter((s) => s.includes('<'));
expect(withMarkup.length).toBeGreaterThan(0);
for (const value of withMarkup) {
expect(sanitizeTranslationHtml(value)).toBe(parse(value).body.innerHTML);
}
});
});
@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import forge from 'node-forge';
import { signPdf, parsePemFiles } from '@/js/logic/digital-sign-pdf';
import {
countSignatures,
extractSignatures,
validatePdfSignatures,
} from '@/js/logic/validate-signature-pdf';
import type { CertificateData } from '@/types';
const RANGE_PLACEHOLDER = '{{RANGE}}';
const RANGE_WIDTH = 64;
const HEX_LENGTH = 8192;
const SIG_HEAD =
'%PDF-1.7\n1 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached ';
const SIG_TAIL =
' /M (D:20260101120000Z) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
cert.setSubject([{ name: 'commonName', value: 'Test Signer' }]);
cert.setIssuer([{ name: 'commonName', value: 'Test Signer' }]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certificateData: CertificateData = parsePemFiles(
forge.pki.certificateToPem(cert),
forge.pki.privateKeyToPem(keys.privateKey)
);
const samplePdf = new Uint8Array(
fs.readFileSync(path.resolve(__dirname, './fixtures/sample.pdf'))
);
function signedCmsHex(content: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(content);
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
return forge.util.bytesToHex(forge.asn1.toDer(p7.toAsn1()).getBytes());
}
function buildSignedPdf(layout: {
prefix?: string;
head: string;
gap: (hex: string) => string;
tail: string;
}): Uint8Array {
const prefix = layout.prefix ?? '';
const headLength =
layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
const gapLength = layout.gap('0'.repeat(HEX_LENGTH)).length;
const byteRange = [
prefix.length,
headLength,
prefix.length + headLength + gapLength,
layout.tail.length,
];
const head = layout.head.replace(
RANGE_PLACEHOLDER,
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
);
const hex = signedCmsHex(head + layout.tail).padEnd(HEX_LENGTH, '0');
return new TextEncoder().encode(
prefix + head + layout.gap(hex) + layout.tail
);
}
function appendIncrementalUpdate(pdfBytes: Uint8Array): Uint8Array {
const update = new TextEncoder().encode(
'\n2 0 obj\n<< /Type /Annot /Subtype /Text /Contents (Added later) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n'
);
const updated = new Uint8Array(pdfBytes.length + update.length);
updated.set(pdfBytes, 0);
updated.set(update, pdfBytes.length);
return updated;
}
describe('validatePdfSignatures extraction and coverage', () => {
it('validates a signature made by the app signer with full coverage', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: {
reason: 'Approved (final) é',
location: '東京',
contactInfo: '[email protected]',
},
});
const results = await validatePdfSignatures(signed);
expect(countSignatures(signed)).toBe(1);
expect(results).toHaveLength(1);
expect(results[0].cryptoVerified).toBe(true);
expect(results[0].coverageStatus).toBe('full');
expect(results[0].isValid).toBe(true);
expect(results[0].errorMessage).toBeUndefined();
expect(results[0].signerName).toBe('Test Signer');
expect(results[0].reason).toBe('Approved (final) é');
expect(results[0].location).toBe('東京');
expect(results[0].contactInfo).toBe('[email protected]');
expect(results[0].signatureDate).toBeInstanceOf(Date);
});
it('validates a hand-assembled signature whose gap is exactly its /Contents', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Prop_Build << /App << /Name /Builder >> >> /Name (Real Signer) /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const [result] = await validatePdfSignatures(pdfBytes);
expect(result.isValid).toBe(true);
expect(result.coverageStatus).toBe('full');
expect(result.signatureDate).toBeInstanceOf(Date);
expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer');
});
it('reports an intact signature as partial when an incremental update follows', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'Original' },
});
const [result] = await validatePdfSignatures(
appendIncrementalUpdate(signed)
);
expect(result.cryptoVerified).toBe(true);
expect(result.cryptoVerificationStatus).toBe('verified');
expect(result.coverageStatus).toBe('partial');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toBeUndefined();
expect(result.reason).toBe('Original');
});
it('keeps the earlier signature partial and the latest one full in a multi-signed file', async () => {
const first = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'First' },
});
const second = await signPdf(first, certificateData, {
signatureInfo: { reason: 'Second' },
});
const results = await validatePdfSignatures(second);
expect(countSignatures(second)).toBe(2);
expect(results.map((r) => r.reason)).toEqual(['First', 'Second']);
expect(results.map((r) => r.coverageStatus)).toEqual(['partial', 'full']);
expect(results.map((r) => r.cryptoVerified)).toEqual([true, true]);
expect(results.map((r) => r.errorMessage)).toEqual([undefined, undefined]);
expect(results[1].isValid).toBe(true);
});
it('rejects a decoy /Contents whose gap holds a different signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}/Contents <> ${RANGE_PLACEHOLDER} /Decoy /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].coverageStatus).toBe('unknown');
expect(results[0].errorMessage).toMatch(
/not the \/Contents of this signature/
);
});
it('rejects a ByteRange that does not start at byte 0', async () => {
const pdfBytes = buildSignedPdf({
prefix:
'%PDF-1.7\n9 0 obj\n<< /Type /Catalog /Unsigned true >>\nendobj\n',
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/does not start at the beginning of the file/
);
});
it('rejects a gap that carries unsigned bytes after the hex signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}> /Reason (Approved by the CEO)`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/not exactly a hex-encoded signature/
);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import forge from 'node-forge';
import { validateSignature } from '@/js/logic/validate-signature-pdf';
interface Identity {
cert: forge.pki.Certificate;
key: forge.pki.rsa.PrivateKey;
}
let serial = 1;
function makeIdentity(
commonName: string,
isCa: boolean,
issuer?: Identity
): Identity {
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = (serial++).toString(16).padStart(2, '0');
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
const subject = [{ name: 'commonName', value: commonName }];
cert.setSubject(subject);
cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject);
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create());
return { cert, key: keys.privateKey };
}
function signContent(
content: Uint8Array,
signer: Identity,
bundle: forge.pki.Certificate[]
): Uint8Array {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(
String.fromCharCode(...Array.from(content))
);
for (const cert of bundle) p7.addCertificate(cert);
p7.addSigner({
key: signer.key,
certificate: signer.cert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
return Uint8Array.from(der, (c) => c.charCodeAt(0));
}
async function check(
signer: Identity,
bundle: forge.pki.Certificate[],
trusted: forge.pki.Certificate
) {
const pdfBytes = new TextEncoder().encode('%PDF-1.7 signed content');
const contents = signContent(pdfBytes, signer, bundle);
return validateSignature(
{
index: 0,
contents,
byteRange: [0, pdfBytes.length, pdfBytes.length, 0],
},
pdfBytes,
trusted
);
}
describe('validateSignature trust chain', () => {
const root = makeIdentity('Company Root CA', true);
const intermediate = makeIdentity('Company Issuing CA', true, root);
const employee = makeIdentity('Real Employee', false, root);
const chainedEmployee = makeIdentity('Chained Employee', false, intermediate);
const attacker = makeIdentity('John Smith', false);
it('does not trust a self-made signer bundled with an unrelated company certificate', async () => {
const result = await check(
attacker,
[attacker.cert, employee.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('identifies the real signer even when another certificate is listed first', async () => {
const result = await check(
attacker,
[employee.cert, attacker.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer issued directly by the trusted certificate', async () => {
const result = await check(employee, [employee.cert], root.cert);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('trusts a signer that chains to the trusted certificate through an intermediate CA', async () => {
const result = await check(
chainedEmployee,
[chainedEmployee.cert, intermediate.cert],
root.cert
);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('does not accept a non-CA certificate as an intermediate', async () => {
const issuedByLeaf = makeIdentity('Issued By Leaf', false, employee);
const result = await check(
issuedByLeaf,
[issuedByLeaf.cert, employee.cert],
root.cert
);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer whose own certificate is the trusted certificate', async () => {
const result = await check(attacker, [attacker.cert], attacker.cert);
expect(result.isTrusted).toBe(true);
});
});
@@ -0,0 +1,306 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createNodeByType, nodeRegistry } from '@/js/workflow/nodes/registry';
import type { BaseWorkflowNode } from '@/js/workflow/nodes/base-node';
import {
getControlSpec,
MAX_TEXT_CONTROL_LENGTH,
} from '@/js/workflow/nodes/control-specs';
import {
exportWorkflow,
getSavedTemplateNames,
loadWorkflow,
saveWorkflow,
} from '@/js/workflow/serialization';
vi.mock('heic2any', () => ({ default: vi.fn() }));
type Editor = Parameters<typeof loadWorkflow>[0];
type Area = Parameters<typeof loadWorkflow>[1];
const TEMPLATES_KEY = 'bento-pdf-workflow-templates';
function createFakeEditor(initial: BaseWorkflowNode[] = []) {
const nodes = new Map<string, BaseWorkflowNode>(
initial.map((node) => [node.id, node])
);
const editor = {
getNodes: () => [...nodes.values()],
getConnections: (): unknown[] => [],
getNode: (id: string) => nodes.get(id),
addNode: async (node: BaseWorkflowNode) => {
nodes.set(node.id, node);
return true;
},
removeNode: async (id: string) => nodes.delete(id),
removeConnection: async () => true,
addConnection: async () => true,
};
const area = {
nodeViews: new Map(),
translate: async () => true,
};
return {
nodes,
editor: editor as unknown as Editor,
area: area as unknown as Area,
};
}
function controlValue(node: BaseWorkflowNode, key: string): unknown {
const control = node.controls[key];
return control && 'value' in control ? control.value : undefined;
}
function setControl(node: BaseWorkflowNode, key: string, value: unknown) {
const control = node.controls[key];
if (!control || !('value' in control)) throw new Error(`missing ${key}`);
control.value = value;
}
function storeTemplate(name: string, json: string) {
localStorage.setItem(TEMPLATES_KEY, `{"${name}":${json}}`);
}
function workflowJson(
nodes: { type: string; controls: Record<string, unknown> }[]
): string {
return JSON.stringify({
version: 1,
nodes: nodes.map((node, index) => ({
id: `n${index}`,
type: node.type,
position: { x: 0, y: 0 },
controls: node.controls,
})),
connections: [],
});
}
async function loadNodes(json: string): Promise<BaseWorkflowNode[]> {
storeTemplate('imported', json);
const { editor, area, nodes } = createFakeEditor();
expect(await loadWorkflow(editor, area, 'imported')).toBe(true);
return [...nodes.values()];
}
function findNode(nodes: BaseWorkflowNode[], type: string): BaseWorkflowNode {
const node = nodes.find((candidate) => candidate.nodeType === type);
if (!node) throw new Error(`missing ${type}`);
return node;
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('workflow import validation', () => {
it('replaces select values outside the option list with the default', async () => {
const nodes = await loadNodes(
workflowJson([
{
type: 'RedactNode',
controls: { redactMode: 'Area', text: 'secret' },
},
{ type: 'NUpNode', controls: { pagesPerSheet: 7 } },
{ type: 'RotateNode', controls: { angle: '45' } },
{ type: 'CompressNode', controls: { algorithm: 'photon' } },
])
);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'redactMode')).toBe('text');
expect(controlValue(redact, 'text')).toBe('secret');
expect(controlValue(findNode(nodes, 'NUpNode'), 'pagesPerSheet')).toBe(4);
expect(controlValue(findNode(nodes, 'RotateNode'), 'angle')).toBe('90');
expect(controlValue(findNode(nodes, 'CompressNode'), 'algorithm')).toBe(
'photon'
);
});
it('keeps only known multi-select values', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'OCRNode', controls: { language: 'eng+../../x+fra+eng' } },
{ type: 'OCRNode', controls: { language: 'zz+../evil' } },
])
);
const [mixed, unknown] = nodes;
expect(controlValue(mixed, 'language')).toBe('eng+fra');
expect(controlValue(unknown, 'language')).toBe('eng');
});
it('clamps out-of-range numbers and resets non-finite ones', async () => {
const json = workflowJson([
{
type: 'WatermarkNode',
controls: { opacity: 5000, fontSize: 1, angle: 0 },
},
{ type: 'PosterizeNode', controls: { rows: 0, cols: 0 } },
{ type: 'RedactNode', controls: { x0: -50, x1: 0 } },
])
.replace('"angle":0', '"angle":-1e999')
.replace('"cols":0', '"cols":1e999')
.replace('"x1":0', '"x1":1e999');
const nodes = await loadNodes(json);
const watermark = findNode(nodes, 'WatermarkNode');
expect(controlValue(watermark, 'opacity')).toBe(100);
expect(controlValue(watermark, 'fontSize')).toBe(10);
expect(controlValue(watermark, 'angle')).toBe(-45);
const posterize = findNode(nodes, 'PosterizeNode');
expect(controlValue(posterize, 'rows')).toBe(1);
expect(controlValue(posterize, 'cols')).toBe(2);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'x0')).toBe(0);
expect(controlValue(redact, 'x1')).toBe(200);
const scanner = createNodeByType('ScannerEffectNode');
expect(scanner?.sanitizeControlValue('resolution', Number.NaN)).toBe(150);
});
it('validates booleans, colors and text length', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'SanitizeNode', controls: { removeJavascript: 'nope' } },
{ type: 'BackgroundColorNode', controls: { color: 'red;x' } },
{
type: 'EditMetadataNode',
controls: { title: 'a'.repeat(MAX_TEXT_CONTROL_LENGTH + 500) },
},
])
);
expect(
controlValue(findNode(nodes, 'SanitizeNode'), 'removeJavascript')
).toBe('true');
expect(controlValue(findNode(nodes, 'BackgroundColorNode'), 'color')).toBe(
'#ffffff'
);
expect(
String(controlValue(findNode(nodes, 'EditMetadataNode'), 'title'))
).toHaveLength(MAX_TEXT_CONTROL_LENGTH);
});
it('skips inherited property names used as node types', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'constructor', controls: {} },
{ type: 'toString', controls: {} },
{ type: '__proto__', controls: {} },
{ type: 'hasOwnProperty', controls: {} },
{ type: 'RotateNode', controls: { constructor: 'x', angle: '180' } },
])
);
expect(nodes).toHaveLength(1);
expect(nodes[0].nodeType).toBe('RotateNode');
expect(controlValue(nodes[0], 'angle')).toBe('180');
expect(createNodeByType('constructor')).toBeNull();
expect(createNodeByType('toString')).toBeNull();
});
it('covers every registered node control with a valid spec', () => {
for (const type of Object.keys(nodeRegistry)) {
const node = createNodeByType(type);
expect(node).not.toBeNull();
if (!node) continue;
for (const key of Object.keys(node.controls)) {
const initial = controlValue(node, key);
const spec = getControlSpec(type, key);
if (/password/i.test(key)) {
expect(spec?.kind, `${type}.${key}`).toBe('password');
continue;
}
if (typeof initial === 'number') {
expect(['number', 'select'], `${type}.${key}`).toContain(spec?.kind);
expect(
node.sanitizeControlValue(key, Number.POSITIVE_INFINITY),
`${type}.${key}`
).toBe(initial);
}
expect(node.sanitizeControlValue(key, initial), `${type}.${key}`).toBe(
initial
);
}
}
});
});
describe('workflow password controls', () => {
function createSecretNodes(): BaseWorkflowNode[] {
const encrypt = createNodeByType('EncryptNode');
const decrypt = createNodeByType('DecryptNode');
if (!encrypt || !decrypt) throw new Error('missing nodes');
setControl(encrypt, 'userPassword', 'user-secret-1');
setControl(encrypt, 'ownerPassword', 'owner-secret-2');
setControl(decrypt, 'password', 'decrypt-secret-3');
return [encrypt, decrypt];
}
const secrets = ['user-secret-1', 'owner-secret-2', 'decrypt-secret-3'];
it('never writes passwords into saved templates', () => {
const { editor, area } = createFakeEditor(createSecretNodes());
saveWorkflow(editor, area, 'secure');
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
expect(stored).toContain('"userPassword":""');
});
it('never writes passwords into exported workflow JSON', async () => {
const blobs: Blob[] = [];
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
blobs.push(blob as Blob);
return 'blob:workflow';
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const nodes = createSecretNodes();
const { editor, area } = createFakeEditor(nodes);
exportWorkflow(editor, area);
expect(blobs).toHaveLength(1);
const exported = await blobs[0].text();
for (const secret of secrets) expect(exported).not.toContain(secret);
expect(controlValue(nodes[0], 'userPassword')).toBe('user-secret-1');
});
it('drops passwords from imported workflows and legacy templates', async () => {
storeTemplate(
'legacy',
workflowJson([
{
type: 'EncryptNode',
controls: {
userPassword: 'user-secret-1',
ownerPassword: 'owner-secret-2',
},
},
{ type: 'DecryptNode', controls: { password: 'decrypt-secret-3' } },
])
);
expect(getSavedTemplateNames()).toEqual(['legacy']);
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
const legacyJson = workflowJson([
{ type: 'EncryptNode', controls: { userPassword: 'user-secret-1' } },
]);
const nodes = await loadNodes(legacyJson);
expect(controlValue(nodes[0], 'userPassword')).toBe('');
expect(
createNodeByType('DecryptNode')?.sanitizeControlValue(
'password',
'decrypt-secret-3'
)
).toBe('');
});
});
+1 -1
View File
@@ -1 +1 @@
813903c93c75
051abe5b72a9
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
813903c93c75
051abe5b72a9
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -97,7 +97,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
"value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/ https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/dist/ https://cdn.jsdelivr.net/npm/[email protected]/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
},
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
@@ -168,7 +168,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
"value": "default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
}
]
},
@@ -177,7 +177,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
"value": "default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
}
]
},