mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 11:52:48 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ca7238bb |
@@ -45,7 +45,7 @@ jobs:
|
||||
run: npm run package
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@v2
|
||||
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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
build-args: |
|
||||
@@ -111,11 +111,12 @@ jobs:
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64
|
||||
platforms: linux/amd64
|
||||
no-cache: true
|
||||
cache-from: type=gha,scope=amd64-${{ matrix.mode.name }}
|
||||
cache-to: type=gha,mode=max,scope=amd64-${{ matrix.mode.name }}
|
||||
|
||||
- name: Build and push amd64 ${{ matrix.mode.name }} (edge)
|
||||
if: steps.version.outputs.is_release == 'false'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
build-args: |
|
||||
@@ -145,10 +146,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -171,7 +172,7 @@ jobs:
|
||||
|
||||
- name: Build and push arm64 ${{ matrix.mode.name }} (release)
|
||||
if: steps.version.outputs.is_release == 'true'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
build-args: |
|
||||
@@ -179,11 +180,12 @@ jobs:
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64
|
||||
platforms: linux/arm64
|
||||
no-cache: true
|
||||
cache-from: type=gha,scope=arm64-${{ matrix.mode.name }}
|
||||
cache-to: type=gha,mode=max,scope=arm64-${{ matrix.mode.name }}
|
||||
|
||||
- name: Build and push arm64 ${{ matrix.mode.name }} (edge)
|
||||
if: steps.version.outputs.is_release == 'false'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
build-args: |
|
||||
@@ -209,7 +211,7 @@ jobs:
|
||||
suffix: "-simple"
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -273,14 +275,14 @@ jobs:
|
||||
suffix: "-simple"
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md'
|
||||
|
||||
# Branch to store signatures
|
||||
branch: 'cla-signatures'
|
||||
branch: 'main'
|
||||
|
||||
# Allowlist for bot accounts (won't need to sign CLA)
|
||||
allowlist: 'bot*,*[bot],dependabot*,renovate*,github-actions*'
|
||||
|
||||
@@ -6,7 +6,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
|
||||
- uses: JamesIves/github-sponsors-readme-action@v1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
||||
file: 'README.md'
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image for scanning
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.file }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
no-cache: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ matrix.image.name }}:scan
|
||||
format: sarif
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan npm dependencies with Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan Dockerfiles for misconfigurations
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: config
|
||||
scan-ref: .
|
||||
|
||||
@@ -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/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'"
|
||||
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'"
|
||||
</IfModule>
|
||||
|
||||
# Block source maps from being served
|
||||
|
||||
@@ -62,17 +62,6 @@ 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
@@ -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.9
|
||||
version: 1.0.8
|
||||
|
||||
# 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.10'
|
||||
appVersion: '2.8.9'
|
||||
|
||||
@@ -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, per /64 for IPv6 (requires KV namespace; fails open on KV errors)
|
||||
- **Rate limiting**: 100 requests/minute per IP (requires KV namespace)
|
||||
- **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.)
|
||||
- **Size limits**: Max 100MB per file
|
||||
- **Caching**: Reduces origin requests and improves performance
|
||||
|
||||
+76
-166
@@ -81,134 +81,53 @@ 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) {
|
||||
const host = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '');
|
||||
|
||||
if (['localhost', 'localhost.localdomain'].includes(host)) {
|
||||
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)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isIpLiteral(host) && isBlockedIp(host);
|
||||
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;
|
||||
}
|
||||
|
||||
async function hostnameResolvesToPrivate(hostname) {
|
||||
const clean = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '');
|
||||
if (isPrivateOrReservedHost(clean)) return true;
|
||||
if (isIpLiteral(clean)) return false;
|
||||
const clean = hostname.replace(/^\[|\]$/g, '');
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) {
|
||||
return isPrivateOrReservedHost(clean);
|
||||
}
|
||||
const ips = [];
|
||||
for (const type of ['A', 'AAAA']) {
|
||||
try {
|
||||
@@ -219,16 +138,14 @@ async function hostnameResolvesToPrivate(hostname) {
|
||||
if (!res.ok) return true;
|
||||
const data = await res.json();
|
||||
for (const ans of data.Answer || []) {
|
||||
if (ans && (ans.type === 1 || ans.type === 28) && ans.data) {
|
||||
ips.push(String(ans.data));
|
||||
}
|
||||
if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, ''));
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (ips.length === 0) return true;
|
||||
return ips.some((ip) => isBlockedIp(ip));
|
||||
return ips.some((ip) => isPrivateOrReservedHost(ip));
|
||||
}
|
||||
|
||||
function isValidCertificateUrl(urlString) {
|
||||
@@ -455,56 +372,49 @@ export default {
|
||||
}
|
||||
|
||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||
const rateLimitKey = `ratelimit:${getRateLimitKey(clientIP)}`;
|
||||
const rateLimitKey = `ratelimit:${clientIP}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (env.RATE_LIMIT_KV) {
|
||||
try {
|
||||
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
|
||||
type: 'json',
|
||||
});
|
||||
const requests = rateLimitData?.requests || [];
|
||||
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(
|
||||
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
await env.RATE_LIMIT_KV.put(
|
||||
rateLimitKey,
|
||||
JSON.stringify({ requests: recentRequests }),
|
||||
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
|
||||
),
|
||||
}),
|
||||
{
|
||||
expirationTtl: 120,
|
||||
status: 429,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': Math.ceil(
|
||||
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
} 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'
|
||||
|
||||
@@ -39,38 +39,9 @@ const ALLOWED_EXTENSIONS = [
|
||||
'.mem',
|
||||
'.asm.js',
|
||||
'.worker.js',
|
||||
'.whl',
|
||||
'.html',
|
||||
];
|
||||
|
||||
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) =>
|
||||
@@ -95,7 +66,6 @@ function corsHeaders(origin) {
|
||||
'Access-Control-Expose-Headers':
|
||||
'Content-Length, Content-Range, Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +89,7 @@ function getContentType(pathname) {
|
||||
'.so': 'application/octet-stream',
|
||||
'.zip': 'application/zip',
|
||||
'.mem': 'application/octet-stream',
|
||||
'.html': 'text/html',
|
||||
};
|
||||
return contentTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
@@ -144,34 +115,7 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
|
||||
? sourceBaseUrl.slice(0, -1)
|
||||
: sourceBaseUrl;
|
||||
const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`;
|
||||
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const targetUrl = `${normalizedBase}${normalizedPath}`;
|
||||
|
||||
if (!isAllowedFile(normalizedPath)) {
|
||||
return new Response(
|
||||
@@ -207,6 +151,8 @@ 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,
|
||||
@@ -258,14 +204,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: 'Failed to fetch the requested resource',
|
||||
message: error.message,
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
@@ -314,49 +260,42 @@ export default {
|
||||
|
||||
if (env.RATE_LIMIT_KV) {
|
||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||
const rateLimitKey = `wasm-ratelimit:${getRateLimitKey(clientIP)}`;
|
||||
const rateLimitKey = `wasm-ratelimit:${clientIP}`;
|
||||
const now = Date.now();
|
||||
|
||||
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 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.`,
|
||||
}),
|
||||
{
|
||||
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 }),
|
||||
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.`,
|
||||
}),
|
||||
{
|
||||
expirationTtl: 120,
|
||||
status: 429,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '60',
|
||||
},
|
||||
}
|
||||
);
|
||||
} 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/')) {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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$/, '')
|
||||
|
||||
@@ -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 minute per IP or IPv6 /64 (requires KV, fails open on KV errors) |
|
||||
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||
|
||||
### DNS rebinding (self-hosting off Cloudflare)
|
||||
|
||||
@@ -126,11 +126,10 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
- Redaction tools for permanently removing sensitive content
|
||||
- Shape tools for rectangles, circles, lines, and arrows
|
||||
- Image insertion onto PDF pages
|
||||
- Built-in text search across the document
|
||||
@@ -37,7 +37,6 @@ 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
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"version": "2.8.9",
|
||||
"version": "2.8.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bento-pdf",
|
||||
"version": "2.8.9",
|
||||
"version": "2.8.8",
|
||||
"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-051abe5b72a9.tgz",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
|
||||
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.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-051abe5b72a9",
|
||||
"resolved": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-051abe5b72a9.tgz",
|
||||
"integrity": "sha512-ctM0Y6MEVa2UQO8+uD7/GYzl7GWyrEJJxzUJURHJttJKzxn02DtNpyEWgcLREXnfaFIo7UJ4j5sFmioVGi/WpQ==",
|
||||
"version": "0.0.0-813903c93c75",
|
||||
"resolved": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
|
||||
"integrity": "sha512-Qse8wclamZdqLx7IbYpkFLsNcCFpj2+iJBbaiNnWU7136ZZwwq5lw4HWYGBOmQL6VQ4q8hJ1ID9XdSxBGvYCwQ==",
|
||||
"license": "AGPL-3.0"
|
||||
},
|
||||
"node_modules/bentopdf-viewer": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
|
||||
"integrity": "sha512-A+utjVLgFdE/MA2mlPLimi7jXdhMXex6RozdcJbt0YgNj4tt1W3ihk9QYbVi3dUHm2zfwGPntE+CSn1d8OGoZw==",
|
||||
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.tgz",
|
||||
"integrity": "sha512-mlWp2VxU2Xq53K6pnvCQAv4PvCsVq10+vAMJaoeFvr0AYjpHT347UzHHXUS9bEZHm5XcinJFIvuePcDA1TSvJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"preact": "^10.17.0",
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"private": true,
|
||||
"version": "2.8.10",
|
||||
"version": "2.8.9",
|
||||
"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-051abe5b72a9.tgz",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-051abe5b72a9.tgz",
|
||||
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-813903c93c75.tgz",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-813903c93c75.tgz",
|
||||
"blob-stream": "^0.1.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
|
||||
+5
-11
@@ -313,17 +313,11 @@
|
||||
Use the redaction tool in the
|
||||
<a href="/edit-pdf" class="text-indigo-300 hover:text-indigo-400"
|
||||
>PDF Editor</a
|
||||
>. 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.
|
||||
>, 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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
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'
|
||||
+143
-253
@@ -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,322 +25,212 @@ 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();
|
||||
}
|
||||
|
||||
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;
|
||||
console.log(`${color}${title} (${items.length}):${colors.reset}`);
|
||||
items.forEach(item => {
|
||||
console.log(` ${colors.dim}•${colors.reset} ${item}`);
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (languages.length === 0) {
|
||||
console.error(`${colors.red}✗ No translation files found in ${LOCALES_DIR}${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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Extra keys
|
||||
if (extra.length > 0) {
|
||||
hasIssues = true;
|
||||
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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`
|
||||
);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
// All good
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`${colors.green} No missing or extra keys!${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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -2,7 +2,6 @@ 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);
|
||||
@@ -63,7 +62,7 @@ function fixApplicationCategory(document) {
|
||||
data.applicationCategory !== 'UtilitiesApplication'
|
||||
) {
|
||||
data.applicationCategory = 'UtilitiesApplication';
|
||||
script.textContent = serializeJsonLd(data);
|
||||
script.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +92,15 @@ function injectFaqSchema(document) {
|
||||
if (items.length === 0) return false;
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.textContent = serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items,
|
||||
});
|
||||
script.textContent = JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
document.body.appendChild(script);
|
||||
return true;
|
||||
}
|
||||
@@ -106,13 +109,17 @@ function injectWebsiteSchema(document) {
|
||||
if (hasLdType(document, 'WebSite')) return;
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.textContent = serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'BentoPDF',
|
||||
alternateName: ['Bento PDF', 'BentoPDF.com'],
|
||||
url: `${SITE_URL}/`,
|
||||
});
|
||||
script.textContent = JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'BentoPDF',
|
||||
alternateName: ['Bento PDF', 'BentoPDF.com'],
|
||||
url: `${SITE_URL}/`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
|
||||
+75
-60
@@ -2,7 +2,6 @@ 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);
|
||||
@@ -124,15 +123,19 @@ function faqSchema(entries) {
|
||||
if (entries.length === 0) return '';
|
||||
return `
|
||||
<script type="application/ld+json">
|
||||
${serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: entries.map((f) => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
|
||||
})),
|
||||
})}
|
||||
${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
|
||||
)}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -243,47 +246,55 @@ ${renderFaqs(meta.faqs)}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
${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,
|
||||
${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,
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BentoPDF',
|
||||
url: SITE_URL,
|
||||
},
|
||||
})}
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
${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,
|
||||
},
|
||||
],
|
||||
})}
|
||||
${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
|
||||
)}
|
||||
</script>${faqSchema(faqEntries)}
|
||||
</body>
|
||||
</html>
|
||||
@@ -359,19 +370,23 @@ ${cards}
|
||||
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
${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,
|
||||
${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,
|
||||
},
|
||||
},
|
||||
})}
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,6 @@ 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);
|
||||
@@ -134,7 +133,7 @@ function injectOrganizationLd(document) {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.setAttribute(ORGANIZATION_LD_MARKER, '');
|
||||
script.textContent = serializeJsonLd(data);
|
||||
script.textContent = JSON.stringify(data, null, 2);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
@@ -215,7 +214,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.setAttribute(BREADCRUMB_MARKER, '');
|
||||
script.textContent = serializeJsonLd(ld);
|
||||
script.textContent = JSON.stringify(ld, null, 2);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -17,187 +16,103 @@ 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_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 DEFAULT_WASM_ORIGINS = {
|
||||
pymupdf: 'https://cdn.jsdelivr.net',
|
||||
gs: 'https://cdn.jsdelivr.net',
|
||||
cpdf: 'https://cdn.jsdelivr.net',
|
||||
};
|
||||
|
||||
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 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 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,
|
||||
];
|
||||
|
||||
function urlOrDefault(value, fallback) {
|
||||
return originOf(value) ? value : fallback;
|
||||
}
|
||||
const tesseractOrigins = uniq([
|
||||
originOf(process.env.VITE_TESSERACT_WORKER_URL),
|
||||
originOf(process.env.VITE_TESSERACT_CORE_URL),
|
||||
originOf(process.env.VITE_TESSERACT_LANG_URL),
|
||||
]);
|
||||
|
||||
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 corsProxyOrigin =
|
||||
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
|
||||
|
||||
const tesseractOrigins = uniq([
|
||||
originOf(env.VITE_TESSERACT_WORKER_URL),
|
||||
originOf(env.VITE_TESSERACT_CORE_URL),
|
||||
originOf(env.VITE_TESSERACT_LANG_URL),
|
||||
]);
|
||||
const ocrFontOrigin =
|
||||
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
|
||||
|
||||
const corsProxyOrigin =
|
||||
originOf(env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
|
||||
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
|
||||
const connectOrigins = uniq([
|
||||
...wasmOrigins,
|
||||
...tesseractOrigins,
|
||||
corsProxyOrigin,
|
||||
ocrFontOrigin,
|
||||
]);
|
||||
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
|
||||
|
||||
const ocrFontOrigin =
|
||||
originOf(env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
|
||||
const githubStarsDisabled =
|
||||
process.env.DISABLE_GITHUB_STARS === 'true' ||
|
||||
process.env.SIMPLE_MODE === 'true';
|
||||
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
|
||||
|
||||
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 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 githubStarsDisabled =
|
||||
env.DISABLE_GITHUB_STARS === 'true' || env.SIMPLE_MODE === 'true';
|
||||
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
|
||||
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 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 csp = directives.join('; ');
|
||||
const docsCsp = docsDirectives.join('; ');
|
||||
|
||||
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 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 csp = directives.join('; ');
|
||||
const docsCsp = docsDirectives.join('; ');
|
||||
const contents = `add_header Content-Security-Policy "${csp}" always;
|
||||
${commonHeaders}`;
|
||||
|
||||
const nginxHeaders = (policy) =>
|
||||
[['Content-Security-Policy', policy], ...COMMON_HEADERS]
|
||||
.map(([name, value]) => `add_header ${name} "${value}" always;\n`)
|
||||
.join('');
|
||||
const docsContents = `add_header Content-Security-Policy "${docsCsp}" always;
|
||||
${commonHeaders}`;
|
||||
|
||||
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();
|
||||
}
|
||||
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)`);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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);
|
||||
@@ -31,32 +29,6 @@ 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)) {
|
||||
@@ -72,18 +44,6 @@ 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}`);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function serializeJsonLd(data) {
|
||||
return JSON.stringify(data, null, 2)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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 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 X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
-13
@@ -3,10 +3,6 @@
|
||||
{
|
||||
"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"
|
||||
@@ -24,15 +20,6 @@
|
||||
"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,
|
||||
|
||||
@@ -679,6 +679,14 @@
|
||||
"created_at": "2026-10-18T05:24:56Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 904
|
||||
},
|
||||
{
|
||||
"name": "CumpsD",
|
||||
"id": 1201644,
|
||||
"comment_id": 6326889418,
|
||||
"created_at": "2026-10-21T21:24:45Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 944
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -405,7 +404,7 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateLoaded'))} <i data-lucide="check" class="inline w-4 h-4"></i>`;
|
||||
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`;
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
@@ -560,7 +559,7 @@ async function handlePasswordInput(): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateUnlocked'))} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
|
||||
certStatus.innerHTML = `${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';
|
||||
}
|
||||
|
||||
@@ -15,164 +15,13 @@ 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[] {
|
||||
@@ -328,10 +177,7 @@ async function handleFiles(files: FileList) {
|
||||
pdfWrapper.classList.remove('hidden');
|
||||
|
||||
const { default: EmbedPDF } = await import('bentopdf-viewer');
|
||||
const disabledCategories = [
|
||||
...getEditorDisabledCategories(),
|
||||
'document-export',
|
||||
];
|
||||
const disabledCategories = getEditorDisabledCategories();
|
||||
viewerInstance = EmbedPDF.init({
|
||||
disabledCategories,
|
||||
type: 'container',
|
||||
@@ -352,24 +198,9 @@ 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);
|
||||
});
|
||||
|
||||
@@ -427,32 +258,6 @@ 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);
|
||||
@@ -496,42 +301,6 @@ 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',
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../utils/flatten-annotations.js';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { flattenAnnotations } from '../utils/flatten-annotations.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
|
||||
@@ -18,6 +16,11 @@ const pageState: FlattenPdfState = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.files = [];
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
templateNameExists,
|
||||
deleteTemplate,
|
||||
} from '@/js/workflow/serialization';
|
||||
import { getControlSpec } from '@/js/workflow/nodes/control-specs';
|
||||
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
|
||||
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
|
||||
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
|
||||
|
||||
@@ -1124,8 +1124,181 @@ 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)',
|
||||
@@ -1160,17 +1333,15 @@ 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 (spec?.kind === 'multiselect') {
|
||||
const { options, separator } = spec;
|
||||
if (dropdownOptions[key] && multiSelectDropdowns.has(key)) {
|
||||
const selectedValues = new Set(
|
||||
currentValue ? currentValue.split(separator) : []
|
||||
currentValue ? currentValue.split('+') : []
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
container.className = 'flex flex-col gap-1';
|
||||
@@ -1193,7 +1364,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
function updateTags() {
|
||||
tagsDiv.innerHTML = '';
|
||||
for (const val of selectedValues) {
|
||||
const opt = options.find((o) => o.value === val);
|
||||
const opt = dropdownOptions[key].find((o) => o.value === val);
|
||||
if (!opt) continue;
|
||||
const tag = document.createElement('span');
|
||||
tag.className =
|
||||
@@ -1217,13 +1388,13 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
|
||||
function updateCtrl() {
|
||||
(ctrl as { value: string }).value =
|
||||
Array.from(selectedValues).join(separator);
|
||||
Array.from(selectedValues).join('+');
|
||||
}
|
||||
|
||||
function renderList(filter: string) {
|
||||
listDiv.innerHTML = '';
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
for (const opt of options) {
|
||||
for (const opt of dropdownOptions[key]) {
|
||||
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
|
||||
continue;
|
||||
const label = document.createElement('label');
|
||||
@@ -1256,10 +1427,10 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
updateTags();
|
||||
renderList('');
|
||||
wrapper.appendChild(container);
|
||||
} else if (spec?.kind === 'select') {
|
||||
} else if (dropdownOptions[key]) {
|
||||
const select = document.createElement('select');
|
||||
select.className = inputClass;
|
||||
for (const opt of spec.options) {
|
||||
for (const opt of dropdownOptions[key]) {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.value;
|
||||
option.textContent = opt.label;
|
||||
@@ -1267,14 +1438,13 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
ctrl.value =
|
||||
typeof ctrl.value === 'number' ? Number(select.value) : select.value;
|
||||
(ctrl as { value: string }).value = select.value;
|
||||
if (conditionalVisibility[key]) {
|
||||
applyConditionalVisibility(key, select.value);
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(select);
|
||||
} else if (spec?.kind === 'boolean') {
|
||||
} else if (booleanControls.has(key)) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
const isOn = currentValue === 'true';
|
||||
@@ -1291,7 +1461,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 (spec?.kind === 'color') {
|
||||
} else if (colorControls.has(key)) {
|
||||
const colorRow = document.createElement('div');
|
||||
colorRow.className = 'flex items-center gap-2';
|
||||
const colorInput = document.createElement('input');
|
||||
@@ -1319,25 +1489,18 @@ 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.value = node.sanitizeControlValue(key, num);
|
||||
(ctrl as { value: number }).value = num;
|
||||
}
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
input.value = String(ctrl.value ?? '');
|
||||
});
|
||||
wrapper.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = spec?.kind === 'password' ? 'password' : 'text';
|
||||
const isPasswordField = key === 'password' || key === 'ownerPassword';
|
||||
input.type = isPasswordField ? 'password' : 'text';
|
||||
input.className = inputClass;
|
||||
input.value = currentValue;
|
||||
input.addEventListener('input', () => {
|
||||
|
||||
@@ -149,25 +149,14 @@ async function runSanitize() {
|
||||
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
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();
|
||||
}
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -7,14 +7,8 @@ import {
|
||||
} from '../utils/helpers.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../utils/load-pdf-document.js';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../utils/flatten-annotations.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { flattenAnnotations } from '../utils/flatten-annotations.js';
|
||||
import type { SignState, PDFViewerWindow } from '@/types';
|
||||
|
||||
const signState: SignState = {
|
||||
@@ -291,7 +285,7 @@ async function applyAndSaveSignatures() {
|
||||
const pdfBytes = new Uint8Array(rawPdfBytes);
|
||||
const pdfDoc = await loadPdfDocument(pdfBytes);
|
||||
try {
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
pdfDoc.getForm().flatten();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (!msg.includes('getForm')) {
|
||||
@@ -304,9 +298,7 @@ async function applyAndSaveSignatures() {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('Could not flatten annotations:', msg);
|
||||
}
|
||||
const flattenedPdfBytes = await removeUnreferencedObjects(
|
||||
await pdfDoc.save()
|
||||
);
|
||||
const flattenedPdfBytes = await pdfDoc.save();
|
||||
|
||||
const blob = new Blob([new Uint8Array(flattenedPdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
|
||||
@@ -377,9 +377,9 @@ function createSignatureCard(
|
||||
result.cryptoVerified === true &&
|
||||
result.coverageStatus === 'partial'
|
||||
) {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Modified After Signing (signed part intact)';
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
statusText = 'Invalid — Modified After Signing (partial coverage)';
|
||||
} else {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
|
||||
@@ -1,296 +1,82 @@
|
||||
import forge from 'node-forge';
|
||||
import { SignatureValidationResult } from '@/types';
|
||||
import type { ExtractedSignatureWithError, PdfValueSpan } from '@/types';
|
||||
import { ExtractedSignature, SignatureValidationResult } from '@/types';
|
||||
|
||||
const INSECURE_DIGEST_OIDS = new Set<string>([
|
||||
'1.2.840.113549.2.5',
|
||||
'1.3.14.3.2.26',
|
||||
]);
|
||||
|
||||
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[] = [];
|
||||
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||
const signatures: ExtractedSignature[] = [];
|
||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
||||
|
||||
// Find all signature objects for /Type /Sig
|
||||
const objectHeader = /\d+\s+\d+\s+obj\b/g;
|
||||
let endobj = -1;
|
||||
const sigRegex = /\/Type\s*\/Sig\b/g;
|
||||
let sigMatch;
|
||||
let sigIndex = 0;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
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]}`;
|
||||
}
|
||||
|
||||
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: ExtractedSignatureWithError,
|
||||
signature: ExtractedSignature,
|
||||
pdfBytes: Uint8Array,
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): Promise<SignatureValidationResult> {
|
||||
@@ -313,13 +99,6 @@ 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,
|
||||
@@ -562,7 +341,13 @@ function hexToBytes(hex: string): Uint8Array {
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
|
||||
let actualLength = bytes.length;
|
||||
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
|
||||
actualLength--;
|
||||
}
|
||||
|
||||
return bytes.slice(0, actualLength);
|
||||
}
|
||||
|
||||
function getDigestAlgorithmName(oid: string): string {
|
||||
@@ -960,9 +745,8 @@ async function performCryptoVerification(
|
||||
if (!fields) {
|
||||
return { status: 'failed', reason: 'Could not parse signer info' };
|
||||
}
|
||||
const byteRangeError = getByteRangeError(byteRange, pdfBytes.length);
|
||||
if (byteRangeError) {
|
||||
return { status: 'failed', reason: byteRangeError };
|
||||
if (byteRange.length !== 4) {
|
||||
return { status: 'failed', reason: 'Malformed ByteRange' };
|
||||
}
|
||||
|
||||
const md = createMd(fields.digestOid);
|
||||
@@ -974,6 +758,17 @@ 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);
|
||||
|
||||
+4
-5
@@ -29,7 +29,6 @@ import {
|
||||
setStoredItem,
|
||||
removeStoredItem,
|
||||
} from './utils/safe-storage.js';
|
||||
import { sanitizeTranslationHtml } from './utils/translation-html.js';
|
||||
declare const __BRAND_NAME__: string;
|
||||
|
||||
const init = async () => {
|
||||
@@ -49,9 +48,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">${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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -899,7 +898,7 @@ const init = async () => {
|
||||
}
|
||||
|
||||
dom.warningTitle.textContent = title;
|
||||
dom.warningMessage.innerHTML = sanitizeTranslationHtml(message);
|
||||
dom.warningMessage.innerHTML = message;
|
||||
dom.warningModal.classList.remove('hidden');
|
||||
dom.warningModal.classList.add('flex');
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -66,7 +66,3 @@ 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';
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ExtractedSignature } from './validate-signature-type.ts';
|
||||
|
||||
export interface PdfValueSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ExtractedSignatureWithError extends ExtractedSignature {
|
||||
extractionError?: string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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' };
|
||||
@@ -7,65 +7,8 @@ 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[] = [];
|
||||
@@ -125,7 +68,9 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isHiddenAnnotation(annot)) {
|
||||
const flagsObj = annot.get(PDFName.of('F'));
|
||||
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
|
||||
if (flags & 0x02) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function convertToPdfA(
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-dSAFER',
|
||||
'-dNOSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
@@ -418,7 +418,7 @@ export async function convertFontsToOutlines(
|
||||
onProgress?.('Converting fonts to outlines...');
|
||||
|
||||
const args = [
|
||||
'-dSAFER',
|
||||
'-dNOSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -17,15 +17,6 @@ import {
|
||||
} 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) {
|
||||
@@ -42,20 +33,11 @@ function decodeStream(stream: PDFRawStream): Uint8Array | null {
|
||||
}
|
||||
}
|
||||
|
||||
function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
|
||||
function removeTextFromDocument(doc: PDFDocument, term: 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 needle = term.toLowerCase();
|
||||
const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const contains = (text: string) => text.toLowerCase().includes(needle);
|
||||
const valueContains = (value: PDFObject | undefined): boolean => {
|
||||
if (value instanceof PDFArray) return value.asArray().some(valueContains);
|
||||
const text = decodeText(value);
|
||||
@@ -198,11 +180,7 @@ function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -240,12 +218,11 @@ function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
|
||||
|
||||
export async function redactDocumentData(
|
||||
pdf: Uint8Array,
|
||||
terms: readonly string[] = []
|
||||
term?: string
|
||||
): Promise<Uint8Array> {
|
||||
const doc = await loadPdfDocument(pdf);
|
||||
const searchTerms = terms.filter((term) => term.length > 0);
|
||||
if (searchTerms.length > 0) {
|
||||
removeTextFromDocument(doc, searchTerms);
|
||||
if (term) {
|
||||
removeTextFromDocument(doc, term);
|
||||
} else {
|
||||
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
|
||||
}
|
||||
|
||||
+125
-518
@@ -1,24 +1,9 @@
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFDict,
|
||||
PDFArray,
|
||||
PDFStream,
|
||||
PDFObject,
|
||||
PDFRef,
|
||||
PDFRawStream,
|
||||
PDFFlateStream,
|
||||
PDFPageLeaf,
|
||||
PDFContext,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } 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';
|
||||
import type { PDFDocumentInternal } from '@/types';
|
||||
|
||||
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
|
||||
return pdfDoc.catalog;
|
||||
@@ -48,22 +33,6 @@ 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;
|
||||
@@ -105,11 +74,10 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
pdfDoc.setProducer('');
|
||||
|
||||
try {
|
||||
forEachDict(pdfDoc, (dict) => {
|
||||
dict.delete(PDFName.of('Metadata'));
|
||||
dict.delete(PDFName.of('PieceInfo'));
|
||||
dict.delete(PDFName.of('Thumb'));
|
||||
});
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
if (catalogDict.has(PDFName.of('Metadata'))) {
|
||||
catalogDict.delete(PDFName.of('Metadata'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove XMP metadata:', getErrorMessage(e));
|
||||
}
|
||||
@@ -122,6 +90,15 @@ 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) {
|
||||
@@ -138,72 +115,9 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
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 flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -226,13 +140,92 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
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('OpenAction'))) {
|
||||
catalogDict.delete(PDFName.of('OpenAction'));
|
||||
}
|
||||
|
||||
forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -309,410 +302,37 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
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 NEWLINE = new Uint8Array([0x0a]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
}
|
||||
|
||||
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]);
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
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);
|
||||
if (pageDict.has(PDFName.of('OCProperties'))) {
|
||||
pageDict.delete(PDFName.of('OCProperties'));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove page layers:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -945,9 +565,8 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) {
|
||||
export async function sanitizePdf(
|
||||
pdfBytes: Uint8Array,
|
||||
options: SanitizeOptions
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> {
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
|
||||
const pdfDoc = await loadPdfDocument(pdfBytes);
|
||||
const skipped: string[] = [];
|
||||
|
||||
if (options.flattenForms) {
|
||||
try {
|
||||
@@ -964,7 +583,6 @@ export async function sanitizePdf(
|
||||
'Could not remove AcroForm:',
|
||||
getErrorMessage(removeError)
|
||||
);
|
||||
skipped.push('flatten forms');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,7 +600,6 @@ export async function sanitizePdf(
|
||||
removeJavascriptFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove JavaScript');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,7 +608,6 @@ export async function sanitizePdf(
|
||||
removeEmbeddedFilesFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove embedded files');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,7 +616,6 @@ export async function sanitizePdf(
|
||||
removeLayersFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove layers: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove layers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +624,6 @@ export async function sanitizePdf(
|
||||
removeLinksFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove links: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove links');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,7 +632,6 @@ export async function sanitizePdf(
|
||||
removeStructureTreeFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove the structure tree');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,7 +640,6 @@ export async function sanitizePdf(
|
||||
removeMarkInfoFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove MarkInfo');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,14 +648,9 @@ 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: await removeUnreferencedObjects(savedBytes),
|
||||
skipped,
|
||||
};
|
||||
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const ALLOWED_TAGS = ['br', 'strong', 'em'];
|
||||
|
||||
export function sanitizeTranslationHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] });
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
@@ -42,10 +41,6 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
||||
if (typeof value !== typeof current) {
|
||||
return current;
|
||||
}
|
||||
return sanitizeWithControlSpec(
|
||||
getControlSpec(this.nodeType, key),
|
||||
value,
|
||||
current
|
||||
);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../../utils/flatten-annotations.js';
|
||||
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
@@ -33,7 +30,8 @@ export class FlattenNode extends BaseWorkflowNode {
|
||||
const pdfDoc = await loadPdfDocument(input.bytes);
|
||||
|
||||
try {
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
} catch (err) {
|
||||
console.error('Flatten form error (may have no forms):', err);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export class RedactNode extends BaseWorkflowNode {
|
||||
|
||||
const resultBytes = await redactDocumentData(
|
||||
redactedBytes,
|
||||
mode === 'text' ? [searchText] : []
|
||||
mode === 'text' ? searchText : undefined
|
||||
);
|
||||
|
||||
const resultDoc = await loadPdfDocument(resultBytes);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: 5 })
|
||||
new ClassicPreset.InputControl('number', { initial: 250 })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,11 +84,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -19,24 +18,6 @@ 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>
|
||||
@@ -75,13 +56,11 @@ function serializeWorkflow(
|
||||
});
|
||||
}
|
||||
|
||||
const workflow: SerializedWorkflow = {
|
||||
return {
|
||||
version: WORKFLOW_VERSION,
|
||||
nodes,
|
||||
connections,
|
||||
};
|
||||
clearPasswordControls(workflow);
|
||||
return workflow;
|
||||
} as SerializedWorkflow;
|
||||
}
|
||||
|
||||
async function deserializeWorkflow(
|
||||
@@ -117,7 +96,6 @@ 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(
|
||||
@@ -166,19 +144,7 @@ function getStoredTemplates(): StoredTemplates {
|
||||
const json = localStorage.getItem(TEMPLATES_KEY);
|
||||
if (!json) return {};
|
||||
try {
|
||||
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;
|
||||
return JSON.parse(json) as StoredTemplates;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -350,14 +350,9 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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
|
||||
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
|
||||
and check before you share it.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -401,13 +401,12 @@
|
||||
<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. 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.
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('redactDocumentData', () => {
|
||||
SECRET.toLowerCase()
|
||||
);
|
||||
|
||||
const output = await redactDocumentData(input, [secret()]);
|
||||
const output = await redactDocumentData(input, secret());
|
||||
const text = await allText(output);
|
||||
|
||||
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
|
||||
@@ -200,62 +200,6 @@ describe('redactDocumentData', () => {
|
||||
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);
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
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
@@ -1 +1 @@
|
||||
051abe5b72a9
|
||||
813903c93c75
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
051abe5b72a9
|
||||
813903c93c75
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+3
-3
@@ -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/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'"
|
||||
"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'"
|
||||
},
|
||||
{ "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' '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'"
|
||||
"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'"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
"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'"
|
||||
"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'"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user